diff --git a/README.md b/README.md index 4bc994939..901534768 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -
Your self-hosted tool for effortlessly archiving, organizing, and sharing your favorite web links.
+Your self-hosted app for effortlessly organizing, archiving, and sharing your favorite bookmarks.
@@ -68,11 +68,8 @@ LinkAce provides multiple ways of installing it on your server. The complete doc
* [Setup without Docker](https://www.linkace.org/docs/v2/setup/setup-with-php/)
* [One-Click Deployment to the Cloud](https://www.linkace.org/docs/v2/setup/one-click-deploy/)
* [Setup with Kubernetes](https://www.linkace.org/docs/v2/setup/setup-to-k8s/) (Beta)
-* [Official managed Hosting](https://hosting.linkace.org) (_Beta Waitlist_)
+* [Official managed Hosting](https://hosting.linkace.org) (_Beta_)
-
-
-> **LinkAce 2.0 was just released!** This is a big upgrade to the application. Please read the [upgrade guide](https://www.linkace.org/docs/v2/upgrade/from-v1/) if you are still using LinkAce 1 and want to use version 2.
@@ -83,9 +80,8 @@ I built LinkAce to solve my own problem, and I now offer my solution and code wi
:star: You can get personal and dedicated support by **becoming a supporter on [Open Collective](https://opencollective.com/linkace), [Patreon](https://www.patreon.com/Kovah) or [Github](https://github.com/sponsors/Kovah)**.
-#### Our Supporters on Open Collective
-
+
### Documentation and Community
@@ -94,6 +90,11 @@ Details about all features and advanced configuration can be found in the [**pro
Additionally, you may visit the [community discussions](https://github.com/Kovah/LinkAce/discussions) to share your ideas, talk with other users or find help for specific problems.
+#### Our Supporters on Open Collective
+
+
+
+
@@ -103,16 +104,12 @@ Additionally, you may visit the [community discussions](https://github.com/Kovah
Please consult the [**contribution guidelines**](CONTRIBUTING.md) to start working on LinkAce.
-
-
Thanks go to these wonderful people for their contributions:
[](https://github.com/Kovah/LinkAce/graphs/contributors)
-
-
LinkAce is a project by [Kevin Woblick](https://woblick.dev) and [Contributors](https://github.com/Kovah/LinkAce/graphs/contributors)
diff --git a/app/Console/Commands/DebugConfigCommand.php b/app/Console/Commands/DebugConfigCommand.php
new file mode 100644
index 000000000..f2c461a3b
--- /dev/null
+++ b/app/Console/Commands/DebugConfigCommand.php
@@ -0,0 +1,187 @@
+error('This command is only available when APP_DEBUG=true.');
+ return self::FAILURE;
+ }
+
+ $this->line('');
+ $this->line('
TestUser was created', $output);
}
+
+ public function test_user_names_are_escaped(): void
+ {
+ $payload = '';
+ $user = User::factory()->create(['name' => $payload]);
+
+ $historyEntry = $user->audits()->first();
+ $output = (new UserEntry($historyEntry))->render();
+
+ $this->assertStringNotContainsString($payload, $output);
+ $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output);
+ }
}
diff --git a/tests/Controller/API/BulkEditApiTest.php b/tests/Controller/API/BulkEditApiTest.php
index a68ef952f..9f7981fca 100644
--- a/tests/Controller/API/BulkEditApiTest.php
+++ b/tests/Controller/API/BulkEditApiTest.php
@@ -107,6 +107,27 @@ public function test_alternative_links_edit(): void
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherLink->visibility);
}
+ public function test_links_edit_skips_visible_links_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicLink = Link::factory()->for($otherUser)->create();
+ $otherInternalLink = Link::factory()->for($otherUser)->create([
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->patchJson('api/v2/bulk/links', [
+ 'models' => [$otherPublicLink->id, $otherInternalLink->id],
+ 'tags' => [],
+ 'tags_mode' => 'append',
+ 'lists' => [],
+ 'lists_mode' => 'append',
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])->assertExactJson([null, null]);
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicLink->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalLink->refresh()->visibility);
+ }
+
public function test_lists_edit(): void
{
Log::shouldReceive('warning')->once();
@@ -128,6 +149,23 @@ public function test_lists_edit(): void
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility);
}
+ public function test_lists_edit_skips_visible_lists_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicList = LinkList::factory()->for($otherUser)->create();
+ $otherInternalList = LinkList::factory()->for($otherUser)->create([
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->patchJson('api/v2/bulk/lists', [
+ 'models' => [$otherPublicList->id, $otherInternalList->id],
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])->assertExactJson([null, null]);
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicList->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalList->refresh()->visibility);
+ }
+
public function test_alternative_lists_edit(): void
{
Log::shouldReceive('warning')->once();
@@ -170,6 +208,23 @@ public function test_tags_edit(): void
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility);
}
+ public function test_tags_edit_skips_visible_tags_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicTag = Tag::factory()->for($otherUser)->create();
+ $otherInternalTag = Tag::factory()->for($otherUser)->create([
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->patchJson('api/v2/bulk/tags', [
+ 'models' => [$otherPublicTag->id, $otherInternalTag->id],
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])->assertExactJson([null, null]);
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicTag->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalTag->refresh()->visibility);
+ }
+
public function test_alternative_tags_edit(): void
{
Log::shouldReceive('warning')->once();
diff --git a/tests/Controller/API/LinkApiTest.php b/tests/Controller/API/LinkApiTest.php
index b2508b602..9f0614bba 100644
--- a/tests/Controller/API/LinkApiTest.php
+++ b/tests/Controller/API/LinkApiTest.php
@@ -436,7 +436,7 @@ public function test_update_request(): void
'lists' => [$list->id],
'is_private' => false,
'check_disabled' => false,
- ])->assertOk()->assertJson(['url' => 'https://new-internal-link.com']);
+ ])->assertForbidden();
$this->patchJsonAuthorized('api/v2/links/3', [
'url' => 'https://new-internal-link.com',
@@ -446,6 +446,9 @@ public function test_update_request(): void
'is_private' => false,
'check_disabled' => false,
])->assertForbidden();
+
+ $this->assertEquals('https://internal-link.com', Link::find(2)->url);
+ $this->assertEquals('https://private-link.com', Link::find(3)->url);
}
public function test_update_request_with_system_token(): void
diff --git a/tests/Controller/API/ListApiTest.php b/tests/Controller/API/ListApiTest.php
index f3e42ae49..27c08d53f 100644
--- a/tests/Controller/API/ListApiTest.php
+++ b/tests/Controller/API/ListApiTest.php
@@ -125,17 +125,16 @@ public function test_update_request(): void
'name' => 'Updated Internal List',
'description' => 'Custom Description',
'visibility' => 1,
- ])
- ->assertOk()
- ->assertJson([
- 'name' => 'Updated Internal List',
- ]);
+ ])->assertForbidden();
$this->patchJsonAuthorized('api/v2/lists/3', [
'name' => 'Updated Internal List',
'description' => 'Custom Description',
'visibility' => 1,
])->assertForbidden();
+
+ $this->assertEquals('Internal List', LinkList::find(2)->name);
+ $this->assertEquals('Private List', LinkList::find(3)->name);
}
public function test_invalid_update_request(): void
diff --git a/tests/Controller/API/NoteApiTest.php b/tests/Controller/API/NoteApiTest.php
index 7d0348212..aba51b73b 100644
--- a/tests/Controller/API/NoteApiTest.php
+++ b/tests/Controller/API/NoteApiTest.php
@@ -59,10 +59,17 @@ public function test_invalid_create_request(): void
public function test_update_request(): void
{
- $this->createTestLinks();
+ $testData = $this->createTestLinks();
+ $otherUser = $testData[3];
Note::factory()->create(['link_id' => 1]);
- Note::factory()->create(['link_id' => 2]); // Note for internal link of other user
- Note::factory()->create(['link_id' => 3]); // Note for private link of other user
+ Note::factory()->for($otherUser)->create([
+ 'link_id' => 2,
+ 'note' => 'Internal Note',
+ ]); // Note for internal link of other user
+ Note::factory()->for($otherUser)->create([
+ 'link_id' => 3,
+ 'note' => 'Private Note',
+ ]); // Note for private link of other user
$this->patchJsonAuthorized('api/v2/notes/1', [
'note' => 'Gallia est omnis divisa in partes tres, quarum.',
@@ -80,16 +87,15 @@ public function test_update_request(): void
$this->patchJsonAuthorized('api/v2/notes/2', [
'note' => 'Gallia est omnis divisa in partes tres, quarum.',
'visibility' => 1,
- ])
- ->assertOk()
- ->assertJson([
- 'note' => 'Gallia est omnis divisa in partes tres, quarum.',
- ]);
+ ])->assertForbidden();
$this->patchJsonAuthorized('api/v2/notes/3', [
'note' => 'Gallia est omnis divisa in partes tres, quarum.',
'visibility' => 1,
])->assertForbidden();
+
+ $this->assertEquals('Internal Note', Note::find(2)->note);
+ $this->assertEquals('Private Note', Note::find(3)->note);
}
public function test_invalid_update_request(): void
diff --git a/tests/Controller/API/TagApiTest.php b/tests/Controller/API/TagApiTest.php
index 0daa44c02..e7189a417 100644
--- a/tests/Controller/API/TagApiTest.php
+++ b/tests/Controller/API/TagApiTest.php
@@ -124,17 +124,16 @@ public function test_update_request(): void
$this->patchJsonAuthorized('api/v2/tags/2', [
'name' => 'Updated Internal Tag',
'visibility' => 1,
- ])
- ->assertOk()
- ->assertJson([
- 'name' => 'Updated Internal Tag',
- ]);
+ ])->assertForbidden();
$this->patchJsonAuthorized('api/v2/tags/3', [
'name' => 'Updated Private Tag',
'visibility' => 1,
])
->assertForbidden();
+
+ $this->assertEquals('Internal Tag', Tag::find(2)->name);
+ $this->assertEquals('Private Tag', Tag::find(3)->name);
}
public function test_invalid_update_request(): void
diff --git a/tests/Controller/App/AuditControllerTest.php b/tests/Controller/App/AuditControllerTest.php
index 94eb51fa3..77a5ec5bc 100644
--- a/tests/Controller/App/AuditControllerTest.php
+++ b/tests/Controller/App/AuditControllerTest.php
@@ -2,9 +2,11 @@
namespace Tests\Controller\App;
+use App\Enums\ActivityLog;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
class AuditControllerTest extends TestCase
@@ -39,4 +41,22 @@ public function test_audit_page_with_entries(): void
$response = $this->get('system/audit');
$response->assertSee('System: Cron Token was re-generated');
}
+
+ public function test_audit_page_escapes_activity_causer_name(): void
+ {
+ $this->user->assignRole(Role::ADMIN);
+ $payload = '
';
+ $attacker = User::factory()->create(['name' => $payload]);
+ Activity::create([
+ 'description' => ActivityLog::USER_API_TOKEN_GENERATED,
+ 'causer_type' => User::class,
+ 'causer_id' => $attacker->id,
+ ]);
+
+ $response = $this->get('system/audit');
+
+ $response->assertOk();
+ $response->assertDontSee($payload, false);
+ $response->assertSee('<img src=x onerror=alert(1)>', false);
+ }
}
diff --git a/tests/Controller/Models/BulkEditControllerTest.php b/tests/Controller/Models/BulkEditControllerTest.php
index 4a6fd2a31..f1bf7e949 100644
--- a/tests/Controller/Models/BulkEditControllerTest.php
+++ b/tests/Controller/Models/BulkEditControllerTest.php
@@ -119,6 +119,32 @@ public function test_links_edit_without_taxonomy(): void
$this->assertEmpty($links[2]->tags()->pluck('id')->toArray());
}
+ public function test_links_edit_skips_visible_links_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicLink = Link::factory()->for($otherUser)->create([
+ 'url' => 'https://other-public-link.com',
+ ]);
+ $otherInternalLink = Link::factory()->for($otherUser)->create([
+ 'url' => 'https://other-internal-link.com',
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->post('bulk-edit/update-links', [
+ 'models' => $otherPublicLink->id . ',' . $otherInternalLink->id,
+ 'tags' => json_encode([]),
+ 'tags_mode' => 'append',
+ 'lists' => json_encode([]),
+ 'lists_mode' => 'append',
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])
+ ->assertRedirect('links')
+ ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Links out of 2 selected ones.');
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicLink->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalLink->refresh()->visibility);
+ }
+
public function test_alternative_links_edit(): void
{
Log::shouldReceive('warning')->once();
@@ -177,6 +203,25 @@ public function test_lists_edit(): void
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherList->visibility);
}
+ public function test_lists_edit_skips_visible_lists_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicList = LinkList::factory()->for($otherUser)->create();
+ $otherInternalList = LinkList::factory()->for($otherUser)->create([
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->post('bulk-edit/update-lists', [
+ 'models' => $otherPublicList->id . ',' . $otherInternalList->id,
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])
+ ->assertRedirect('lists')
+ ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Lists out of 2 selected ones.');
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicList->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalList->refresh()->visibility);
+ }
+
public function test_alternative_lists_edit(): void
{
Log::shouldReceive('warning')->once();
@@ -223,6 +268,25 @@ public function test_tags_edit(): void
$this->assertEquals(ModelAttribute::VISIBILITY_PRIVATE, $otherTag->visibility);
}
+ public function test_tags_edit_skips_visible_tags_owned_by_other_users(): void
+ {
+ $otherUser = User::factory()->create();
+ $otherPublicTag = Tag::factory()->for($otherUser)->create();
+ $otherInternalTag = Tag::factory()->for($otherUser)->create([
+ 'visibility' => ModelAttribute::VISIBILITY_INTERNAL,
+ ]);
+
+ $this->post('bulk-edit/update-tags', [
+ 'models' => $otherPublicTag->id . ',' . $otherInternalTag->id,
+ 'visibility' => ModelAttribute::VISIBILITY_PRIVATE,
+ ])
+ ->assertRedirect('tags')
+ ->assertSessionHas('flash_notification.0.message', 'Successfully updated 0 Tags out of 2 selected ones.');
+
+ $this->assertEquals(ModelAttribute::VISIBILITY_PUBLIC, $otherPublicTag->refresh()->visibility);
+ $this->assertEquals(ModelAttribute::VISIBILITY_INTERNAL, $otherInternalTag->refresh()->visibility);
+ }
+
public function test_alternative_tags_edit(): void
{
Log::shouldReceive('warning')->once();
diff --git a/tests/Controller/Models/LinkControllerTest.php b/tests/Controller/Models/LinkControllerTest.php
index a833b3843..c459eeda6 100644
--- a/tests/Controller/Models/LinkControllerTest.php
+++ b/tests/Controller/Models/LinkControllerTest.php
@@ -383,7 +383,7 @@ public function test_edit_view(): void
$this->createTestLinks();
$this->get('links/1/edit')->assertOk()->assertSee('https://public-link.com');
- $this->get('links/2/edit')->assertOk()->assertSee('https://internal-link.com');
+ $this->get('links/2/edit')->assertForbidden();
$this->get('links/3/edit')->assertForbidden();
}
@@ -427,7 +427,7 @@ public function test_update_response(): void
'tags' => null,
'visibility' => 1,
'check_disabled' => '0',
- ])->assertRedirect('links/2');
+ ])->assertForbidden();
$this->patch('links/3', [
'url' => 'https://private-link.com',
@@ -438,6 +438,9 @@ public function test_update_response(): void
'visibility' => 1,
'check_disabled' => '0',
])->assertForbidden();
+
+ $this->assertEquals('https://internal-link.com', Link::find(2)->url);
+ $this->assertEquals('https://private-link.com', Link::find(3)->url);
}
public function test_update_with_malicious_url(): void
@@ -537,7 +540,7 @@ public function test_check_toggle_request(): void
// Check other links
$this->post('links/toggle-check/2', [
'toggle' => '1',
- ])->assertRedirect('links/2');
+ ])->assertForbidden();
$this->post('links/toggle-check/3', ['toggle' => '1'])->assertForbidden();
}
@@ -559,7 +562,7 @@ public function test_mark_working_request(): void
$link = Link::first();
$this->post('links/mark-working/1')->assertRedirect('links/1');
- $this->post('links/mark-working/2')->assertRedirect('links/2');
+ $this->post('links/mark-working/2')->assertForbidden();
$this->post('links/mark-working/3')->assertForbidden();
$this->assertEquals(Link::STATUS_OK, $link->refresh()->status);
diff --git a/tests/Controller/Models/ListControllerTest.php b/tests/Controller/Models/ListControllerTest.php
index 1d200a871..130f04494 100644
--- a/tests/Controller/Models/ListControllerTest.php
+++ b/tests/Controller/Models/ListControllerTest.php
@@ -206,7 +206,7 @@ public function test_edit_view(): void
$this->createTestLists();
$this->get('lists/1/edit')->assertOk()->assertSee('Public List')->assertSee('Edit List');
- $this->get('lists/2/edit')->assertOk()->assertSee('Internal List')->assertSee('Edit List');
+ $this->get('lists/2/edit')->assertForbidden();
$this->get('lists/3/edit')->assertForbidden();
}
@@ -233,13 +233,16 @@ public function test_update_response(): void
'list_id' => 2,
'name' => 'New Internal List',
'visibility' => 1,
- ])->assertRedirect('lists/2');
+ ])->assertForbidden();
$this->patch('lists/3', [
'list_id' => $list->id,
'name' => 'New Test List',
'visibility' => 1,
])->assertForbidden();
+
+ $this->assertEquals('Internal List', LinkList::find(2)->name);
+ $this->assertEquals('Private List', LinkList::find(3)->name);
}
public function test_missing_model_error_for_update(): void
diff --git a/tests/Controller/Models/NoteControllerTest.php b/tests/Controller/Models/NoteControllerTest.php
index a29bc5c75..40b9622f5 100644
--- a/tests/Controller/Models/NoteControllerTest.php
+++ b/tests/Controller/Models/NoteControllerTest.php
@@ -162,6 +162,36 @@ public function test_update_response(): void
$this->assertEquals('Lorem ipsum dolor est updated', $note->refresh()->note);
}
+ public function test_other_users_visible_notes_cannot_be_updated(): void
+ {
+ $link = Link::factory()->create();
+ $otherUser = User::factory()->create();
+ $publicNote = Note::factory()->for($otherUser)->create([
+ 'link_id' => $link->id,
+ 'note' => 'Original public note',
+ ]);
+ $internalNote = Note::factory()->for($otherUser)->create([
+ 'link_id' => $link->id,
+ 'note' => 'Original internal note',
+ 'visibility' => 2,
+ ]);
+
+ $this->patch('notes/' . $publicNote->id, [
+ 'link_id' => $link->id,
+ 'note' => 'Updated public note',
+ 'visibility' => 1,
+ ])->assertForbidden();
+
+ $this->patch('notes/' . $internalNote->id, [
+ 'link_id' => $link->id,
+ 'note' => 'Updated internal note',
+ 'visibility' => 1,
+ ])->assertForbidden();
+
+ $this->assertEquals('Original public note', $publicNote->refresh()->note);
+ $this->assertEquals('Original internal note', $internalNote->refresh()->note);
+ }
+
public function test_missing_model_error_for_update(): void
{
$this->patch('notes/1', [
diff --git a/tests/Controller/Models/TagControllerTest.php b/tests/Controller/Models/TagControllerTest.php
index ceb112ee6..77f1a9286 100644
--- a/tests/Controller/Models/TagControllerTest.php
+++ b/tests/Controller/Models/TagControllerTest.php
@@ -197,7 +197,7 @@ public function test_edit_view(): void
$this->createTestTags();
$this->get('tags/1/edit')->assertOk()->assertSee('Public Tag');
- $this->get('tags/2/edit')->assertOk()->assertSee('Internal Tag');
+ $this->get('tags/2/edit')->assertForbidden();
$this->get('tags/3/edit')->assertForbidden();
}
@@ -224,13 +224,16 @@ public function test_update_response(): void
'tag_id' => 2,
'name' => 'New Internal Tag',
'visibility' => 1,
- ])->assertRedirect('tags/2');
+ ])->assertForbidden();
$this->patch('tags/3', [
'tag_id' => 3,
'name' => 'New Private Tag',
'visibility' => 1,
])->assertForbidden();
+
+ $this->assertEquals('Internal Tag', Tag::find(2)->name);
+ $this->assertEquals('Private Tag', Tag::find(3)->name);
}
public function test_missing_model_error_for_update(): void