1
0
mirror of https://github.com/Kovah/LinkAce.git synced 2025-04-15 04:06:01 +02:00

WIP: system-wide api tokens (#165)

This is a ton of work. A new system user is generated while running the migrations. System tokes are bound to that user. Api calls need to be properly authorized, which feels really hacky at the moment. I only implemented link api tests for now.
This commit is contained in:
Kovah 2022-09-29 10:10:58 +02:00
parent b38bb3b72f
commit b2705deeb5
No known key found for this signature in database
GPG Key ID: AAAA031BA9830D7B
44 changed files with 847 additions and 78 deletions

View File

@ -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';
}

View File

@ -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,
];
}

View File

@ -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;
}

View File

@ -13,6 +13,8 @@ class LinkCheckController extends Controller
{
$searchedUrl = $request->input('url', false);
$this->authorize('viewAny', Link::class);
if (!$searchedUrl) {
return response()->json(['linksFound' => false]);
}

View File

@ -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 @@ class LinkController extends Controller
$this->checkOrdering();
$links = Link::query()
->visibleForUser()
->visibleForUser(privateSystemAccess: $request->user()->tokenCan(ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE))
->orderBy($this->orderBy, $this->orderDir)
->paginate(getPaginationLimit());

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\ActivityLog;
use App\Enums\ApiToken;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CreateSystemApiTokenRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
class ApiTokenController extends Controller
{
public function index()
{
return view('admin.api-tokens.index', [
'tokens' => 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');
}
}

View File

@ -11,6 +11,10 @@ class Authenticate extends IlluminateAuthenticate
{
$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'));
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin;
use App\Rules\ApiTokenAbilityRule;
use Illuminate\Database\Query\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateSystemApiTokenRequest extends FormRequest
{
public function rules(): array
{
return [
'token_name' => [
'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',
],
];
}
}

View File

@ -7,12 +7,16 @@ use Illuminate\Database\Eloquent\Builder;
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)

View File

@ -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 @@ class User extends Authenticatable implements Auditable
{
return $this->is(auth()->user());
}
public function isSystemUser(): bool
{
return $this->id === 0;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Policies\Api;
use App\Enums\ApiToken;
use App\Enums\ModelAttribute;
use App\Models\Link;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class LinkApiPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
if ($user->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;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Policies\Api;
use App\Enums\ModelAttribute;
use App\Models\LinkList;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class LinkListApiPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, LinkList $list): bool
{
return $this->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;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Policies\Api;
use App\Enums\ModelAttribute;
use App\Models\Note;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class NoteApiPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Note $note): bool
{
return $this->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;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Policies\Api;
use App\Enums\ModelAttribute;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class TagApiPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Tag $tag): bool
{
return $this->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;
}
}

View File

@ -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 @@ class ApiTokenPolicy
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

View File

@ -6,6 +6,10 @@ use App\Models\Link;
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,
];
/**

View File

@ -0,0 +1,48 @@
<?php
namespace App\Rules;
use App\Enums\ApiToken;
use Illuminate\Contracts\Validation\Rule;
class ApiTokenAbilityRule implements Rule
{
private static array $availableAbilities = [
ApiToken::ABILITY_LINKS_READ,
ApiToken::ABILITY_LINKS_CREATE,
ApiToken::ABILITY_LINKS_UPDATE,
ApiToken::ABILITY_LINKS_DELETE,
ApiToken::ABILITY_LISTS_READ,
ApiToken::ABILITY_LISTS_CREATE,
ApiToken::ABILITY_LISTS_UPDATE,
ApiToken::ABILITY_LISTS_DELETE,
ApiToken::ABILITY_TAGS_READ,
ApiToken::ABILITY_TAGS_CREATE,
ApiToken::ABILITY_TAGS_UPDATE,
ApiToken::ABILITY_TAGS_DELETE,
ApiToken::ABILITY_NOTES_READ,
ApiToken::ABILITY_NOTES_CREATE,
ApiToken::ABILITY_NOTES_UPDATE,
ApiToken::ABILITY_NOTES_DELETE,
];
public function passes($attribute, $value): bool
{
if (!is_array($value) || empty($value)) {
return false;
}
foreach ($value as $item) {
if (!in_array($item, self::$availableAbilities, true)) {
return false;
}
}
return true;
}
public function message(): string
{
return trans('validation.custom.api_token_ability.api_token_ability');
}
}

View File

@ -49,22 +49,22 @@ class UserSettings extends Settings
public bool $share_whatsapp;
public bool $share_xing;
private static int $user_id = 0;
private static int $userId = 0;
public static function group(): string
{
return 'user-' . self::getUserId();
}
public static function setUserId(int $user_id): void
public static function setUserId(int $userId): void
{
self::$user_id = $user_id;
self::$userId = $userId;
}
// By default, settings are scoped to the currently authenticated user
protected static function getUserId(): int
{
return self::$user_id ?: auth()->id();
return self::$userId ?: auth()->id();
}
public static function defaults(): array

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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,
];

View File

@ -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 @@ class UserFactory extends Factory
'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);
});
}
}

View File

@ -10,7 +10,10 @@ use App\Models\User;
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 @@ class MigrateUserData extends Migration
$this->addUserRoles();
$this->migrateApiTokens();
$this->createSystemUser();
}
protected function migrateLinkVisibility(): void
@ -140,4 +144,17 @@ class MigrateUserData extends Migration
$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]);
}
}

View File

@ -16,7 +16,7 @@ class MigrateExistingSettings extends SettingsMigration
$this->migrateSystemSettings();
$this->migrateGuestSettings();
$this->migrateUserSettings();
$this->migrateAllUserSettings();
}
protected function migrateSystemSettings(): void
@ -60,85 +60,93 @@ class MigrateExistingSettings extends SettingsMigration
$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));
}
}

View File

@ -32,10 +32,21 @@ return [
'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: <code>:token</code>',
'api_tokens.generated_successfully' => 'The API token was generated successfully: <code>:token</code>',
'api_tokens.generated_help' => 'Please store this token in a safe place. It is <strong>not</strong> 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.',

View File

@ -40,6 +40,7 @@ return [
'block' => 'Block',
'unblock' => 'Unblock',
'unblocked' => 'Unblocked',
'details' => 'Details',
'menu' => 'Menu',
'entries' => 'Entries',

View File

@ -25,6 +25,8 @@ return [
'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 <code>:name</code> was deleted',

View File

@ -128,6 +128,9 @@ return [
'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.',
],
],
/*

View File

@ -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);
}
}

View File

@ -0,0 +1,125 @@
@extends('layouts.app')
@section('content')
@if(session()->has('new_token'))
<div class="alert alert-warning mb-4">
<p class="text-xl mb-2">
<strong>
@lang('auth.api_tokens.generated_successfully', ['token' => session()->get('new_token')])
</strong>
</p>
<p class="mb-0">@lang('auth.api_tokens.generated_help')</p>
</div>
@endif
<div class="card">
<div class="card-header">
@lang('auth.api_tokens_system')
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<tr>
<th>@lang('auth.api_tokens.name')</th>
<th>@lang('linkace.created_at')</th>
<th>@lang('linkace.last_used')</th>
<th></th>
</tr>
@forelse($tokens as $token)
<tr>
<td>{{ $token->name }}</td>
<td>{{ $token->created_at }}</td>
<td>{{ $token->last_used ?: trans('linkace.never_used') }}</td>
<td>
<form action="{{ route('api-tokens.destroy', ['api_token' => $token]) }}" method="post"
data-confirmation="@lang('auth.api_tokens.revoke_confirm')" class="text-end">
@csrf
@method('DELETE')
<a href="{{ route('system.api-tokens.show', ['api_token' => $token]) }}"
class="btn btn-sm btn-outline-primary">
@lang('linkace.details')
</a>
<button type="submit" class="btn btn-sm btn-outline-danger">
@lang('auth.api_tokens.revoke')
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-muted">@lang('auth.api_tokens.no_tokens_found')</td>
</tr>
@endforelse
</table>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
@lang('auth.api_tokens.generate')
</div>
<div class="card-body">
<p class="mb-4">@lang('auth.api_tokens.generate_help_system')</p>
<form action="{{ route('system.api-tokens.store') }}" method="post">
@csrf
<div class="mb-4">
<label class="form-label" for="token_name">
@lang('auth.api_tokens.name')
</label>
<input type="text" name="token_name" id="token_name" required
class="form-control{{ $errors->has('token_name') ? ' is-invalid' : '' }}"
value="{{ old('token_name') }}">
<p class="text-muted small mt-1">@lang('auth.api_tokens.name_help')</p>
@if ($errors->has('token_name'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('token_name') }}
</p>
@endif
</div>
<div class="mb-4">
<label class="form-label" for="private_access">
<input type="checkbox" id="private_access" name="private_access" class="form-check-input me-2"
@checked(old('private_access'))> @lang('auth.api_tokens.private_access')
</label>
<p class="text-muted small mt-1">@lang('auth.api_tokens.private_access_help')</p>
@if($errors->has('private_access'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('private_access') }}
</p>
@endif
</div>
<div class="mb-4">
<label class="form-label" for="abilities">
@lang('auth.api_tokens.abilities')
</label>
<select id="abilities" name="abilities[]" multiple autocomplete="off" required
placeholder="@lang('auth.api_tokens.abilities_select')" data-select-config='{"create":false}'
class="simple-select {{ $errors->has('abilities') ? ' is-invalid' : '' }}">
<option value="0">@lang('auth.api_tokens.abilities_select')</option>
@foreach(\App\Enums\ApiToken::$systemTokenAbilities as $ability)
<option value="{{ $ability }}">{{ $ability }}</option>
@endforeach
</select>
<p class="text-muted small mt-1">@lang('auth.api_tokens.abilities_help')</p>
@if($errors->has('private_access'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('private_access') }}
</p>
@endif
</div>
<button type="submit" class="btn btn-primary">@lang('auth.api_tokens.generate_short')</button>
</form>
</div>
</div>
@endsection

View File

@ -0,0 +1,35 @@
@extends('layouts.app')
@section('content')
@if(session()->has('new_token'))
<div class="alert alert-warning mb-4">
<p class="text-xl mb-2">
<strong>
@lang('auth.api_tokens.generated_successfully', ['token' => session()->get('new_token')])
</strong>
</p>
<p class="mb-0">@lang('auth.api_tokens.generated_help')</p>
</div>
@endif
<div class="card">
<div class="card-header">
@lang('auth.api_token_system')
</div>
<div class="card-body">
<h2 class="mb-3">{{ $token->name }}</h2>
<p>@lang('auth.api_tokens.abilities'):</p>
<ul>
@foreach($token->abilities as $ability)
<li>{{ $ability }}</li>
@endforeach
</ul>
<p>@lang('auth.api_tokens.ability_private_access'): {{ in_array(\App\Enums\ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE, $token->abilities) ? trans('linkace.yes') : trans('linkace.no') }}</p>
</div>
</div>
@endsection

View File

@ -35,6 +35,9 @@
<a href="{{ route('system.users') }}" class="dropdown-item">
@lang('admin.user_management.title')
</a>
<a href="{{ route('system.api-tokens.index') }}" class="dropdown-item">
@lang('auth.api_tokens_system')
</a>
<a href="{{ route('system-audit') }}" class="dropdown-item">
@lang('audit.log')
</a>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\ApiTokenController as AdminApiTokenController;
use App\Http\Controllers\Admin\SystemSettingsController;
use App\Http\Controllers\Admin\UserManagementController;
use App\Http\Controllers\App\ApiTokenController;
@ -170,6 +171,15 @@ Route::group(['middleware' => ['auth', 'role:admin']], function () {
Route::delete('system/users/invite/{invitation}', [UserManagementController::class, 'deleteInvitation'])
->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');
});

View File

@ -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 @@ abstract class ApiTestCase extends TestCase
'</head></html>';
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 @@ abstract class ApiTestCase extends TestCase
* @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 @@ abstract class ApiTestCase extends TestCase
* @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 @@ abstract class ApiTestCase extends TestCase
* @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);
}
}

View File

@ -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 @@ class LinkApiTest extends ApiTestCase
]);
}
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', [

View File

@ -29,7 +29,7 @@ class BookmarkletControllerTest extends TestCase
'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');

View File

@ -19,7 +19,7 @@ class ExportControllerTest extends TestCase
$this->seed('ExampleSeeder');
$this->user = User::first();
$this->user = User::notSystem()->first();
$this->actingAs($this->user);
}

View File

@ -42,7 +42,7 @@ class UserSettingsControllerTest extends TestCase
$response->assertRedirect('/');
$updatedUser = User::first();
$updatedUser = User::notSystem()->first();
$this->assertEquals('New Name', $updatedUser->name);
$this->assertEquals('test@linkace.org', $updatedUser->email);

View File

@ -19,8 +19,6 @@ class LinkControllerTest extends TestCase
use RefreshDatabase;
use PreparesTestData;
private $basicTestHtml;
protected function setUp(): void
{
parent::setUp();
@ -28,14 +26,14 @@ class LinkControllerTest extends TestCase
$user = User::factory()->create();
$this->actingAs($user);
$this->basicTestHtml = '<!DOCTYPE html><head>' .
$basicTestHtml = '<!DOCTYPE html><head>' .
'<title>Example Title</title>' .
'<meta name="description" content="This an example description">' .
'</head></html>';
Http::preventStrayRequests();
Http::fake([
'example.com' => Http::response($this->basicTestHtml),
'example.com' => Http::response($basicTestHtml),
]);
Queue::fake();

View File

@ -24,7 +24,7 @@ class ExampleSeedingTest extends TestCase
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());