diff --git a/app/Enums/ActivityLog.php b/app/Enums/ActivityLog.php index a4216b81..9697d829 100644 --- a/app/Enums/ActivityLog.php +++ b/app/Enums/ActivityLog.php @@ -8,4 +8,7 @@ class ActivityLog public const USER_API_TOKEN_GENERATED = 'user.api_token_regenerated'; public const USER_API_TOKEN_REVOKED = 'user.api_token_revoked'; + + public const SYSTEM_API_TOKEN_GENERATED = 'system.api_token_regenerated'; + public const SYSTEM_API_TOKEN_REVOKED = 'system.api_token_revoked'; } diff --git a/app/Enums/ApiToken.php b/app/Enums/ApiToken.php index 40f223af..34b7dd43 100644 --- a/app/Enums/ApiToken.php +++ b/app/Enums/ApiToken.php @@ -6,4 +6,44 @@ class ApiToken { public const ABILITY_USER_ACCESS = 'user_access'; public const ABILITY_SYSTEM_ACCESS = 'system_access'; + public const ABILITY_SYSTEM_ACCESS_PRIVATE = 'system_access_private'; + + public const ABILITY_LINKS_READ = 'links.read'; + public const ABILITY_LINKS_CREATE = 'links.create'; + public const ABILITY_LINKS_UPDATE = 'links.update'; + public const ABILITY_LINKS_DELETE = 'links.delete'; + + public const ABILITY_LISTS_READ = 'lists.read'; + public const ABILITY_LISTS_CREATE = 'lists.create'; + public const ABILITY_LISTS_UPDATE = 'lists.update'; + public const ABILITY_LISTS_DELETE = 'lists.delete'; + + public const ABILITY_TAGS_READ = 'tags.read'; + public const ABILITY_TAGS_CREATE = 'tags.create'; + public const ABILITY_TAGS_UPDATE = 'tags.update'; + public const ABILITY_TAGS_DELETE = 'tags.delete'; + + public const ABILITY_NOTES_READ = 'notes.read'; + public const ABILITY_NOTES_CREATE = 'notes.create'; + public const ABILITY_NOTES_UPDATE = 'notes.update'; + public const ABILITY_NOTES_DELETE = 'notes.delete'; + + public static array $systemTokenAbilities = [ + self::ABILITY_LINKS_READ, + self::ABILITY_LINKS_CREATE, + self::ABILITY_LINKS_UPDATE, + self::ABILITY_LINKS_DELETE, + self::ABILITY_LISTS_READ, + self::ABILITY_LISTS_CREATE, + self::ABILITY_LISTS_UPDATE, + self::ABILITY_LISTS_DELETE, + self::ABILITY_TAGS_READ, + self::ABILITY_TAGS_CREATE, + self::ABILITY_TAGS_UPDATE, + self::ABILITY_TAGS_DELETE, + self::ABILITY_NOTES_READ, + self::ABILITY_NOTES_CREATE, + self::ABILITY_NOTES_UPDATE, + self::ABILITY_NOTES_DELETE, + ]; } diff --git a/app/Helper/functions.php b/app/Helper/functions.php index 5da7bcfd..c2dec7ee 100644 --- a/app/Helper/functions.php +++ b/app/Helper/functions.php @@ -122,6 +122,10 @@ function getPaginationLimit(): mixed $default = config('linkace.default.pagination'); + if (auth()->id() === 0) { + return $default; + } + if (request()->is('guest/*')) { return guestsettings('listitem_count') ?: $default; } diff --git a/app/Http/Controllers/API/LinkCheckController.php b/app/Http/Controllers/API/LinkCheckController.php index 2818dfcc..92038a99 100644 --- a/app/Http/Controllers/API/LinkCheckController.php +++ b/app/Http/Controllers/API/LinkCheckController.php @@ -13,6 +13,8 @@ public function __invoke(Request $request): JsonResponse { $searchedUrl = $request->input('url', false); + $this->authorize('viewAny', Link::class); + if (!$searchedUrl) { return response()->json(['linksFound' => false]); } diff --git a/app/Http/Controllers/API/LinkController.php b/app/Http/Controllers/API/LinkController.php index bb0d2286..7111d3ba 100644 --- a/app/Http/Controllers/API/LinkController.php +++ b/app/Http/Controllers/API/LinkController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Enums\ApiToken; use App\Http\Controllers\Controller; use App\Http\Controllers\Traits\ChecksOrdering; use App\Http\Requests\Models\LinkStoreRequest; @@ -18,7 +19,7 @@ class LinkController extends Controller public function __construct() { $this->allowedOrderBy = Link::$allowOrderBy; - $this->authorizeResource(Link::class, 'link'); + $this->authorizeResource(Link::class . 'Api', 'link'); } public function index(Request $request): JsonResponse @@ -29,7 +30,7 @@ public function index(Request $request): JsonResponse $this->checkOrdering(); $links = Link::query() - ->visibleForUser() + ->visibleForUser(privateSystemAccess: $request->user()->tokenCan(ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE)) ->orderBy($this->orderBy, $this->orderDir) ->paginate(getPaginationLimit()); diff --git a/app/Http/Controllers/API/ListController.php b/app/Http/Controllers/API/ListController.php index cc5568a7..8d1ce2af 100644 --- a/app/Http/Controllers/API/ListController.php +++ b/app/Http/Controllers/API/ListController.php @@ -18,7 +18,7 @@ class ListController extends Controller public function __construct() { $this->allowedOrderBy = LinkList::$allowOrderBy; - $this->authorizeResource(LinkList::class, 'list'); + $this->authorizeResource(LinkList::class . 'Api', 'list'); } public function index(Request $request): JsonResponse diff --git a/app/Http/Controllers/API/NoteController.php b/app/Http/Controllers/API/NoteController.php index cd0c3bbc..bd04e712 100644 --- a/app/Http/Controllers/API/NoteController.php +++ b/app/Http/Controllers/API/NoteController.php @@ -14,7 +14,7 @@ class NoteController extends Controller { public function __construct() { - $this->authorizeResource(Note::class, 'note'); + $this->authorizeResource(Note::class . 'Api', 'note'); } public function store(NoteStoreRequest $request): JsonResponse diff --git a/app/Http/Controllers/API/TagController.php b/app/Http/Controllers/API/TagController.php index 7be96a36..ac978364 100644 --- a/app/Http/Controllers/API/TagController.php +++ b/app/Http/Controllers/API/TagController.php @@ -18,7 +18,7 @@ class TagController extends Controller public function __construct() { $this->allowedOrderBy = Tag::$allowOrderBy; - $this->authorizeResource(Tag::class, 'tag'); + $this->authorizeResource(Tag::class . 'Api', 'tag'); } public function index(Request $request): JsonResponse diff --git a/app/Http/Controllers/Admin/ApiTokenController.php b/app/Http/Controllers/Admin/ApiTokenController.php new file mode 100644 index 00000000..b52fe835 --- /dev/null +++ b/app/Http/Controllers/Admin/ApiTokenController.php @@ -0,0 +1,65 @@ + User::getSystemUser()->tokens()->get(), + ]); + } + + public function show(PersonalAccessToken $token) + { + return view('admin.api-tokens.show', [ + 'token' => $token, + ]); + } + + public function store(CreateSystemApiTokenRequest $request): RedirectResponse + { + $abilities = $request->validated('abilities'); + + if ($request->get('private_access', false)) { + $abilities[] = ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE; + } else { + $abilities[] = ApiToken::ABILITY_SYSTEM_ACCESS; + } + + $token = User::getSystemUser()->createToken($request->validated('token_name'), $abilities); + + activity() + ->by($request->user()) + ->withProperty('token_id', $token->accessToken->id) + ->log(ActivityLog::SYSTEM_API_TOKEN_GENERATED); + + session()->flash('new_token', $token->plainTextToken); + + return redirect()->route('system.api-tokens.show', ['api_token' => $token->accessToken]); + } + + public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse + { + $this->authorize('delete', $token); + + $token->delete(); + + activity() + ->by($request->user()) + ->log(ActivityLog::SYSTEM_API_TOKEN_REVOKED); + + flash()->warning(trans('auth.api_tokens.revoke_successful')); + return redirect()->route('system.api-tokens.index'); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 7d97c7c1..34a0382a 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -11,6 +11,10 @@ public function handle($request, Closure $next, ...$guards) { $this->authenticate($request, $guards); + if (!$request->is('api/*') && $request->user()->isSystemUser()) { + abort(403, trans('user.system_user_locked')); + } + if ($request->user()->isBlocked()) { abort(403, trans('user.block_warning')); } diff --git a/app/Http/Requests/Admin/CreateSystemApiTokenRequest.php b/app/Http/Requests/Admin/CreateSystemApiTokenRequest.php new file mode 100644 index 00000000..30eb1063 --- /dev/null +++ b/app/Http/Requests/Admin/CreateSystemApiTokenRequest.php @@ -0,0 +1,34 @@ + [ + 'required', + 'alpha_dash', + 'min:3', + 'max:100', + Rule::unique('personal_access_tokens', 'name')->where(function (Builder $query) { + return $query->whereNull('tokenable_id'); + }), + ], + 'abilities' => [ + 'required', + new ApiTokenAbilityRule(), + ], + 'private_access' => [ + 'sometimes', + 'accepted', + ], + ]; + } +} diff --git a/app/Models/ScopesVisibility.php b/app/Models/ScopesVisibility.php index 00aac7f4..a1c52799 100644 --- a/app/Models/ScopesVisibility.php +++ b/app/Models/ScopesVisibility.php @@ -7,12 +7,16 @@ trait ScopesVisibility { - public function scopeVisibleForUser(Builder $query, int $userId = null): Builder + public function scopeVisibleForUser(Builder $query, int $userId = null, bool $privateSystemAccess = false): Builder { if (is_null($userId) && auth()->check()) { $userId = auth()->id(); } + if ($userId === 0) { + return $privateSystemAccess ? $query : $query->whereNot('visibility', ModelAttribute::VISIBILITY_PRIVATE); + } + // Entity must be either public or internal, or have a private status together with the matching user id return $query->where(function (Builder $query) use ($userId) { $query->where('visibility', ModelAttribute::VISIBILITY_PUBLIC) diff --git a/app/Models/User.php b/app/Models/User.php index a3d7197e..89a5483a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -77,11 +78,26 @@ class User extends Authenticatable implements Auditable public array $auditModifiers = []; + /* + * ======================================================================== + * SCOPES + */ + + public function scopeNotSystem(Builder $query): Builder + { + return $query->whereNot('id', 0); + } + /* * ======================================================================== * METHODS */ + public static function getSystemUser(): User + { + return self::whereId(0)->first(); + } + public function isBlocked(): bool { return $this->blocked_at !== null; @@ -91,4 +107,9 @@ public function isCurrentlyLoggedIn(): bool { return $this->is(auth()->user()); } + + public function isSystemUser(): bool + { + return $this->id === 0; + } } diff --git a/app/Policies/Api/LinkApiPolicy.php b/app/Policies/Api/LinkApiPolicy.php new file mode 100644 index 00000000..d1fd2e92 --- /dev/null +++ b/app/Policies/Api/LinkApiPolicy.php @@ -0,0 +1,65 @@ +isSystemUser()) { + return $user->tokenCan(ApiToken::ABILITY_LINKS_READ); + } + return true; + } + + public function view(User $user, Link $link): bool + { + if ($user->isSystemUser()) { + $canViewPrivate = $user->tokenCan(ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE); + return $link->is_private ? $canViewPrivate : $user->tokenCan(ApiToken::ABILITY_LINKS_READ); + } + return $this->userCanAccessLink($user, $link); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Link $link): bool + { + return $this->userCanAccessLink($user, $link); + } + + public function delete(User $user, Link $link): bool + { + return $link->user->is($user); + } + + public function restore(User $user, Link $link): bool + { + return $link->user->is($user); + } + + public function forceDelete(User $user, Link $link): bool + { + return $link->user->is($user); + } + + // Link must be either owned by user, or be not private + protected function userCanAccessLink(User $user, Link $link): bool + { + if ($link->user_id === $user->id) { + return true; + } + return $link->visibility !== ModelAttribute::VISIBILITY_PRIVATE; + } +} diff --git a/app/Policies/Api/LinkListApiPolicy.php b/app/Policies/Api/LinkListApiPolicy.php new file mode 100644 index 00000000..e37e8cde --- /dev/null +++ b/app/Policies/Api/LinkListApiPolicy.php @@ -0,0 +1,57 @@ +userCanAccessList($user, $list); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, LinkList $list): bool + { + return $this->userCanAccessList($user, $list); + } + + public function delete(User $user, LinkList $list): bool + { + return $list->user->is($user); + } + + public function restore(User $user, LinkList $list): bool + { + return $list->user->is($user); + } + + public function forceDelete(User $user, LinkList $list): bool + { + return $list->user->is($user); + } + + // Link must be either owned by user, or be not private + protected function userCanAccessList(User $user, LinkList $list): bool + { + if ($list->user_id === $user->id) { + return true; + } + return $list->visibility !== ModelAttribute::VISIBILITY_PRIVATE; + } +} diff --git a/app/Policies/Api/NoteApiPolicy.php b/app/Policies/Api/NoteApiPolicy.php new file mode 100644 index 00000000..074b47aa --- /dev/null +++ b/app/Policies/Api/NoteApiPolicy.php @@ -0,0 +1,57 @@ +userCanAccessNote($user, $note); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Note $note): bool + { + return $this->userCanAccessNote($user, $note); + } + + public function delete(User $user, Note $note): bool + { + return $note->user->is($user); + } + + public function restore(User $user, Note $note): bool + { + return $note->user->is($user); + } + + public function forceDelete(User $user, Note $note): bool + { + return $note->user->is($user); + } + + // Link must be either owned by user, or be not private + protected function userCanAccessNote(User $user, Note $note): bool + { + if ($note->user_id === $user->id) { + return true; + } + return $note->visibility !== ModelAttribute::VISIBILITY_PRIVATE; + } +} diff --git a/app/Policies/Api/TagApiPolicy.php b/app/Policies/Api/TagApiPolicy.php new file mode 100644 index 00000000..edbe81b4 --- /dev/null +++ b/app/Policies/Api/TagApiPolicy.php @@ -0,0 +1,57 @@ +userCanAccessTag($user, $tag); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Tag $tag): bool + { + return $this->userCanAccessTag($user, $tag); + } + + public function delete(User $user, Tag $tag): bool + { + return $tag->user->is($user); + } + + public function restore(User $user, Tag $tag): bool + { + return $tag->user->is($user); + } + + public function forceDelete(User $user, Tag $tag): bool + { + return $tag->user->is($user); + } + + // Link must be either owned by user, or be not private + protected function userCanAccessTag(User $user, Tag $tag): bool + { + if ($tag->user_id === $user->id) { + return true; + } + return $tag->visibility !== ModelAttribute::VISIBILITY_PRIVATE; + } +} diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index 2e45532c..c31cf42d 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\Role; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; use Laravel\Sanctum\PersonalAccessToken; @@ -32,7 +33,7 @@ public function update(User $user, PersonalAccessToken $personalAccessToken): bo public function delete(User $user, PersonalAccessToken $personalAccessToken): bool { - return $personalAccessToken->tokenable->is($user); + return $personalAccessToken->tokenable->is($user) || $user->hasRole(Role::ADMIN); } public function restore(User $user, PersonalAccessToken $personalAccessToken): bool diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e60c53b8..0a9541e5 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,6 +6,10 @@ use App\Models\LinkList; use App\Models\Note; use App\Models\Tag; +use App\Policies\Api\LinkApiPolicy; +use App\Policies\Api\LinkListApiPolicy; +use App\Policies\Api\NoteApiPolicy; +use App\Policies\Api\TagApiPolicy; use App\Policies\ApiTokenPolicy; use App\Policies\LinkListPolicy; use App\Policies\LinkPolicy; @@ -27,6 +31,10 @@ class AuthServiceProvider extends ServiceProvider Note::class => NotePolicy::class, Tag::class => TagPolicy::class, PersonalAccessToken::class => ApiTokenPolicy::class, + Link::class . 'Api' => LinkApiPolicy::class, + LinkList::class . 'Api' => LinkListApiPolicy::class, + Note::class . 'Api' => NoteApiPolicy::class, + Tag::class . 'Api' => TagApiPolicy::class, ]; /** diff --git a/app/Rules/ApiTokenAbilityRule.php b/app/Rules/ApiTokenAbilityRule.php new file mode 100644 index 00000000..7fe84a01 --- /dev/null +++ b/app/Rules/ApiTokenAbilityRule.php @@ -0,0 +1,48 @@ +id(); + return self::$userId ?: auth()->id(); } public static function defaults(): array diff --git a/database/factories/LinkFactory.php b/database/factories/LinkFactory.php index b4391c61..9dcdf75c 100644 --- a/database/factories/LinkFactory.php +++ b/database/factories/LinkFactory.php @@ -18,7 +18,7 @@ class LinkFactory extends Factory public function definition(): array { return [ - 'user_id' => User::first()->id ?? User::factory(), + 'user_id' => User::notSystem()->first()->id ?? User::factory(), 'url' => $this->faker->url(), 'title' => $this->faker->boolean(70) ? $this->faker->words(random_int(2, 5), true) diff --git a/database/factories/LinkListFactory.php b/database/factories/LinkListFactory.php index 7cdd4588..9413e76b 100644 --- a/database/factories/LinkListFactory.php +++ b/database/factories/LinkListFactory.php @@ -18,7 +18,7 @@ class LinkListFactory extends Factory public function definition(): array { return [ - 'user_id' => User::first()->id ?? User::factory(), + 'user_id' => User::notSystem()->first()->id ?? User::factory(), 'name' => ucwords($this->faker->words(random_int(2, 5), true)), 'description' => random_int(0, 1) ? $this->faker->sentences(random_int(1, 2), true) : null, 'visibility' => ModelAttribute::VISIBILITY_PUBLIC, diff --git a/database/factories/NoteFactory.php b/database/factories/NoteFactory.php index 5dcbd9fd..b8f5a18c 100644 --- a/database/factories/NoteFactory.php +++ b/database/factories/NoteFactory.php @@ -19,7 +19,7 @@ class NoteFactory extends Factory public function definition(): array { return [ - 'user_id' => User::first()->id ?? User::factory(), + 'user_id' => User::notSystem()->first()->id ?? User::factory(), 'link_id' => Link::first()->id ?? Link::factory(), 'note' => $this->faker->sentences(random_int(1, 5), true), 'visibility' => ModelAttribute::VISIBILITY_PUBLIC, diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php index f8e1c4a7..5206efe0 100644 --- a/database/factories/TagFactory.php +++ b/database/factories/TagFactory.php @@ -18,7 +18,7 @@ class TagFactory extends Factory public function definition(): array { return [ - 'user_id' => User::first()->id ?? User::factory(), + 'user_id' => User::notSystem()->first()->id ?? User::factory(), 'name' => $this->faker->words(random_int(2, 3), true), 'visibility' => ModelAttribute::VISIBILITY_PUBLIC, ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 846f822c..5a9c2eb8 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,9 +2,10 @@ namespace Database\Factories; +use App\Actions\Settings\SetDefaultSettingsForUser; +use App\Enums\Role; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Str; class UserFactory extends Factory { @@ -21,4 +22,12 @@ public function definition(): array 'password' => '$2y$10$9.preebMjZ.8obdvk5ZVdOCw7Cq1EJm6i1B1RJevxCXYW0lUiwDJG', // secretpassword ]; } + + public function configure(): UserFactory + { + return $this->afterCreating(function (User $user) { + (new SetDefaultSettingsForUser($user))->up(); + $user->assignRole(Role::USER); + }); + } } diff --git a/database/migrations/2022_06_23_112431_migrate_user_data.php b/database/migrations/2022_06_23_112431_migrate_user_data.php index 2139f81d..6c24e710 100644 --- a/database/migrations/2022_06_23_112431_migrate_user_data.php +++ b/database/migrations/2022_06_23_112431_migrate_user_data.php @@ -10,7 +10,10 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class MigrateUserData extends Migration { @@ -27,6 +30,7 @@ public function up() $this->addUserRoles(); $this->migrateApiTokens(); + $this->createSystemUser(); } protected function migrateLinkVisibility(): void @@ -140,4 +144,17 @@ public function migrateApiTokens(): void $table->dropColumn('api_token'); }); } + + public function createSystemUser(): void + { + User::forceCreate([ + 'id' => '0', + 'name' => 'System', + 'email' => 'system@localhost', + 'password' => Hash::make(Str::random(128)), + 'two_factor_secret' => encrypt(Str::random(128)), + ]); + + DB::table('users')->where('email', 'system@localhost')->update(['id' => 0]); + } } diff --git a/database/settings/2022_06_22_124112_migrate_existing_settings.php b/database/settings/2022_06_22_124112_migrate_existing_settings.php index 62f06741..f4dfdce7 100644 --- a/database/settings/2022_06_22_124112_migrate_existing_settings.php +++ b/database/settings/2022_06_22_124112_migrate_existing_settings.php @@ -16,7 +16,7 @@ public function up(): void $this->migrateSystemSettings(); $this->migrateGuestSettings(); - $this->migrateUserSettings(); + $this->migrateAllUserSettings(); } protected function migrateSystemSettings(): void @@ -60,85 +60,93 @@ protected function migrateGuestSettings() $this->migrator->add('guest.share_xing', (bool)$this->sysSettings->get('guest_share_xing', false)); } - protected function migrateUserSettings(): void + protected function migrateAllUserSettings(): void { + foreach (DB::table('users')->pluck('id') as $userId) { + $this->migrateUserSettings($userId); + } + } + + protected function migrateUserSettings(int $userId): void + { + $id = 'user-' . $userId; $this->migrator->add( - 'user-1.timezone', + $id . '.timezone', $this->userSettings->get('timezone', 'UTC') ); $this->migrator->add( - 'user-1.date_format', + $id . '.date_format', $this->userSettings->get('date_format', config('linkace.default.date_format')) ); $this->migrator->add( - 'user-1.time_format', + $id . '.time_format', $this->userSettings->get('time_format', config('linkace.default.time_format')) ); $this->migrator->add( - 'user-1.locale', + $id . '.locale', $this->userSettings->get('locale', config('app.fallback_locale')) ); $this->migrator->add( - 'user-1.profile_is_public', + $id . '.profile_is_public', (bool)$this->sysSettings->get('system_guest_access', false) ); $this->migrator->add( - 'user-1.links_default_visibility', + $id . '.links_default_visibility', $this->userSettings->get('links_private_default', false) ? ModelAttribute::VISIBILITY_PRIVATE : ModelAttribute::VISIBILITY_PUBLIC ); $this->migrator->add( - 'user-1.notes_default_visibility', + $id . '.notes_default_visibility', $this->userSettings->get('notes_private_default', false) ? ModelAttribute::VISIBILITY_PRIVATE : ModelAttribute::VISIBILITY_PUBLIC ); $this->migrator->add( - 'user-1.lists_default_visibility', + $id . '.lists_default_visibility', $this->userSettings->get('lists_private_default', false) ? ModelAttribute::VISIBILITY_PRIVATE : ModelAttribute::VISIBILITY_PUBLIC ); $this->migrator->add( - 'user-1.tags_default_visibility', + $id . '.tags_default_visibility', $this->userSettings->get('tags_private_default', false) ? ModelAttribute::VISIBILITY_PRIVATE : ModelAttribute::VISIBILITY_PUBLIC ); $this->migrator->add( - 'user-1.archive_backups_enabled', + $id . '.archive_backups_enabled', (bool)$this->userSettings->get('archive_backups_enabled', true) ); $this->migrator->add( - 'user-1.archive_private_backups_enabled', + $id . '.archive_private_backups_enabled', (bool)$this->userSettings->get('archive_private_backups_enabled', true) ); - $this->migrator->add('user-1.listitem_count', (int)$this->userSettings->get('listitem_count', 24)); - $this->migrator->add('user-1.darkmode_setting', (int)$this->userSettings->get('darkmode_setting', 2)); - $this->migrator->add('user-1.link_display_mode', (int)$this->userSettings->get('link_display_mode', 1)); - $this->migrator->add('user-1.links_new_tab', (bool)$this->userSettings->get('links_new_tab', false)); - $this->migrator->add('user-1.markdown_for_text', (bool)$this->userSettings->get('markdown_for_text', true)); + $this->migrator->add($id . '.listitem_count', (int)$this->userSettings->get('listitem_count', 24)); + $this->migrator->add($id . '.darkmode_setting', (int)$this->userSettings->get('darkmode_setting', 2)); + $this->migrator->add($id . '.link_display_mode', (int)$this->userSettings->get('link_display_mode', 1)); + $this->migrator->add($id . '.links_new_tab', (bool)$this->userSettings->get('links_new_tab', false)); + $this->migrator->add($id . '.markdown_for_text', (bool)$this->userSettings->get('markdown_for_text', true)); - $this->migrator->add('user-1.share_email', (bool)$this->userSettings->get('share_email', true)); - $this->migrator->add('user-1.share_buffer', (bool)$this->userSettings->get('share_buffer', true)); - $this->migrator->add('user-1.share_evernote', (bool)$this->userSettings->get('share_evernote', true)); - $this->migrator->add('user-1.share_facebook', (bool)$this->userSettings->get('share_facebook', true)); - $this->migrator->add('user-1.share_flipboard', (bool)$this->userSettings->get('share_flipboard', true)); - $this->migrator->add('user-1.share_hackernews', (bool)$this->userSettings->get('share_hackernews', true)); - $this->migrator->add('user-1.share_linkedin', (bool)$this->userSettings->get('share_linkedin', true)); - $this->migrator->add('user-1.share_mastodon', (bool)$this->userSettings->get('share_mastodon', true)); - $this->migrator->add('user-1.share_pinterest', (bool)$this->userSettings->get('share_pinterest', true)); - $this->migrator->add('user-1.share_pocket', (bool)$this->userSettings->get('share_pocket', true)); - $this->migrator->add('user-1.share_reddit', (bool)$this->userSettings->get('share_reddit', true)); - $this->migrator->add('user-1.share_skype', (bool)$this->userSettings->get('share_skype', true)); - $this->migrator->add('user-1.share_sms', (bool)$this->userSettings->get('share_sms', true)); - $this->migrator->add('user-1.share_telegram', (bool)$this->userSettings->get('share_telegram', true)); - $this->migrator->add('user-1.share_trello', (bool)$this->userSettings->get('share_trello', true)); - $this->migrator->add('user-1.share_tumblr', (bool)$this->userSettings->get('share_tumblr', true)); - $this->migrator->add('user-1.share_twitter', (bool)$this->userSettings->get('share_twitter', true)); - $this->migrator->add('user-1.share_wechat', (bool)$this->userSettings->get('share_wechat', true)); - $this->migrator->add('user-1.share_whatsapp', (bool)$this->userSettings->get('share_whatsapp', true)); - $this->migrator->add('user-1.share_xing', (bool)$this->userSettings->get('share_xing', true)); + $this->migrator->add($id . '.share_email', (bool)$this->userSettings->get('share_email', true)); + $this->migrator->add($id . '.share_buffer', (bool)$this->userSettings->get('share_buffer', true)); + $this->migrator->add($id . '.share_evernote', (bool)$this->userSettings->get('share_evernote', true)); + $this->migrator->add($id . '.share_facebook', (bool)$this->userSettings->get('share_facebook', true)); + $this->migrator->add($id . '.share_flipboard', (bool)$this->userSettings->get('share_flipboard', true)); + $this->migrator->add($id . '.share_hackernews', (bool)$this->userSettings->get('share_hackernews', true)); + $this->migrator->add($id . '.share_linkedin', (bool)$this->userSettings->get('share_linkedin', true)); + $this->migrator->add($id . '.share_mastodon', (bool)$this->userSettings->get('share_mastodon', true)); + $this->migrator->add($id . '.share_pinterest', (bool)$this->userSettings->get('share_pinterest', true)); + $this->migrator->add($id . '.share_pocket', (bool)$this->userSettings->get('share_pocket', true)); + $this->migrator->add($id . '.share_reddit', (bool)$this->userSettings->get('share_reddit', true)); + $this->migrator->add($id . '.share_skype', (bool)$this->userSettings->get('share_skype', true)); + $this->migrator->add($id . '.share_sms', (bool)$this->userSettings->get('share_sms', true)); + $this->migrator->add($id . '.share_telegram', (bool)$this->userSettings->get('share_telegram', true)); + $this->migrator->add($id . '.share_trello', (bool)$this->userSettings->get('share_trello', true)); + $this->migrator->add($id . '.share_tumblr', (bool)$this->userSettings->get('share_tumblr', true)); + $this->migrator->add($id . '.share_twitter', (bool)$this->userSettings->get('share_twitter', true)); + $this->migrator->add($id . '.share_wechat', (bool)$this->userSettings->get('share_wechat', true)); + $this->migrator->add($id . '.share_whatsapp', (bool)$this->userSettings->get('share_whatsapp', true)); + $this->migrator->add($id . '.share_xing', (bool)$this->userSettings->get('share_xing', true)); } } diff --git a/lang/en_US/auth.php b/lang/en_US/auth.php index 3f79a5b3..5074e3ef 100644 --- a/lang/en_US/auth.php +++ b/lang/en_US/auth.php @@ -32,10 +32,21 @@ 'api_tokens.generate' => 'Generate a new API Token', 'api_tokens.generate_short' => 'Generate Token', 'api_tokens.generate_help' => 'API tokens are used to authenticate yourself when using the LinkAce API.', - 'api_tokens.generated_successfully' => 'Your API token was generated successfully: :token', + 'api_tokens.generated_successfully' => 'The API token was generated successfully: :token', 'api_tokens.generated_help' => 'Please store this token in a safe place. It is not possible to recover your token if you lose it.', 'api_tokens.name' => 'Token name', 'api_tokens.name_help' => 'Choose a name for your token. The name can only contain alpha-numeric characters, dashes, and underscores. Helpful if you want to create separate tokens for different use cases or applications.', + + 'api_token_system' => 'System API Token', + 'api_tokens_system' => 'System API Tokens', + 'api_tokens.generate_help_system' => 'API tokens are used to access the LinkAce API from other applications or scripts. By default, only public or internal data is accessible, but tokens can be granted additional access to private data if needed.', + 'api_tokens.private_access' => 'Token can access private data', + 'api_tokens.private_access_help' => 'The token access and change private links, lists, tags and notes of any user based on the specified abilities.', + 'api_tokens.abilities' => 'Token abilities', + 'api_tokens.abilities_select' => 'Select token abilities...', + 'api_tokens.abilities_help' => 'Select all abilities a token can have. Abilities cannot be changed later.', + 'api_tokens.ability_private_access' => 'Token can access private data', + 'api_tokens.revoke' => 'Revoke token', 'api_tokens.revoke_confirm' => 'Do you really want to revoke this token? This step cannot be undone and the token cannot be recovered.', 'api_tokens.revoke_successful' => 'The token was revoked successfully.', diff --git a/lang/en_US/linkace.php b/lang/en_US/linkace.php index 0f064366..0bb357fe 100644 --- a/lang/en_US/linkace.php +++ b/lang/en_US/linkace.php @@ -40,6 +40,7 @@ 'block' => 'Block', 'unblock' => 'Unblock', 'unblocked' => 'Unblocked', + 'details' => 'Details', 'menu' => 'Menu', 'entries' => 'Entries', diff --git a/lang/en_US/user.php b/lang/en_US/user.php index 42ef1207..8292a078 100644 --- a/lang/en_US/user.php +++ b/lang/en_US/user.php @@ -25,6 +25,8 @@ 'restore_confirmation' => 'Do you really want to restore this User?', 'restore_successful' => 'The user :username was restored successfully.', + 'system_user_locked' => 'The system user cannot login like a regular user. Please login with your personal account.', + 'action_not_allowed_on_user' => 'This action cannot be performed on the selected user.', 'history_deleted' => 'User :name was deleted', diff --git a/lang/en_US/validation.php b/lang/en_US/validation.php index 863ba06f..534f4fd0 100644 --- a/lang/en_US/validation.php +++ b/lang/en_US/validation.php @@ -128,6 +128,9 @@ 'visibility' => [ 'visibility' => 'The Visibility must bei either 1 (public), 2 (internal) or 3 (private).', ], + 'api_token_ability' => [ + 'api_token_ability' => 'The API token must at least have one ability from the predefined token abilities.', + ], ], /* diff --git a/resources/assets/js/components/SimpleSelect.js b/resources/assets/js/components/SimpleSelect.js index aeef6cbc..b518b3dc 100644 --- a/resources/assets/js/components/SimpleSelect.js +++ b/resources/assets/js/components/SimpleSelect.js @@ -8,9 +8,14 @@ TomSelect.define('input_autogrow', TomSelect_input_autogrow); export default class SimpleSelect { constructor ($el) { - new TomSelect($el, { + let options = { plugins: ['caret_position', 'input_autogrow'], create: false - }); + }; + if (typeof $el.dataset.selectConfig !== 'undefined') { + const additionalOptions = JSON.parse($el.dataset.selectConfig); + options = {...options, ...additionalOptions}; + } + new TomSelect($el, options); } } diff --git a/resources/views/admin/api-tokens/index.blade.php b/resources/views/admin/api-tokens/index.blade.php new file mode 100644 index 00000000..c2ccdcef --- /dev/null +++ b/resources/views/admin/api-tokens/index.blade.php @@ -0,0 +1,125 @@ +@extends('layouts.app') + +@section('content') + + @if(session()->has('new_token')) +
+

+ + @lang('auth.api_tokens.generated_successfully', ['token' => session()->get('new_token')]) + +

+

@lang('auth.api_tokens.generated_help')

+
+ @endif + +
+
+ @lang('auth.api_tokens_system') +
+
+ +
+ + + + + + + + @forelse($tokens as $token) + + + + + + + @empty + + + + @endforelse +
@lang('auth.api_tokens.name')@lang('linkace.created_at')@lang('linkace.last_used')
{{ $token->name }}{{ $token->created_at }}{{ $token->last_used ?: trans('linkace.never_used') }} +
+ @csrf + @method('DELETE') + + @lang('linkace.details') + + +
+
@lang('auth.api_tokens.no_tokens_found')
+
+ +
+
+ +
+
+ @lang('auth.api_tokens.generate') +
+
+ +

@lang('auth.api_tokens.generate_help_system')

+ +
+ @csrf +
+ + +

@lang('auth.api_tokens.name_help')

+ @if ($errors->has('token_name')) + + @endif +
+ +
+ +

@lang('auth.api_tokens.private_access_help')

+ @if($errors->has('private_access')) + + @endif +
+ +
+ + +

@lang('auth.api_tokens.abilities_help')

+ @if($errors->has('private_access')) + + @endif +
+ + +
+ +
+
+ +@endsection diff --git a/resources/views/admin/api-tokens/show.blade.php b/resources/views/admin/api-tokens/show.blade.php new file mode 100644 index 00000000..ff67bc8c --- /dev/null +++ b/resources/views/admin/api-tokens/show.blade.php @@ -0,0 +1,35 @@ +@extends('layouts.app') + +@section('content') + + @if(session()->has('new_token')) +
+

+ + @lang('auth.api_tokens.generated_successfully', ['token' => session()->get('new_token')]) + +

+

@lang('auth.api_tokens.generated_help')

+
+ @endif + +
+
+ @lang('auth.api_token_system') +
+
+ +

{{ $token->name }}

+ +

@lang('auth.api_tokens.abilities'):

+ + +

@lang('auth.api_tokens.ability_private_access'): {{ in_array(\App\Enums\ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE, $token->abilities) ? trans('linkace.yes') : trans('linkace.no') }}

+
+
+ +@endsection diff --git a/resources/views/partials/nav-user.blade.php b/resources/views/partials/nav-user.blade.php index e5e4224e..eb7bdb3f 100644 --- a/resources/views/partials/nav-user.blade.php +++ b/resources/views/partials/nav-user.blade.php @@ -35,6 +35,9 @@ @lang('admin.user_management.title') + + @lang('auth.api_tokens_system') + @lang('audit.log') diff --git a/routes/web.php b/routes/web.php index 35ff3c3e..3e445539 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('system.users.invite-delete'); + Route::resource('system/api-tokens', AdminApiTokenController::class) + ->names([ + 'index' => 'system.api-tokens.index', + 'show' => 'system.api-tokens.show', + 'store' => 'system.api-tokens.store', + 'destroy' => 'system.api-tokens.destroy', + ]) + ->only(['index', 'show', 'store', 'destroy']); + Route::get('system/logs', [LogViewerController::class, 'index'])->name('system-logs'); Route::get('system/audit', AuditController::class)->name('system-audit'); }); diff --git a/tests/Controller/API/ApiTestCase.php b/tests/Controller/API/ApiTestCase.php index 46a3af50..ac9a9c3a 100644 --- a/tests/Controller/API/ApiTestCase.php +++ b/tests/Controller/API/ApiTestCase.php @@ -14,12 +14,14 @@ abstract class ApiTestCase extends TestCase { protected User $user; protected string $accessToken; + protected ?User $systemUser = null; + protected ?string $systemAccessToken = null; protected function setUp(): void { parent::setUp(); - $this->user = User::first() ?: User::factory()->create(); + $this->user = User::notSystem()->first() ?: User::factory()->create(); $this->accessToken = $this->user->createToken('api-test', [ApiToken::ABILITY_USER_ACCESS])->plainTextToken; Queue::fake(); @@ -31,20 +33,28 @@ protected function setUp(): void ''; Http::fake([ - 'example.com' => Http::response($testHtml, 200), + 'example.com' => Http::response($testHtml), ]); } + protected function createSystemToken(array $abilities = []): void + { + $this->systemUser = User::getSystemUser(); + $abilities[] = ApiToken::ABILITY_SYSTEM_ACCESS; + $this->systemAccessToken = $this->systemUser->createToken('api-test', $abilities)->plainTextToken; + } + /** * Send an authorized JSON request for the GET method. * * @param string $uri * @param array $headers + * @param bool $useSystemToken * @return TestResponse */ - public function getJsonAuthorized(string $uri, array $headers = []): TestResponse + public function getJsonAuthorized(string $uri, array $headers = [], bool $useSystemToken = false): TestResponse { - $headers['Authorization'] = 'Bearer ' . $this->accessToken; + $headers['Authorization'] = 'Bearer ' . ($useSystemToken ? $this->systemAccessToken : $this->accessToken); return $this->getJson($uri, $headers); } @@ -54,11 +64,16 @@ public function getJsonAuthorized(string $uri, array $headers = []): TestRespons * @param string $uri * @param array $data * @param array $headers + * @param bool $useSystemToken * @return TestResponse */ - public function postJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse - { - $headers['Authorization'] = 'Bearer ' . $this->accessToken; + public function postJsonAuthorized( + string $uri, + array $data = [], + array $headers = [], + bool $useSystemToken = false + ): TestResponse { + $headers['Authorization'] = 'Bearer ' . ($useSystemToken ? $this->systemAccessToken : $this->accessToken); return $this->postJson($uri, $data, $headers); } @@ -68,11 +83,16 @@ public function postJsonAuthorized(string $uri, array $data = [], array $headers * @param string $uri * @param array $data * @param array $headers + * @param bool $useSystemToken * @return TestResponse */ - public function patchJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse - { - $headers['Authorization'] = 'Bearer ' . $this->accessToken; + public function patchJsonAuthorized( + string $uri, + array $data = [], + array $headers = [], + bool $useSystemToken = false + ): TestResponse { + $headers['Authorization'] = 'Bearer ' . ($useSystemToken ? $this->systemAccessToken : $this->accessToken); return $this->patchJson($uri, $data, $headers); } @@ -82,11 +102,16 @@ public function patchJsonAuthorized(string $uri, array $data = [], array $header * @param string $uri * @param array $data * @param array $headers + * @param bool $useSystemToken * @return TestResponse */ - public function deleteJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse - { - $headers['Authorization'] = 'Bearer ' . $this->accessToken; + public function deleteJsonAuthorized( + string $uri, + array $data = [], + array $headers = [], + bool $useSystemToken = false + ): TestResponse { + $headers['Authorization'] = 'Bearer ' . ($useSystemToken ? $this->systemAccessToken : $this->accessToken); return $this->deleteJson($uri, $data, $headers); } } diff --git a/tests/Controller/API/LinkApiTest.php b/tests/Controller/API/LinkApiTest.php index 1e5c58ca..8bafd10b 100644 --- a/tests/Controller/API/LinkApiTest.php +++ b/tests/Controller/API/LinkApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Controller\API; +use App\Enums\ApiToken; use App\Models\Link; use App\Models\LinkList; use App\Models\Tag; @@ -55,6 +56,51 @@ public function testIndexRequest(): void ]); } + public function testForbiddenIndexRequestFromSystem(): void + { + $this->createTestLinks(); + $this->createSystemToken(); + + $this->getJsonAuthorized('api/v1/links', useSystemToken: true) + ->assertForbidden(); + } + + public function testIndexRequestFromSystem(): void + { + $this->createTestLinks(); + $this->createSystemToken([ApiToken::ABILITY_LINKS_READ]); + + $this->getJsonAuthorized('api/v1/links', useSystemToken: true) + ->assertOk() + ->assertJson([ + 'data' => [ + ['url' => 'https://public-link.com'], + ['url' => 'https://internal-link.com'], + ], + ]) + ->assertJsonMissing([ + 'data' => [ + ['url' => 'https://private-link.com'], + ], + ]); + } + + public function testIndexRequestFromSystemWithPrivate(): void + { + $this->createTestLinks(); + $this->createSystemToken([ApiToken::ABILITY_LINKS_READ, ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE]); + + $this->getJsonAuthorized('api/v1/links', useSystemToken: true) + ->assertOk() + ->assertJson([ + 'data' => [ + ['url' => 'https://public-link.com'], + ['url' => 'https://internal-link.com'], + ['url' => 'https://private-link.com'], + ], + ]); + } + public function testMinimalCreateRequest(): void { $this->postJsonAuthorized('api/v1/links', [ diff --git a/tests/Controller/App/BookmarkletControllerTest.php b/tests/Controller/App/BookmarkletControllerTest.php index b6c9a580..6eac82a3 100644 --- a/tests/Controller/App/BookmarkletControllerTest.php +++ b/tests/Controller/App/BookmarkletControllerTest.php @@ -29,7 +29,7 @@ public function testBookmarkletWithExistingLink(): void 'url' => 'https://example.com/test', ]); - $this->actingAs(User::first()); + $this->actingAs(User::notSystem()->first()); $response = $this->get('bookmarklet/add?u=https://example.com/test&t=Example%20Title'); diff --git a/tests/Controller/App/ExportControllerTest.php b/tests/Controller/App/ExportControllerTest.php index 284b284f..5ffef488 100644 --- a/tests/Controller/App/ExportControllerTest.php +++ b/tests/Controller/App/ExportControllerTest.php @@ -19,7 +19,7 @@ protected function setUp(): void $this->seed('ExampleSeeder'); - $this->user = User::first(); + $this->user = User::notSystem()->first(); $this->actingAs($this->user); } diff --git a/tests/Controller/App/UserSettingsControllerTest.php b/tests/Controller/App/UserSettingsControllerTest.php index 6efa5786..c9cdb387 100644 --- a/tests/Controller/App/UserSettingsControllerTest.php +++ b/tests/Controller/App/UserSettingsControllerTest.php @@ -42,7 +42,7 @@ public function testValidUpdateAccountSettingsResponse(): void $response->assertRedirect('/'); - $updatedUser = User::first(); + $updatedUser = User::notSystem()->first(); $this->assertEquals('New Name', $updatedUser->name); $this->assertEquals('test@linkace.org', $updatedUser->email); diff --git a/tests/Controller/Models/LinkControllerTest.php b/tests/Controller/Models/LinkControllerTest.php index 76566513..680ab635 100644 --- a/tests/Controller/Models/LinkControllerTest.php +++ b/tests/Controller/Models/LinkControllerTest.php @@ -19,8 +19,6 @@ class LinkControllerTest extends TestCase use RefreshDatabase; use PreparesTestData; - private $basicTestHtml; - protected function setUp(): void { parent::setUp(); @@ -28,14 +26,14 @@ protected function setUp(): void $user = User::factory()->create(); $this->actingAs($user); - $this->basicTestHtml = '' . + $basicTestHtml = '' . 'Example Title' . '' . ''; Http::preventStrayRequests(); Http::fake([ - 'example.com' => Http::response($this->basicTestHtml), + 'example.com' => Http::response($basicTestHtml), ]); Queue::fake(); diff --git a/tests/Database/ExampleSeedingTest.php b/tests/Database/ExampleSeedingTest.php index 1f0c1783..05f7847f 100644 --- a/tests/Database/ExampleSeedingTest.php +++ b/tests/Database/ExampleSeedingTest.php @@ -24,7 +24,7 @@ protected function setUp(): void public function testSeedingResults(): void { - $this->assertEquals(1, User::count()); + $this->assertEquals(2, User::count()); $this->assertEquals(10, LinkList::count()); $this->assertEquals(30, Tag::count()); $this->assertEquals(50, Link::count());