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:
parent
b38bb3b72f
commit
b2705deeb5
@ -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';
|
||||
}
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
65
app/Http/Controllers/Admin/ApiTokenController.php
Normal file
65
app/Http/Controllers/Admin/ApiTokenController.php
Normal 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');
|
||||
}
|
||||
}
|
@ -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'));
|
||||
}
|
||||
|
34
app/Http/Requests/Admin/CreateSystemApiTokenRequest.php
Normal file
34
app/Http/Requests/Admin/CreateSystemApiTokenRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
65
app/Policies/Api/LinkApiPolicy.php
Normal file
65
app/Policies/Api/LinkApiPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
57
app/Policies/Api/LinkListApiPolicy.php
Normal file
57
app/Policies/Api/LinkListApiPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
57
app/Policies/Api/NoteApiPolicy.php
Normal file
57
app/Policies/Api/NoteApiPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
57
app/Policies/Api/TagApiPolicy.php
Normal file
57
app/Policies/Api/TagApiPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
48
app/Rules/ApiTokenAbilityRule.php
Normal file
48
app/Rules/ApiTokenAbilityRule.php
Normal 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');
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
];
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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.',
|
||||
|
@ -40,6 +40,7 @@ return [
|
||||
'block' => 'Block',
|
||||
'unblock' => 'Unblock',
|
||||
'unblocked' => 'Unblocked',
|
||||
'details' => 'Details',
|
||||
|
||||
'menu' => 'Menu',
|
||||
'entries' => 'Entries',
|
||||
|
@ -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',
|
||||
|
@ -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.',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
125
resources/views/admin/api-tokens/index.blade.php
Normal file
125
resources/views/admin/api-tokens/index.blade.php
Normal 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
|
35
resources/views/admin/api-tokens/show.blade.php
Normal file
35
resources/views/admin/api-tokens/show.blade.php
Normal 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
|
@ -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>
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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', [
|
||||
|
@ -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');
|
||||
|
||||
|
@ -19,7 +19,7 @@ class ExportControllerTest extends TestCase
|
||||
|
||||
$this->seed('ExampleSeeder');
|
||||
|
||||
$this->user = User::first();
|
||||
$this->user = User::notSystem()->first();
|
||||
$this->actingAs($this->user);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -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());
|
||||
|
Loading…
x
Reference in New Issue
Block a user