mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-05-05 04:54:53 +00:00
Merge pull request #1 from HolgerHatGarKeineNode/claude/fix-security-flaws-vZgBB
Implement NIP-42 Nostr login and enhance security
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"sessionId":"95f2d618-b0a9-4b52-b725-3a049ae47e93","pid":146659,"procStart":"1263491","acquiredAt":1777828038888}
|
||||
@@ -1,473 +0,0 @@
|
||||
# Livewire Flux Component Guidelines
|
||||
## Usage Pattern
|
||||
Components use the format: `<flux:name />`
|
||||
## Component Reference
|
||||
### Layout & Structure
|
||||
- **flux:card** - Basic container with default slot
|
||||
- **flux:field** - Form field wrapper with label/description support
|
||||
- **flux:brand** - Logo/company name display with href navigation
|
||||
|
||||
### Navigation
|
||||
- **flux:breadcrumbs** - Navigation breadcrumbs
|
||||
- **flux:breadcrumbs.item** - Individual breadcrumb with href/icon
|
||||
|
||||
- **flux:accordion** - Collapsible content sections
|
||||
- **flux:accordion.item** - Individual accordion item with heading/content
|
||||
- **flux:accordion.heading** - Accordion header
|
||||
- **flux:accordion.content** - Accordion body
|
||||
|
||||
### Form Controls
|
||||
- **flux:input** - Text input with wire:model, validation, icons, masks
|
||||
- **flux:select** - Select input
|
||||
- **flux:select.option** - Select options
|
||||
- **flux:autocomplete** - Searchable input with dropdown items
|
||||
- **flux:checkbox** - Single checkbox or grouped checkboxes
|
||||
- **flux:date-picker** - Date selection with calendar, ranges, presets
|
||||
- **flux:editor** - Rich text editor with toolbar
|
||||
|
||||
### Interactive Elements
|
||||
- **flux:button** - Button with variants (primary, outline, danger), icons, loading states
|
||||
- **flux:dropdown** - Dropdown menu with positioning options
|
||||
- **flux:menu** - Complex menu with items, submenus, separators, checkboxes, radio buttons
|
||||
- **flux:command** - Command palette with searchable items
|
||||
- **flux:context** - Right-click context menu wrapper
|
||||
|
||||
### Display Components
|
||||
- **flux:avatar** - User avatar with initials, images, badges, grouping
|
||||
- **flux:badge** - Status/label badges with colors and variants
|
||||
- **flux:callout** - Highlighted information blocks with icons and actions
|
||||
- **flux:calendar** - Calendar display with date selection modes
|
||||
- **flux:chart** - Data visualization with lines, areas, axes, tooltips
|
||||
|
||||
### Key Props
|
||||
- **wire:model** - Livewire property binding
|
||||
- **variant** - Visual style options (outline, primary, filled, etc.)
|
||||
- **size** - Component sizing (xs, sm, base, lg, xl, 2xl)
|
||||
- **disabled/invalid** - State management
|
||||
- **icon/icon:trailing** - Icon placement with variants
|
||||
- **label/description** - Form field labeling
|
||||
- **color** - Color theming options
|
||||
|
||||
### Common Patterns
|
||||
- Most form components support wire:model binding
|
||||
- Many components have label/description props for field wrapping
|
||||
- Icon components accept variant options (outline, solid, mini, micro)
|
||||
- Size props typically offer xs, sm, base, lg, xl, 2xl options
|
||||
- Variant props provide visual style alternatives
|
||||
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.5.5
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/nightwatch (NIGHTWATCH) - v1
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/flux-pro (FLUXUI_PRO) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `yarn run build`, `yarn run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `yarn run build` or ask the user to run `yarn run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version-specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== fluxui-pro/core rules ===
|
||||
|
||||
## Flux UI Pro
|
||||
|
||||
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
|
||||
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
|
||||
- You should use Flux UI components when available.
|
||||
- Fallback to standard Blade components if Flux is unavailable.
|
||||
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
|
||||
- Flux UI components look like this:
|
||||
|
||||
<code-snippet name="Flux UI Component Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
|
||||
</available-flux-components>
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire
|
||||
|
||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing; don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind CSS 4
|
||||
|
||||
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||
|
||||
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
</laravel-boost-guidelines>
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "/usr/bin/php",
|
||||
"args": [
|
||||
"/var/home/user/Code/einundzwanzig-app/artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
@@ -111,6 +111,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Meetup;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -17,14 +16,10 @@ class MeetupController extends Controller
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (!is_numeric($request->input('user_id'))) {
|
||||
abort(404);
|
||||
}
|
||||
$user = $request->user();
|
||||
abort_unless($user, 401);
|
||||
|
||||
$myMeetupIds = User::query()
|
||||
->findOrFail($request->input('user_id'))
|
||||
?->meetups
|
||||
->pluck('id');
|
||||
$myMeetupIds = $user->meetups->pluck('id');
|
||||
|
||||
return Meetup::query()
|
||||
->select('id', 'name', 'city_id', 'slug')
|
||||
|
||||
@@ -29,7 +29,17 @@ class DownloadMeetupCalendar extends Controller
|
||||
$events = $meetup->meetupEvents()->where('start', '>=', now())->get();
|
||||
$image = $meetup->getFirstMediaUrl('logo');
|
||||
} elseif ($request->has('my')) {
|
||||
$ids = $request->input('my');
|
||||
$validated = $request->validate([
|
||||
'my' => ['required', 'array'],
|
||||
'my.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$ids = $validated['my'];
|
||||
if (auth()->check()) {
|
||||
$ownedIds = auth()->user()->meetups->pluck('id')->all();
|
||||
$ids = array_values(array_intersect($ids, $ownedIds));
|
||||
}
|
||||
|
||||
$events = MeetupEvent::query()
|
||||
->with([
|
||||
'meetup',
|
||||
|
||||
@@ -12,6 +12,8 @@ class ImageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, $path)
|
||||
{
|
||||
abort_if(str_contains($path, '..'), 404);
|
||||
|
||||
$source = new \League\Flysystem\Filesystem(
|
||||
new \League\Flysystem\Local\LocalFilesystemAdapter(storage_path('app'))
|
||||
);
|
||||
|
||||
@@ -154,14 +154,10 @@ final class LnurlAuthController extends Controller
|
||||
*/
|
||||
private function ensureLoginKeyExists(string $k1, int $userId): void
|
||||
{
|
||||
$loginKey = LoginKey::where('k1', $k1)->first();
|
||||
|
||||
if (! $loginKey) {
|
||||
LoginKey::create([
|
||||
'k1' => $k1,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
LoginKey::query()->updateOrCreate(
|
||||
['k1' => $k1],
|
||||
['user_id' => $userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,15 @@ class FetchNostrProfileJob implements ShouldQueue
|
||||
|
||||
private function downloadAndSaveProfilePhoto(User $user, string $photoUrl): void
|
||||
{
|
||||
if (!$this->isPublicHttpUrl($photoUrl)) {
|
||||
\Log::warning('Refused to download Nostr profile photo from disallowed URL', [
|
||||
'user_id' => $user->id,
|
||||
'url' => $photoUrl,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Download the image from the URL
|
||||
$response = Http::timeout(10)->get($photoUrl);
|
||||
@@ -178,6 +187,41 @@ class FetchNostrProfileJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject URLs that are not http(s) or that resolve to a private/loopback
|
||||
* address, to prevent SSRF when fetching arbitrary profile photo URLs.
|
||||
*/
|
||||
private function isPublicHttpUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $parts['host'];
|
||||
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
|
||||
|
||||
if (empty($ips)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($ips as $ip) {
|
||||
if (!filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getImageExtension(?string $contentType, string $url): string
|
||||
{
|
||||
// Try to get extension from content type
|
||||
|
||||
@@ -20,11 +20,13 @@ class Course extends Model implements HasMedia
|
||||
use InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'lecturer_id',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
|
||||
+17
-4
@@ -22,11 +22,24 @@ class Lecturer extends Model implements HasMedia
|
||||
use InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'subtitle',
|
||||
'intro',
|
||||
'description',
|
||||
'active',
|
||||
'website',
|
||||
'twitter_username',
|
||||
'nostr',
|
||||
'lightning_address',
|
||||
'lnurl',
|
||||
'node_id',
|
||||
'paynym',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
|
||||
+18
-4
@@ -23,11 +23,25 @@ class Meetup extends Model implements HasMedia
|
||||
use InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* The attributes that aren't mass assignable.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'city_id',
|
||||
'intro',
|
||||
'telegram_link',
|
||||
'webpage',
|
||||
'twitter_username',
|
||||
'matrix_group',
|
||||
'nostr',
|
||||
'nostr_status',
|
||||
'simplex',
|
||||
'signal',
|
||||
'community',
|
||||
'github_data',
|
||||
'visible_on_map',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
|
||||
@@ -22,7 +22,22 @@ class SelfHostedService extends Model implements HasMedia
|
||||
use HasTags;
|
||||
use InteractsWithMedia;
|
||||
|
||||
protected $guarded = [];
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'type',
|
||||
'intro',
|
||||
'url_clearnet',
|
||||
'url_onion',
|
||||
'url_i2p',
|
||||
'url_pkdns',
|
||||
'ip',
|
||||
'contact',
|
||||
'anon',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'integer',
|
||||
|
||||
+22
-1
@@ -24,7 +24,28 @@ class User extends Authenticatable implements CipherSweetEncrypted
|
||||
use Notifiable;
|
||||
use UsesCipherSweet;
|
||||
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'email_verified_at',
|
||||
'remember_token',
|
||||
'profile_photo_path',
|
||||
'public_key',
|
||||
'is_lecturer',
|
||||
'is_leader',
|
||||
'current_team_id',
|
||||
'current_language',
|
||||
'timezone',
|
||||
'lightning_address',
|
||||
'lnurl',
|
||||
'node_id',
|
||||
'paynym',
|
||||
'nostr',
|
||||
'lnbits',
|
||||
'change',
|
||||
'change_time',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||
@@ -36,6 +37,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
|
||||
if ($this->app->environment('production')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
Livewire::setUpdateRoute(function ($handle) {
|
||||
return Route::post('/livewire/update', $handle)
|
||||
->middleware(['web', Sample::rate(0)]);
|
||||
|
||||
@@ -16,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->web(append: [
|
||||
DomainMiddleware::class,
|
||||
LangCountrySession::class,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {npubEncode} from "nostr-tools/nip19";
|
||||
|
||||
export default () => ({
|
||||
pollingInterval: null,
|
||||
errorCheckInterval: null,
|
||||
@@ -13,11 +11,21 @@ export default () => ({
|
||||
},
|
||||
|
||||
async openNostrLogin() {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const npub = npubEncode(pubkey);
|
||||
console.log(pubkey);
|
||||
console.log(npub);
|
||||
this.$dispatch('nostrLoggedIn', {pubkey: npub});
|
||||
const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire;
|
||||
const challenge = livewireComponent?.$wire?.nostrChallenge;
|
||||
if (!challenge) {
|
||||
this.showAuthError('Login challenge missing. Please reload and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['challenge', challenge]],
|
||||
content: '',
|
||||
});
|
||||
|
||||
this.$dispatch('nostrLoggedIn', {signedEvent});
|
||||
},
|
||||
|
||||
initErrorPolling() {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ __('Meetups') }}</span>
|
||||
<img alt="{{ request()->route('country') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.request()->route('country').'.svg') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.strtolower(request()->route('country')).'.svg') }}"
|
||||
width="24" height="12"/>
|
||||
</div>
|
||||
</flux:navlist.item>
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ __('Karte') }}</span>
|
||||
<img alt="{{ request()->route('country') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.request()->route('country').'.svg') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.strtolower(request()->route('country')).'.svg') }}"
|
||||
width="24" height="12"/>
|
||||
</div>
|
||||
</flux:navlist.item>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ __('Kurse') }}</span>
|
||||
<img alt="{{ request()->route('country') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.request()->route('country').'.svg') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.strtolower(request()->route('country')).'.svg') }}"
|
||||
width="24" height="12"/>
|
||||
</div>
|
||||
</flux:navlist.item>
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ __('Dozenten') }}</span>
|
||||
<img alt="{{ request()->route('country') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.request()->route('country').'.svg') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.strtolower(request()->route('country')).'.svg') }}"
|
||||
width="24" height="12"/>
|
||||
</div>
|
||||
</flux:navlist.item>
|
||||
|
||||
@@ -13,10 +13,13 @@ use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use swentel\nostr\Event\Event as NostrEvent;
|
||||
use swentel\nostr\Key\Key as NostrKey;
|
||||
|
||||
new #[Layout('components.layouts.auth')]
|
||||
class extends Component {
|
||||
@@ -42,6 +45,11 @@ class extends Component {
|
||||
|
||||
public ?string $authError = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $nostrChallenge = null;
|
||||
|
||||
private const NOSTR_CHALLENGE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* Handle authError property type conversion.
|
||||
* Ensure array values from frontend are converted to string or null.
|
||||
@@ -57,6 +65,10 @@ class extends Component {
|
||||
{
|
||||
$this->currentLangCountry = session('lang_country') ?? 'de-DE';
|
||||
|
||||
$this->nostrChallenge = bin2hex(random_bytes(32));
|
||||
Session::put('nostr_login_challenge', $this->nostrChallenge);
|
||||
Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp);
|
||||
|
||||
// Nur beim ersten Mount initialisieren
|
||||
if ($this->k1 === null) {
|
||||
$this->k1 = bin2hex(str()->random(32));
|
||||
@@ -80,18 +92,19 @@ class extends Component {
|
||||
}
|
||||
|
||||
#[On('nostrLoggedIn')]
|
||||
public function loginListener($pubkey): void
|
||||
public function loginListener($signedEvent = null): void
|
||||
{
|
||||
$user = \App\Models\User::query()->where('nostr', $pubkey)->first();
|
||||
$npub = $this->verifyNostrLoginEvent($signedEvent);
|
||||
|
||||
$user = User::query()->where('nostr', $npub)->first();
|
||||
if (!$user) {
|
||||
$fakeName = str()->random(10);
|
||||
// create User
|
||||
$user = User::create([
|
||||
'public_key' => null,
|
||||
'is_lecturer' => true,
|
||||
'name' => $fakeName,
|
||||
'email' => str($pubkey)->substr(-12).'@portal.einundzwanzig.space',
|
||||
'nostr' => $pubkey,
|
||||
'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space',
|
||||
'nostr' => $npub,
|
||||
'lnbits' => [
|
||||
'read_key' => null,
|
||||
'url' => null,
|
||||
@@ -137,6 +150,79 @@ class extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-42-style signed login event and return the user's npub.
|
||||
*
|
||||
* Throws ValidationException on any invalid input — never trust client data.
|
||||
*/
|
||||
protected function verifyNostrLoginEvent(mixed $signedEvent): string
|
||||
{
|
||||
if (!is_array($signedEvent)) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
$required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
||||
foreach ($required as $key) {
|
||||
if (!array_key_exists($key, $signedEvent)) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $signedEvent['kind'] !== 22242) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
$expectedChallenge = Session::get('nostr_login_challenge');
|
||||
$expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0);
|
||||
|
||||
if (!is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) {
|
||||
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
$challengeFromEvent = null;
|
||||
foreach ($signedEvent['tags'] as $tag) {
|
||||
if (is_array($tag) && ($tag[0] ?? null) === 'challenge') {
|
||||
$challengeFromEvent = (string) ($tag[1] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($challengeFromEvent === null || !hash_equals($expectedChallenge, $challengeFromEvent)) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
$createdAt = (int) $signedEvent['created_at'];
|
||||
if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
$eventJson = json_encode([
|
||||
'id' => (string) $signedEvent['id'],
|
||||
'pubkey' => (string) $signedEvent['pubkey'],
|
||||
'created_at' => $createdAt,
|
||||
'kind' => 22242,
|
||||
'tags' => $signedEvent['tags'],
|
||||
'content' => (string) $signedEvent['content'],
|
||||
'sig' => (string) $signedEvent['sig'],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$isValid = false;
|
||||
try {
|
||||
$isValid = (new NostrEvent())->verify($eventJson);
|
||||
} catch (\Throwable $e) {
|
||||
$isValid = false;
|
||||
}
|
||||
|
||||
if (!$isValid) {
|
||||
throw ValidationException::withMessages(['email' => __('auth.failed')]);
|
||||
}
|
||||
|
||||
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
|
||||
|
||||
return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the authentication request is not rate limited.
|
||||
*/
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\SeoTrait;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
new #[Layout('components.layouts.auth')]
|
||||
class extends Component {
|
||||
use SeoTrait;
|
||||
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
event(new Registered(($user = User::create($validated))));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->redirectIntended(route('dashboard', ['country' => str(session('lang_country', config('app.domain_country')))->after('-')->lower()],absolute: false), navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Create an account')"
|
||||
:description="__('Enter your details below to create your account')"/>
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')"/>
|
||||
|
||||
<form wire:submit="register" class="flex flex-col gap-6">
|
||||
<!-- Name -->
|
||||
<flux:input
|
||||
wire:model="name"
|
||||
:label="__('Name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
:placeholder="__('Full name')"
|
||||
/>
|
||||
|
||||
<!-- Email Address -->
|
||||
<flux:input
|
||||
wire:model="email"
|
||||
:label="__('Email address')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<flux:input
|
||||
wire:model="password"
|
||||
:label="__('Password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Password')"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<flux:input
|
||||
wire:model="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:placeholder="__('Confirm password')"
|
||||
viewable
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button type="submit" variant="primary" class="w-full">
|
||||
{{ __('Create account') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $logo;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
@@ -16,7 +16,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $logo;
|
||||
|
||||
public Course $course;
|
||||
|
||||
@@ -87,7 +87,7 @@ class extends Component {
|
||||
<!-- Lecturer Social Links -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if($course->lecturer->website)
|
||||
<flux:button href="{{ $course->lecturer->website }}" target="_blank" variant="ghost"
|
||||
<flux:button href="{{ $course->lecturer->website }}" target="_blank" rel="noopener noreferrer" variant="ghost"
|
||||
size="xs">
|
||||
<flux:icon.globe-alt class="w-4 h-4 mr-1"/>
|
||||
Website
|
||||
@@ -96,7 +96,7 @@ class extends Component {
|
||||
|
||||
@if($course->lecturer->twitter_username)
|
||||
<flux:button href="https://twitter.com/{{ $course->lecturer->twitter_username }}"
|
||||
target="_blank" variant="ghost" size="xs">
|
||||
target="_blank" rel="noopener noreferrer" variant="ghost" size="xs">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
@@ -106,7 +106,7 @@ class extends Component {
|
||||
@endif
|
||||
|
||||
@if($course->lecturer->nostr)
|
||||
<flux:button href="https://njump.me/{{ $course->lecturer->nostr }}" target="_blank"
|
||||
<flux:button href="https://njump.me/{{ $course->lecturer->nostr }}" target="_blank" rel="noopener noreferrer"
|
||||
variant="ghost" size="xs">
|
||||
<flux:icon.bolt class="w-4 h-4 mr-1"/>
|
||||
Nostr
|
||||
@@ -173,6 +173,7 @@ class extends Component {
|
||||
<div class="mt-auto pt-4 flex gap-2">
|
||||
<flux:button
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:href="$event->link"
|
||||
size="xs"
|
||||
variant="primary"
|
||||
|
||||
@@ -96,7 +96,7 @@ class extends Component {
|
||||
<a href="{{ route('meetups.map', ['country' => $country->code]) }}">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<img alt="{{ $country->code }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.$country->code.'.svg') }}"
|
||||
src="{{ asset('vendor/blade-flags/country-'.strtolower($country->code).'.svg') }}"
|
||||
width="24" height="12"/>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ $country->name }}</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $avatar;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
@@ -15,7 +15,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $avatar;
|
||||
|
||||
public Lecturer $lecturer;
|
||||
@@ -45,8 +45,17 @@ class extends Component {
|
||||
#[Locked]
|
||||
public ?string $updated_at = null;
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
if (!is_null($this->lecturer->created_by) && auth()->id() !== $this->lecturer->created_by) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$this->lecturer->load('media');
|
||||
|
||||
$this->name = $this->lecturer->name ?? '';
|
||||
@@ -70,6 +79,8 @@ class extends Component {
|
||||
|
||||
public function updateLecturer(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)],
|
||||
'subtitle' => ['nullable', 'string'],
|
||||
|
||||
@@ -15,7 +15,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $logo;
|
||||
|
||||
// Basic Information
|
||||
|
||||
@@ -17,7 +17,7 @@ class extends Component {
|
||||
use WithFileUploads;
|
||||
use SeoTrait;
|
||||
|
||||
#[Validate('image|max:10240')] // 10MB Max
|
||||
#[Validate('image|mimes:jpeg,png,webp|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||
public $logo;
|
||||
|
||||
public Meetup $meetup;
|
||||
@@ -83,8 +83,46 @@ class extends Component {
|
||||
\Flux\Flux::modal('add-city')->close();
|
||||
}
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
if (!is_null($this->meetup->created_by) && auth()->id() !== $this->meetup->created_by) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist the keys allowed inside github_data and coerce types so a
|
||||
* tampered payload cannot smuggle arbitrary keys into the stored JSON.
|
||||
*/
|
||||
protected function sanitizeGithubData(?string $raw): ?array
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
if (array_key_exists('top', $decoded) && (is_string($decoded['top']) || is_numeric($decoded['top']))) {
|
||||
$clean['top'] = (string) $decoded['top'];
|
||||
}
|
||||
if (array_key_exists('left', $decoded) && (is_string($decoded['left']) || is_numeric($decoded['left']))) {
|
||||
$clean['left'] = (string) $decoded['left'];
|
||||
}
|
||||
if (array_key_exists('state', $decoded) && is_string($decoded['state'])) {
|
||||
$clean['state'] = mb_substr($decoded['state'], 0, 64);
|
||||
}
|
||||
|
||||
return $clean === [] ? null : $clean;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$this->meetup->load('media');
|
||||
|
||||
// Basic Information
|
||||
@@ -117,6 +155,8 @@ class extends Component {
|
||||
|
||||
public function updateMeetup(): void
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)],
|
||||
'city_id' => ['nullable', 'exists:cities,id'],
|
||||
@@ -129,19 +169,10 @@ class extends Component {
|
||||
'simplex' => ['nullable', 'string', 'max:255'],
|
||||
'signal' => ['nullable', 'string', 'max:255'],
|
||||
'community' => ['required', 'string', 'max:255'],
|
||||
'github_data' => ['nullable', 'json'],
|
||||
]);
|
||||
|
||||
// Convert github_data string back to array if provided
|
||||
if (!empty($validated['github_data'])) {
|
||||
$decoded = json_decode($validated['github_data'], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$validated['github_data'] = $decoded;
|
||||
} else {
|
||||
$validated['github_data'] = null;
|
||||
}
|
||||
} else {
|
||||
$validated['github_data'] = null;
|
||||
}
|
||||
$validated['github_data'] = $this->sanitizeGithubData($validated['github_data'] ?? null);
|
||||
|
||||
$this->meetup->update($validated);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class extends Component {
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@if($meetup->webpage)
|
||||
<flux:button href="{{ $meetup->webpage }}" target="_blank" variant="ghost"
|
||||
<flux:button href="{{ $meetup->webpage }}" target="_blank" rel="noopener noreferrer" variant="ghost"
|
||||
class="justify-start">
|
||||
<flux:icon.globe-alt class="w-5 h-5 mr-2"/>
|
||||
Webseite
|
||||
@@ -92,7 +92,7 @@ class extends Component {
|
||||
@endif
|
||||
|
||||
@if($meetup->telegram_link)
|
||||
<flux:button href="{{ $meetup->telegram_link }}" target="_blank" variant="ghost"
|
||||
<flux:button href="{{ $meetup->telegram_link }}" target="_blank" rel="noopener noreferrer" variant="ghost"
|
||||
class="justify-start">
|
||||
<flux:icon.chat-bubble-left-right class="w-5 h-5 mr-2"/>
|
||||
Telegram
|
||||
@@ -100,7 +100,7 @@ class extends Component {
|
||||
@endif
|
||||
|
||||
@if($meetup->twitter_username)
|
||||
<flux:button href="https://twitter.com/{{ $meetup->twitter_username }}" target="_blank"
|
||||
<flux:button href="https://twitter.com/{{ $meetup->twitter_username }}" target="_blank" rel="noopener noreferrer"
|
||||
variant="ghost" class="justify-start">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -111,7 +111,7 @@ class extends Component {
|
||||
@endif
|
||||
|
||||
@if($meetup->matrix_group)
|
||||
<flux:button href="{{ $meetup->matrix_group }}" target="_blank" variant="ghost"
|
||||
<flux:button href="{{ $meetup->matrix_group }}" target="_blank" rel="noopener noreferrer" variant="ghost"
|
||||
class="justify-start">
|
||||
<flux:icon.hashtag class="w-5 h-5 mr-2"/>
|
||||
Matrix
|
||||
@@ -119,14 +119,14 @@ class extends Component {
|
||||
@endif
|
||||
|
||||
@if($meetup->signal)
|
||||
<flux:button href="{{ $meetup->signal }}" target="_blank" variant="ghost" class="justify-start">
|
||||
<flux:button href="{{ $meetup->signal }}" target="_blank" rel="noopener noreferrer" variant="ghost" class="justify-start">
|
||||
<flux:icon.phone class="w-5 h-5 mr-2"/>
|
||||
Signal
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if($meetup->simplex)
|
||||
<flux:button href="{{ $meetup->simplex }}" target="_blank" variant="ghost"
|
||||
<flux:button href="{{ $meetup->simplex }}" target="_blank" rel="noopener noreferrer" variant="ghost"
|
||||
class="justify-start">
|
||||
<flux:icon.chat-bubble-oval-left-ellipsis class="w-5 h-5 mr-2"/>
|
||||
SimpleX
|
||||
|
||||
+4
-2
@@ -11,7 +11,7 @@ use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware([])
|
||||
Route::middleware(['throttle:60,1'])
|
||||
->as('api.')
|
||||
->group(function () {
|
||||
Route::resource('countries', CountryController::class);
|
||||
@@ -22,7 +22,9 @@ Route::middleware([])
|
||||
Route::resource('cities', CityController::class);
|
||||
Route::resource('venues', VenueController::class);
|
||||
Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index');
|
||||
Route::post('highscores', [HighscoreController::class, 'store'])->name('highscores.store');
|
||||
Route::post('highscores', [HighscoreController::class, 'store'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('highscores.store');
|
||||
Route::get('nostrplebs', function () {
|
||||
return User::query()
|
||||
->select([
|
||||
|
||||
+2
-4
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')
|
||||
@@ -8,9 +9,6 @@ Route::middleware('guest')
|
||||
Route::livewire('/login', 'auth.login')
|
||||
->name('login');
|
||||
|
||||
Route::livewire('/register', 'auth.register')
|
||||
->name('register');
|
||||
|
||||
Route::livewire('/forgot-password', 'auth.forgot-password')
|
||||
->name('password.request');
|
||||
|
||||
@@ -31,5 +29,5 @@ Route::middleware('auth')
|
||||
->name('password.confirm');
|
||||
});
|
||||
|
||||
Route::post('logout', App\Livewire\Actions\Logout::class)
|
||||
Route::post('logout', Logout::class)
|
||||
->name('logout');
|
||||
|
||||
+2
-9
@@ -2,20 +2,13 @@
|
||||
|
||||
use App\Http\Controllers\DownloadMeetupCalendar;
|
||||
use App\Http\Controllers\ImageController;
|
||||
use App\Jobs\FetchNostrProfileJob;
|
||||
use App\Livewire\Helper\FollowTheRabbit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||
|
||||
// Redirect root URL to 'welcome' page
|
||||
Route::redirect('/', 'welcome');
|
||||
|
||||
// Test route that dispatches a job to fetch Nostr profile for user with ID 1426
|
||||
Route::get('test', function () {
|
||||
FetchNostrProfileJob::dispatchSync(User::find(1426));
|
||||
});
|
||||
|
||||
// Error page route that aborts with given HTTP status code (digits only,
|
||||
// constrained to valid 4xx/5xx range to avoid TypeErrors from bot scans).
|
||||
Route::get('error/{code}', function (string $code) {
|
||||
@@ -50,12 +43,12 @@ Route::livewire('/kaninchenbau', FollowTheRabbit::class)
|
||||
|
||||
// Generic image handler route that serves images from storage
|
||||
Route::get('/img/{path}', ImageController::class)
|
||||
->where('path', '.*')
|
||||
->where('path', '[A-Za-z0-9._\-/]+')
|
||||
->name('img');
|
||||
|
||||
// Public image handler route for serving public images
|
||||
Route::get('/img-public/{path}', ImageController::class)
|
||||
->where('path', '.*')
|
||||
->where('path', '[A-Za-z0-9._\-/]+')
|
||||
->name('imgPublic');
|
||||
|
||||
// Welcome page route using Volt component
|
||||
|
||||
@@ -7,9 +7,3 @@ it('renders the login page with QR code and language selector', function () {
|
||||
->assertSee('Bitcoin, not blockchain')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('renders the registration page', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\Venue;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->country = Country::factory()->create(['code' => 'de', 'name' => 'Deutschland']);
|
||||
});
|
||||
|
||||
it('creates a new city end-to-end with valid coordinates', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/city-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'BrowserTestCity')
|
||||
->fill('[wire\\:model="latitude"]', '52.520008')
|
||||
->fill('[wire\\:model="longitude"]', '13.404954')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(2)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(City::query()->where('name', 'BrowserTestCity')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows validation errors when city form is submitted empty', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/city-create');
|
||||
|
||||
$page->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(City::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates a new venue connected to an existing city', function () {
|
||||
actingAsUser();
|
||||
$city = City::factory()->create(['country_id' => $this->country->id, 'name' => 'VenueTestCity']);
|
||||
|
||||
$page = visit('/de/venue-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'BrowserTestVenue')
|
||||
->fill('[wire\\:model="street"]', 'Teststraße 1');
|
||||
$page->script("Livewire.getByName('venues.create')[0].set('city_id', {$city->id})");
|
||||
$page->wait(0.5)
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(2)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Venue::query()->where('name', 'BrowserTestVenue')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a validation error when creating a venue without a name', function () {
|
||||
actingAsUser();
|
||||
City::factory()->create(['country_id' => $this->country->id]);
|
||||
|
||||
$page = visit('/de/venue-create');
|
||||
|
||||
$page->fill('[wire\\:model="street"]', 'No-Name Street')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Venue::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Lecturer;
|
||||
|
||||
it('creates a new lecturer end-to-end with valid data', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/lecturer-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'BrowserTester Saylor')
|
||||
->fill('[wire\\:model="subtitle"]', 'Browser Test Subject')
|
||||
->fill('[wire\\:model="intro"]', 'A short intro line.')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(2)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Lecturer::query()->where('name', 'BrowserTester Saylor')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a required error when submitting without a lecturer name', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/lecturer-create');
|
||||
|
||||
$page->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Lecturer::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects creation when the lecturer name already exists', function () {
|
||||
actingAsUser();
|
||||
Lecturer::factory()->create(['name' => 'Existing Saylor']);
|
||||
|
||||
$page = visit('/de/lecturer-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'Existing Saylor')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Lecturer::query()->where('name', 'Existing Saylor')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects creation with an invalid website URL', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/lecturer-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'Bad URL Lecturer')
|
||||
->fill('[wire\\:model="website"]', 'not-a-url')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Lecturer::query()->where('name', 'Bad URL Lecturer')->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\Meetup;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->country = Country::factory()->create(['code' => 'de', 'name' => 'Deutschland']);
|
||||
$this->city = City::factory()->create([
|
||||
'country_id' => $this->country->id,
|
||||
'name' => 'BrowserMeetupTestCity',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a new meetup end-to-end via the create form', function () {
|
||||
actingAsUser();
|
||||
$cityId = $this->city->id;
|
||||
|
||||
$page = visit('/de/meetup-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'BrowserSeeded Meetup');
|
||||
$page->script("Livewire.getByName('meetups.create')[0].set('city_id', {$cityId})");
|
||||
$page->wait(0.5)
|
||||
->select('[wire\\:model="community"]', 'einundzwanzig');
|
||||
$page->script("Livewire.getByName('meetups.create')[0].call('createMeetup')");
|
||||
$page->wait(2)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Meetup::query()->where('name', 'BrowserSeeded Meetup')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks meetup creation when the name is missing', function () {
|
||||
actingAsUser();
|
||||
$cityId = $this->city->id;
|
||||
|
||||
$page = visit('/de/meetup-create');
|
||||
|
||||
$page->script("Livewire.getByName('meetups.create')[0].set('city_id', {$cityId})");
|
||||
$page->wait(0.5)
|
||||
->select('[wire\\:model="community"]', 'einundzwanzig');
|
||||
$page->script("Livewire.getByName('meetups.create')[0].call('createMeetup')");
|
||||
$page->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Meetup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('blocks meetup creation when no city is selected', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/meetup-create');
|
||||
|
||||
$page->fill('[wire\\:model="name"]', 'NoCityMeetup')
|
||||
->select('[wire\\:model="community"]', 'einundzwanzig');
|
||||
$page->script("Livewire.getByName('meetups.create')[0].call('createMeetup')");
|
||||
$page->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(Meetup::query()->where('name', 'NoCityMeetup')->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SelfHostedServiceType;
|
||||
use App\Models\City;
|
||||
use App\Models\Country;
|
||||
use App\Models\Meetup;
|
||||
use App\Models\SelfHostedService;
|
||||
|
||||
/**
|
||||
* NOTE: The Search-Inputs of the index pages use Postgres `ilike`, which the
|
||||
* SQLite test database does not support. We therefore exercise the type-badge
|
||||
* filter (uses plain `=`) instead and assert the index list itself reacts
|
||||
* to it.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->country = Country::factory()->create(['code' => 'de']);
|
||||
$this->city = City::factory()->create(['country_id' => $this->country->id]);
|
||||
});
|
||||
|
||||
it('renders all seeded services on the public services index', function () {
|
||||
SelfHostedService::factory()->create(['name' => 'NodeAlpha', 'type' => SelfHostedServiceType::Mempool]);
|
||||
SelfHostedService::factory()->create(['name' => 'BetaService', 'type' => SelfHostedServiceType::Other]);
|
||||
|
||||
$page = visit('/de/services');
|
||||
|
||||
$page->assertSee('NodeAlpha')
|
||||
->assertSee('BetaService')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('filters services by clicking a type-badge in the type-cloud', function () {
|
||||
SelfHostedService::factory()->create(['name' => 'OnlyMempoolNode', 'type' => SelfHostedServiceType::Mempool]);
|
||||
SelfHostedService::factory()->create(['name' => 'OnlyOtherThing', 'type' => SelfHostedServiceType::Other]);
|
||||
|
||||
$page = visit('/de/services');
|
||||
|
||||
$page->assertSee('OnlyMempoolNode')
|
||||
->assertSee('OnlyOtherThing')
|
||||
->click('[wire\\:click="filterByType(\'mempool\')"]')
|
||||
->wait(1)
|
||||
->assertSee('OnlyMempoolNode')
|
||||
->assertDontSee('OnlyOtherThing')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('shows seeded meetups on the public meetups index', function () {
|
||||
Meetup::factory()->create([
|
||||
'city_id' => $this->city->id,
|
||||
'name' => 'BrowserSeeded Meetup XYZ',
|
||||
'visible_on_map' => true,
|
||||
]);
|
||||
|
||||
$page = visit('/de/meetups');
|
||||
|
||||
$page->assertSee('BrowserSeeded Meetup XYZ')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SelfHostedServiceType;
|
||||
use App\Models\SelfHostedService;
|
||||
|
||||
it('creates a new SelfHostedService end-to-end and shows it on the index', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/service-create');
|
||||
|
||||
$page->fill('[wire\\:model="form.name"]', 'BrowserTestNode')
|
||||
->select('[wire\\:model="form.type"]', SelfHostedServiceType::Mempool->value)
|
||||
->fill('[wire\\:model="form.url_clearnet"]', 'https://browsertest.example.com')
|
||||
->fill('[wire\\:model="form.intro"]', 'A node spun up by a browser test.')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(2)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('BrowserTestNode');
|
||||
|
||||
expect(SelfHostedService::query()->where('name', 'BrowserTestNode')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks submission without a name and shows a required error', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/service-create');
|
||||
|
||||
$page->select('[wire\\:model="form.type"]', SelfHostedServiceType::Other->value)
|
||||
->fill('[wire\\:model="form.url_clearnet"]', 'https://no-name.example.com')
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(SelfHostedService::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects submission when no URL or IP is provided', function () {
|
||||
actingAsUser();
|
||||
|
||||
$page = visit('/de/service-create');
|
||||
|
||||
$page->fill('[wire\\:model="form.name"]', 'NoUrlService')
|
||||
->select('[wire\\:model="form.type"]', SelfHostedServiceType::Other->value)
|
||||
->click('[data-flux-button][type="submit"]')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(SelfHostedService::query()->where('name', 'NoUrlService')->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('lets an authenticated user update their profile name and persists it', function () {
|
||||
$user = actingAsUser(['name' => 'Old Name']);
|
||||
|
||||
$page = visit('/de/settings/profile');
|
||||
|
||||
$page->assertSee('Old Name')
|
||||
->fill('name', 'New Browser Name')
|
||||
->click('Save')
|
||||
->wait(1)
|
||||
->assertSee('Saved.')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect($user->refresh()->name)->toBe('New Browser Name');
|
||||
});
|
||||
|
||||
it('shows a validation error when the profile name is cleared', function () {
|
||||
actingAsUser(['name' => 'Original']);
|
||||
|
||||
$page = visit('/de/settings/profile');
|
||||
|
||||
$page->fill('name', '')
|
||||
->click('Save')
|
||||
->wait(1)
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect(User::query()->where('name', '')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('still shows the updated name after a full page reload', function () {
|
||||
$user = actingAsUser(['name' => 'Before Reload']);
|
||||
|
||||
$page = visit('/de/settings/profile');
|
||||
$page->fill('name', 'After Reload')
|
||||
->click('Save')
|
||||
->wait(1);
|
||||
|
||||
$reloaded = visit('/de/settings/profile');
|
||||
$reloaded->assertSee('After Reload')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
expect($user->refresh()->name)->toBe('After Reload');
|
||||
});
|
||||
@@ -20,7 +20,6 @@ it('loads all listed public pages without console errors or JS errors', function
|
||||
$pages = visit([
|
||||
'/welcome',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/de/meetups',
|
||||
'/de/courses',
|
||||
|
||||
@@ -3,17 +3,61 @@
|
||||
use App\Jobs\FetchNostrProfileJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Livewire;
|
||||
use swentel\nostr\Event\Event as NostrEvent;
|
||||
use swentel\nostr\Key\Key as NostrKey;
|
||||
use swentel\nostr\Sign\Sign as NostrSign;
|
||||
|
||||
/**
|
||||
* Build a NIP-42-style signed login event using the challenge that the
|
||||
* login component placed in the session during mount(), and return
|
||||
* [signedEventArray, npubBech32].
|
||||
*
|
||||
* @return array{0: array<string, mixed>, 1: string}
|
||||
*/
|
||||
function makeSignedNostrLoginEvent(): array
|
||||
{
|
||||
$keyGen = new NostrKey;
|
||||
$privateKey = $keyGen->generatePrivateKey();
|
||||
$publicKey = $keyGen->getPublicKey($privateKey);
|
||||
|
||||
$challenge = Session::get('nostr_login_challenge');
|
||||
|
||||
$event = new NostrEvent;
|
||||
$event->setKind(22242)
|
||||
->setCreatedAt(time())
|
||||
->setContent('')
|
||||
->setTags([['challenge', (string) $challenge]]);
|
||||
|
||||
(new NostrSign)->signEvent($event, $privateKey);
|
||||
|
||||
$signed = [
|
||||
'id' => $event->getId(),
|
||||
'pubkey' => $event->getPublicKey(),
|
||||
'created_at' => $event->getCreatedAt(),
|
||||
'kind' => $event->getKind(),
|
||||
'tags' => $event->getTags(),
|
||||
'content' => $event->getContent(),
|
||||
'sig' => $event->getSignature(),
|
||||
];
|
||||
|
||||
$npub = $keyGen->convertPublicKeyToBech32($publicKey);
|
||||
|
||||
return [$signed, $npub];
|
||||
}
|
||||
|
||||
it('creates a new user and dispatches FetchNostrProfileJob when an unknown pubkey logs in', function () {
|
||||
Queue::fake();
|
||||
$pubkey = 'npub1'.str_repeat('z', 58);
|
||||
|
||||
Livewire::test('auth.login')
|
||||
->dispatch('nostrLoggedIn', pubkey: $pubkey)
|
||||
$component = Livewire::test('auth.login');
|
||||
[$signedEvent, $npub] = makeSignedNostrLoginEvent();
|
||||
|
||||
$component
|
||||
->dispatch('nostrLoggedIn', signedEvent: $signedEvent)
|
||||
->assertRedirect();
|
||||
|
||||
$user = User::query()->where('nostr', $pubkey)->first();
|
||||
$user = User::query()->where('nostr', $npub)->first();
|
||||
expect($user)->not->toBeNull()
|
||||
->and((bool) $user->is_lecturer)->toBeTrue()
|
||||
->and($user->email)->toEndWith('@portal.einundzwanzig.space');
|
||||
@@ -24,14 +68,17 @@ it('creates a new user and dispatches FetchNostrProfileJob when an unknown pubke
|
||||
|
||||
it('logs in an existing user without creating a duplicate when their pubkey is already known', function () {
|
||||
Queue::fake();
|
||||
$pubkey = 'npub1'.str_repeat('a', 58);
|
||||
$existing = User::factory()->create(['nostr' => $pubkey]);
|
||||
|
||||
Livewire::test('auth.login')
|
||||
->dispatch('nostrLoggedIn', pubkey: $pubkey)
|
||||
$component = Livewire::test('auth.login');
|
||||
[$signedEvent, $npub] = makeSignedNostrLoginEvent();
|
||||
|
||||
$existing = User::factory()->create(['nostr' => $npub]);
|
||||
|
||||
$component
|
||||
->dispatch('nostrLoggedIn', signedEvent: $signedEvent)
|
||||
->assertRedirect();
|
||||
|
||||
expect(User::query()->where('nostr', $pubkey)->count())->toBe(1);
|
||||
expect(User::query()->where('nostr', $npub)->count())->toBe(1);
|
||||
expect(auth()->id())->toBe($existing->id);
|
||||
Queue::assertPushed(FetchNostrProfileJob::class);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ it('rejects lecturer creation with invalid website URL', function () {
|
||||
});
|
||||
|
||||
it('updates an existing lecturer', function () {
|
||||
$lecturer = Lecturer::factory()->create(['name' => 'Old Name']);
|
||||
actingAsUser();
|
||||
$owner = actingAsUser();
|
||||
$lecturer = Lecturer::factory()->create(['name' => 'Old Name', 'created_by' => $owner->id]);
|
||||
|
||||
Livewire::test('lecturers.edit', ['lecturer' => $lecturer])
|
||||
->set('name', 'New Name')
|
||||
|
||||
@@ -6,10 +6,6 @@ it('mounts the auth.login component', function () {
|
||||
Livewire::test('auth.login')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('mounts the auth.register component', function () {
|
||||
Livewire::test('auth.register')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('mounts the auth.forgot-password component', function () {
|
||||
Livewire::test('auth.forgot-password')->assertStatus(200);
|
||||
});
|
||||
|
||||
@@ -20,9 +20,17 @@ it('mounts lecturers.create when authenticated', function () {
|
||||
Livewire::test('lecturers.create')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('mounts lecturers.edit when authenticated', function () {
|
||||
it('mounts lecturers.edit when authenticated as the lecturer creator', function () {
|
||||
$owner = actingAsUser();
|
||||
$lecturer = Lecturer::factory()->create(['created_by' => $owner->id]);
|
||||
|
||||
Livewire::test('lecturers.edit', ['lecturer' => $lecturer])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('aborts lecturers.edit with 403 when authenticated user is not the creator', function () {
|
||||
actingAsUser();
|
||||
Livewire::test('lecturers.edit', ['lecturer' => $this->lecturer])->assertStatus(200);
|
||||
|
||||
Livewire::test('lecturers.edit', ['lecturer' => $this->lecturer])->assertStatus(403);
|
||||
});
|
||||
|
||||
it('mounts cities.create when authenticated', function () {
|
||||
|
||||
@@ -29,9 +29,20 @@ it('mounts meetups.create when authenticated', function () {
|
||||
Livewire::test('meetups.create')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('mounts meetups.edit when authenticated', function () {
|
||||
it('mounts meetups.edit when authenticated as the meetup creator', function () {
|
||||
$owner = actingAsUser();
|
||||
$meetup = Meetup::factory()->create([
|
||||
'city_id' => $this->city->id,
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
|
||||
Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('aborts meetups.edit with 403 when authenticated user is not the creator', function () {
|
||||
actingAsUser();
|
||||
Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(200);
|
||||
|
||||
Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(403);
|
||||
});
|
||||
|
||||
it('mounts meetups.create-edit-events for new event', function () {
|
||||
|
||||
@@ -8,36 +8,50 @@ use Livewire\Livewire;
|
||||
beforeEach(function () {
|
||||
$country = Country::factory()->create(['code' => 'de']);
|
||||
$this->city = City::factory()->create(['country_id' => $country->id]);
|
||||
$this->meetup = Meetup::factory()->create(['city_id' => $this->city->id, 'name' => 'Original Name']);
|
||||
});
|
||||
|
||||
it('updates an existing Meetup name when authenticated', function () {
|
||||
actingAsUser();
|
||||
$owner = actingAsUser();
|
||||
$meetup = Meetup::factory()->create([
|
||||
'city_id' => $this->city->id,
|
||||
'name' => 'Original Name',
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
|
||||
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||
Livewire::test('meetups.edit', ['meetup' => $meetup])
|
||||
->set('name', 'Updated Name')
|
||||
->set('city_id', $this->city->id)
|
||||
->set('community', 'einundzwanzig')
|
||||
->call('updateMeetup')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($this->meetup->refresh()->name)->toBe('Updated Name');
|
||||
expect($meetup->refresh()->name)->toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('rejects update when name collides with another existing Meetup', function () {
|
||||
$owner = actingAsUser();
|
||||
$meetup = Meetup::factory()->create([
|
||||
'city_id' => $this->city->id,
|
||||
'name' => 'Original Name',
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]);
|
||||
actingAsUser();
|
||||
|
||||
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||
Livewire::test('meetups.edit', ['meetup' => $meetup])
|
||||
->set('name', 'Other Name')
|
||||
->call('updateMeetup')
|
||||
->assertHasErrors(['name' => 'unique']);
|
||||
});
|
||||
|
||||
it('allows update when name is unchanged (Rule::unique ignores own id)', function () {
|
||||
actingAsUser();
|
||||
$owner = actingAsUser();
|
||||
$meetup = Meetup::factory()->create([
|
||||
'city_id' => $this->city->id,
|
||||
'name' => 'Original Name',
|
||||
'created_by' => $owner->id,
|
||||
]);
|
||||
|
||||
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||
Livewire::test('meetups.edit', ['meetup' => $meetup])
|
||||
->set('name', 'Original Name')
|
||||
->set('community', 'einundzwanzig')
|
||||
->call('updateMeetup')
|
||||
@@ -45,5 +59,7 @@ it('allows update when name is unchanged (Rule::unique ignores own id)', functio
|
||||
});
|
||||
|
||||
it('redirects guests when accessing meetup-edit', function () {
|
||||
$this->get('/de/meetup-edit/'.$this->meetup->id)->assertRedirect(route('login'));
|
||||
$meetup = Meetup::factory()->create(['city_id' => $this->city->id]);
|
||||
|
||||
$this->get('/de/meetup-edit/'.$meetup->id)->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ it('returns 404 for /api/meetup/ical (currently a stub that aborts)', function (
|
||||
$this->get('/api/meetup/ical')->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for /api/meetup index without user_id (currently aborts on missing param)', function () {
|
||||
$this->getJson('/api/meetup')->assertNotFound();
|
||||
it('returns 401 for /api/meetup index when unauthenticated (auth-only after IDOR fix)', function () {
|
||||
$this->getJson('/api/meetup')->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('returns a successful response for /stream-calendar', function () {
|
||||
|
||||
@@ -25,7 +25,6 @@ it('returns a successful response for the listed public route', function (string
|
||||
})->with([
|
||||
'welcome' => '/welcome',
|
||||
'login' => '/login',
|
||||
'register' => '/register',
|
||||
'forgot password' => '/forgot-password',
|
||||
'meetups index' => '/de/meetups',
|
||||
'meetups all' => '/de/all-meetups',
|
||||
@@ -42,6 +41,10 @@ it('redirects / to /welcome', function () {
|
||||
$this->get('/')->assertRedirect('/welcome');
|
||||
});
|
||||
|
||||
it('returns 404 for /register because public registration is disabled', function () {
|
||||
$this->get('/register')->assertNotFound();
|
||||
});
|
||||
|
||||
it('redirects /de/dashboard to login when guest', function () {
|
||||
$this->get('/de/dashboard')->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
+8
-1
@@ -1,4 +1,6 @@
|
||||
import {defineConfig} from 'vite';
|
||||
import {
|
||||
defineConfig
|
||||
} from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
@@ -12,5 +14,10 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
cors: true,
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/storage/framework/views/**'
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user