diff --git a/.env.example.complete b/.env.example.complete index a13c8b7d0..e44644f08 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -258,3 +258,9 @@ ALLOW_CONTENT_SCRIPTS=false # Contents of the robots.txt file can be overridden, making this option obsolete. ALLOW_ROBOTS=null +# The default and maximum item-counts for listing API requests. +API_DEFAULT_ITEM_COUNT=100 +API_MAX_ITEM_COUNT=500 + +# The number of API requests that can be made per minute by a single user. +API_REQUESTS_PER_MIN=180 \ No newline at end of file diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php new file mode 100644 index 000000000..a0c45608a --- /dev/null +++ b/app/Api/ApiDocsGenerator.php @@ -0,0 +1,125 @@ +getFlatApiRoutes(); + $apiRoutes = $this->loadDetailsFromControllers($apiRoutes); + $apiRoutes = $this->loadDetailsFromFiles($apiRoutes); + $apiRoutes = $apiRoutes->groupBy('base_model'); + return $apiRoutes; + } + + /** + * Load any API details stored in static files. + */ + protected function loadDetailsFromFiles(Collection $routes): Collection + { + return $routes->map(function (array $route) { + $exampleTypes = ['request', 'response']; + foreach ($exampleTypes as $exampleType) { + $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json"); + $exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null; + $route["example_{$exampleType}"] = $exampleContent; + } + return $route; + }); + } + + /** + * Load any details we can fetch from the controller and its methods. + */ + protected function loadDetailsFromControllers(Collection $routes): Collection + { + return $routes->map(function (array $route) { + $method = $this->getReflectionMethod($route['controller'], $route['controller_method']); + $comment = $method->getDocComment(); + $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null; + $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); + return $route; + }); + } + + /** + * Load body params and their rules by inspecting the given class and method name. + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + protected function getBodyParamsFromClass(string $className, string $methodName): ?array + { + /** @var ApiController $class */ + $class = $this->controllerClasses[$className] ?? null; + if ($class === null) { + $class = app()->make($className); + $this->controllerClasses[$className] = $class; + } + + $rules = $class->getValdationRules()[$methodName] ?? []; + foreach ($rules as $param => $ruleString) { + $rules[$param] = explode('|', $ruleString); + } + return count($rules) > 0 ? $rules : null; + } + + /** + * Parse out the description text from a class method comment. + */ + protected function parseDescriptionFromMethodComment(string $comment) + { + $matches = []; + preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches); + return implode(' ', $matches[1] ?? []); + } + + /** + * Get a reflection method from the given class name and method name. + * @throws ReflectionException + */ + protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod + { + $class = $this->reflectionClasses[$className] ?? null; + if ($class === null) { + $class = new ReflectionClass($className); + $this->reflectionClasses[$className] = $class; + } + + return $class->getMethod($methodName); + } + + /** + * Get the system API routes, formatted into a flat collection. + */ + protected function getFlatApiRoutes(): Collection + { + return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { + return strpos($route->uri, 'api/') === 0; + })->map(function ($route) { + [$controller, $controllerMethod] = explode('@', $route->action['uses']); + $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; + $shortName = $baseModelName . '-' . $controllerMethod; + return [ + 'name' => $shortName, + 'uri' => $route->uri, + 'method' => $route->methods[0], + 'controller' => $controller, + 'controller_method' => $controllerMethod, + 'base_model' => $baseModelName, + ]; + }); + } + +} \ No newline at end of file diff --git a/app/Api/ApiToken.php b/app/Api/ApiToken.php new file mode 100644 index 000000000..523c3b8b8 --- /dev/null +++ b/app/Api/ApiToken.php @@ -0,0 +1,31 @@ + 'date:Y-m-d' + ]; + + /** + * Get the user that this token belongs to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the default expiry value for an API token. + * Set to 100 years from now. + */ + public static function defaultExpiry(): string + { + return Carbon::now()->addYears(100)->format('Y-m-d'); + } +} diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php new file mode 100644 index 000000000..e0a50ebe3 --- /dev/null +++ b/app/Api/ApiTokenGuard.php @@ -0,0 +1,166 @@ +request = $request; + } + + /** + * @inheritDoc + */ + public function user() + { + // Return the user if we've already retrieved them. + // Effectively a request-instance cache for this method. + if (!is_null($this->user)) { + return $this->user; + } + + $user = null; + try { + $user = $this->getAuthorisedUserFromRequest(); + } catch (ApiAuthException $exception) { + $this->lastAuthException = $exception; + } + + $this->user = $user; + return $user; + } + + /** + * Determine if current user is authenticated. If not, throw an exception. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + * + * @throws ApiAuthException + */ + public function authenticate() + { + if (! is_null($user = $this->user())) { + return $user; + } + + if ($this->lastAuthException) { + throw $this->lastAuthException; + } + + throw new ApiAuthException('Unauthorized'); + } + + /** + * Check the API token in the request and fetch a valid authorised user. + * @throws ApiAuthException + */ + protected function getAuthorisedUserFromRequest(): Authenticatable + { + $authToken = trim($this->request->headers->get('Authorization', '')); + $this->validateTokenHeaderValue($authToken); + + [$id, $secret] = explode(':', str_replace('Token ', '', $authToken)); + $token = ApiToken::query() + ->where('token_id', '=', $id) + ->with(['user'])->first(); + + $this->validateToken($token, $secret); + + return $token->user; + } + + /** + * Validate the format of the token header value string. + * @throws ApiAuthException + */ + protected function validateTokenHeaderValue(string $authToken): void + { + if (empty($authToken)) { + throw new ApiAuthException(trans('errors.api_no_authorization_found')); + } + + if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) { + throw new ApiAuthException(trans('errors.api_bad_authorization_format')); + } + } + + /** + * Validate the given secret against the given token and ensure the token + * currently has access to the instance API. + * @throws ApiAuthException + */ + protected function validateToken(?ApiToken $token, string $secret): void + { + if ($token === null) { + throw new ApiAuthException(trans('errors.api_user_token_not_found')); + } + + if (!Hash::check($secret, $token->secret)) { + throw new ApiAuthException(trans('errors.api_incorrect_token_secret')); + } + + $now = Carbon::now(); + if ($token->expires_at <= $now) { + throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); + } + + if (!$token->user->can('access-api')) { + throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); + } + } + + /** + * @inheritDoc + */ + public function validate(array $credentials = []) + { + if (empty($credentials['id']) || empty($credentials['secret'])) { + return false; + } + + $token = ApiToken::query() + ->where('token_id', '=', $credentials['id']) + ->with(['user'])->first(); + + if ($token === null) { + return false; + } + + return Hash::check($credentials['secret'], $token->secret); + } + + /** + * "Log out" the currently authenticated user. + */ + public function logout() + { + $this->user = null; + } +} \ No newline at end of file diff --git a/app/Api/ListingResponseBuilder.php b/app/Api/ListingResponseBuilder.php new file mode 100644 index 000000000..2fa5644c3 --- /dev/null +++ b/app/Api/ListingResponseBuilder.php @@ -0,0 +1,135 @@ + '=', + 'ne' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'like' => 'like' + ]; + + /** + * ListingResponseBuilder constructor. + */ + public function __construct(Builder $query, Request $request, array $fields) + { + $this->query = $query; + $this->request = $request; + $this->fields = $fields; + } + + /** + * Get the response from this builder. + */ + public function toResponse() + { + $data = $this->fetchData(); + $total = $this->query->count(); + + return response()->json([ + 'data' => $data, + 'total' => $total, + ]); + } + + /** + * Fetch the data to return in the response. + */ + protected function fetchData(): Collection + { + $this->applyCountAndOffset($this->query); + $this->applySorting($this->query); + $this->applyFiltering($this->query); + + return $this->query->get($this->fields); + } + + /** + * Apply any filtering operations found in the request. + */ + protected function applyFiltering(Builder $query) + { + $requestFilters = $this->request->get('filter', []); + if (!is_array($requestFilters)) { + return; + } + + $queryFilters = collect($requestFilters)->map(function ($value, $key) { + return $this->requestFilterToQueryFilter($key, $value); + })->filter(function ($value) { + return !is_null($value); + })->values()->toArray(); + + $query->where($queryFilters); + } + + /** + * Convert a request filter query key/value pair into a [field, op, value] where condition. + */ + protected function requestFilterToQueryFilter($fieldKey, $value): ?array + { + $splitKey = explode(':', $fieldKey); + $field = $splitKey[0]; + $filterOperator = $splitKey[1] ?? 'eq'; + + if (!in_array($field, $this->fields)) { + return null; + } + + if (!in_array($filterOperator, array_keys($this->filterOperators))) { + $filterOperator = 'eq'; + } + + $queryOperator = $this->filterOperators[$filterOperator]; + return [$field, $queryOperator, $value]; + } + + /** + * Apply sorting operations to the query from given parameters + * otherwise falling back to the first given field, ascending. + */ + protected function applySorting(Builder $query) + { + $defaultSortName = $this->fields[0]; + $direction = 'asc'; + + $sort = $this->request->get('sort', ''); + if (strpos($sort, '-') === 0) { + $direction = 'desc'; + } + + $sortName = ltrim($sort, '+- '); + if (!in_array($sortName, $this->fields)) { + $sortName = $defaultSortName; + } + + $query->orderBy($sortName, $direction); + } + + /** + * Apply count and offset for paging, based on params from the request while falling + * back to system defined default, taking the max limit into account. + */ + protected function applyCountAndOffset(Builder $query) + { + $offset = max(0, $this->request->get('offset', 0)); + $maxCount = config('api.max_item_count'); + $count = $this->request->get('count', config('api.default_item_count')); + $count = max(min($maxCount, $count), 1); + + $query->skip($offset)->take($count); + } +} diff --git a/app/Auth/Role.php b/app/Auth/Role.php index 3342ef5a8..df9b1cea9 100644 --- a/app/Auth/Role.php +++ b/app/Auth/Role.php @@ -72,7 +72,7 @@ class Role extends Model */ public function detachPermission(RolePermission $permission) { - $this->permissions()->detach($permission->id); + $this->permissions()->detach([$permission->id]); } /** diff --git a/app/Auth/User.php b/app/Auth/User.php index bce418a74..35b3cd54f 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -1,5 +1,6 @@ id); + return $this->hasMany(ApiToken::class); + } + + /** + * Get the url for editing this user. + */ + public function getEditUrl(string $path = ''): string + { + $uri = '/settings/users/' . $this->id . '/' . trim($path, '/'); + return url(rtrim($uri, '/')); } /** * Get the url that links to this user's profile. - * @return mixed */ - public function getProfileUrl() + public function getProfileUrl(): string { return url('/user/' . $this->id); } diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index a903e2c38..e082b2dd5 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -194,6 +194,7 @@ class UserRepo public function destroy(User $user) { $user->socialAccounts()->delete(); + $user->apiTokens()->delete(); $user->delete(); // Delete user profile images diff --git a/app/Config/api.php b/app/Config/api.php new file mode 100644 index 000000000..6afea2dc8 --- /dev/null +++ b/app/Config/api.php @@ -0,0 +1,23 @@ + env('API_DEFAULT_ITEM_COUNT', 100), + + // The maximum number of items that can be returned in a listing API request. + 'max_item_count' => env('API_MAX_ITEM_COUNT', 500), + + // The number of API requests that can be made per minute by a single user. + 'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180) + +]; diff --git a/app/Config/auth.php b/app/Config/auth.php index 5535a6f9c..b3e22c7e1 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -34,9 +34,7 @@ return [ ], 'api' => [ - 'driver' => 'token', - 'provider' => 'users', - 'hash' => false, + 'driver' => 'api-token', ], ], diff --git a/app/Entities/Book.php b/app/Entities/Book.php index 4e54457b8..919f60035 100644 --- a/app/Entities/Book.php +++ b/app/Entities/Book.php @@ -18,7 +18,8 @@ class Book extends Entity implements HasCoverImage { public $searchFactor = 2; - protected $fillable = ['name', 'description', 'image_id']; + protected $fillable = ['name', 'description']; + protected $hidden = ['restricted']; /** * Get the url for this book. diff --git a/app/Exceptions/ApiAuthException.php b/app/Exceptions/ApiAuthException.php new file mode 100644 index 000000000..cc68ba8cf --- /dev/null +++ b/app/Exceptions/ApiAuthException.php @@ -0,0 +1,7 @@ +isApiRequest($request)) { + return $this->renderApiException($e); + } + // Handle notify exceptions which will redirect to the // specified location then show a notification message. if ($this->isExceptionType($e, NotifyException::class)) { @@ -70,6 +77,41 @@ class Handler extends ExceptionHandler return parent::render($request, $e); } + /** + * Check if the given request is an API request. + */ + protected function isApiRequest(Request $request): bool + { + return strpos($request->path(), 'api/') === 0; + } + + /** + * Render an exception when the API is in use. + */ + protected function renderApiException(Exception $e): JsonResponse + { + $code = $e->getCode() === 0 ? 500 : $e->getCode(); + $headers = []; + if ($e instanceof HttpException) { + $code = $e->getStatusCode(); + $headers = $e->getHeaders(); + } + + $responseData = [ + 'error' => [ + 'message' => $e->getMessage(), + ] + ]; + + if ($e instanceof ValidationException) { + $responseData['error']['validation'] = $e->errors(); + $code = $e->status; + } + + $responseData['error']['code'] = $code; + return new JsonResponse($responseData, $code, $headers); + } + /** * Check the exception chain to compare against the original exception type. * @param Exception $e diff --git a/app/Exceptions/UnauthorizedException.php b/app/Exceptions/UnauthorizedException.php new file mode 100644 index 000000000..525b431c7 --- /dev/null +++ b/app/Exceptions/UnauthorizedException.php @@ -0,0 +1,17 @@ +toResponse(); + } + + /** + * Get the validation rules for this controller. + */ + public function getValdationRules(): array + { + return $this->rules; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/ApiDocsController.php b/app/Http/Controllers/Api/ApiDocsController.php new file mode 100644 index 000000000..84ddd5215 --- /dev/null +++ b/app/Http/Controllers/Api/ApiDocsController.php @@ -0,0 +1,47 @@ +getDocs(); + return view('api-docs.index', [ + 'docs' => $docs, + ]); + } + + /** + * Show a JSON view of the API docs data. + */ + public function json() { + $docs = $this->getDocs(); + return response()->json($docs); + } + + /** + * Get the base docs data. + * Checks and uses the system cache for quick re-fetching. + */ + protected function getDocs(): Collection + { + $appVersion = trim(file_get_contents(base_path('version'))); + $cacheKey = 'api-docs::' . $appVersion; + if (Cache::has($cacheKey) && config('app.env') === 'production') { + $docs = Cache::get($cacheKey); + } else { + $docs = (new ApiDocsGenerator())->generate(); + Cache::put($cacheKey, $docs, 60*24); + } + + return $docs; + } + +} diff --git a/app/Http/Controllers/Api/BooksApiController.php b/app/Http/Controllers/Api/BooksApiController.php new file mode 100644 index 000000000..ac4ea171c --- /dev/null +++ b/app/Http/Controllers/Api/BooksApiController.php @@ -0,0 +1,101 @@ + [ + 'name' => 'required|string|max:255', + 'description' => 'string|max:1000', + ], + 'update' => [ + 'name' => 'string|min:1|max:255', + 'description' => 'string|max:1000', + ], + ]; + + /** + * BooksApiController constructor. + */ + public function __construct(BookRepo $bookRepo) + { + $this->bookRepo = $bookRepo; + } + + /** + * Get a listing of books visible to the user. + */ + public function list() + { + $books = Book::visible(); + return $this->apiListingResponse($books, [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id', + ]); + } + + /** + * Create a new book in the system. + * @throws ValidationException + */ + public function create(Request $request) + { + $this->checkPermission('book-create-all'); + $requestData = $this->validate($request, $this->rules['create']); + + $book = $this->bookRepo->create($requestData); + Activity::add($book, 'book_create', $book->id); + + return response()->json($book); + } + + /** + * View the details of a single book. + */ + public function read(string $id) + { + $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy'])->findOrFail($id); + return response()->json($book); + } + + /** + * Update the details of a single book. + * @throws ValidationException + */ + public function update(Request $request, string $id) + { + $book = Book::visible()->findOrFail($id); + $this->checkOwnablePermission('book-update', $book); + + $requestData = $this->validate($request, $this->rules['update']); + $book = $this->bookRepo->update($book, $requestData); + Activity::add($book, 'book_update', $book->id); + + return response()->json($book); + } + + /** + * Delete a single book from the system. + * @throws NotifyException + * @throws BindingResolutionException + */ + public function delete(string $id) + { + $book = Book::visible()->findOrFail($id); + $this->checkOwnablePermission('book-delete', $book); + + $this->bookRepo->destroy($book); + Activity::addMessage('book_delete', $book->name); + + return response('', 204); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/UserApiTokenController.php b/app/Http/Controllers/UserApiTokenController.php new file mode 100644 index 000000000..55675233c --- /dev/null +++ b/app/Http/Controllers/UserApiTokenController.php @@ -0,0 +1,139 @@ +checkPermission('access-api'); + $this->checkPermissionOrCurrentUser('users-manage', $userId); + + $user = User::query()->findOrFail($userId); + return view('users.api-tokens.create', [ + 'user' => $user, + ]); + } + + /** + * Store a new API token in the system. + */ + public function store(Request $request, int $userId) + { + $this->checkPermission('access-api'); + $this->checkPermissionOrCurrentUser('users-manage', $userId); + + $this->validate($request, [ + 'name' => 'required|max:250', + 'expires_at' => 'date_format:Y-m-d', + ]); + + $user = User::query()->findOrFail($userId); + $secret = Str::random(32); + + $token = (new ApiToken())->forceFill([ + 'name' => $request->get('name'), + 'token_id' => Str::random(32), + 'secret' => Hash::make($secret), + 'user_id' => $user->id, + 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + ]); + + while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { + $token->token_id = Str::random(32); + } + + $token->save(); + + session()->flash('api-token-secret:' . $token->id, $secret); + $this->showSuccessNotification(trans('settings.user_api_token_create_success')); + return redirect($user->getEditUrl('/api-tokens/' . $token->id)); + } + + /** + * Show the details for a user API token, with access to edit. + */ + public function edit(int $userId, int $tokenId) + { + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $secret = session()->pull('api-token-secret:' . $token->id, null); + + return view('users.api-tokens.edit', [ + 'user' => $user, + 'token' => $token, + 'model' => $token, + 'secret' => $secret, + ]); + } + + /** + * Update the API token. + */ + public function update(Request $request, int $userId, int $tokenId) + { + $this->validate($request, [ + 'name' => 'required|max:250', + 'expires_at' => 'date_format:Y-m-d', + ]); + + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $token->fill([ + 'name' => $request->get('name'), + 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + ])->save(); + + $this->showSuccessNotification(trans('settings.user_api_token_update_success')); + return redirect($user->getEditUrl('/api-tokens/' . $token->id)); + } + + /** + * Show the delete view for this token. + */ + public function delete(int $userId, int $tokenId) + { + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + return view('users.api-tokens.delete', [ + 'user' => $user, + 'token' => $token, + ]); + } + + /** + * Destroy a token from the system. + */ + public function destroy(int $userId, int $tokenId) + { + [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $token->delete(); + + $this->showSuccessNotification(trans('settings.user_api_token_delete_success')); + return redirect($user->getEditUrl('#api_tokens')); + } + + /** + * Check the permission for the current user and return an array + * where the first item is the user in context and the second item is their + * API token in context. + */ + protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array + { + $this->checkPermissionOr('users-manage', function () use ($userId) { + return $userId === user()->id && userCan('access-api'); + }); + + $user = User::query()->findOrFail($userId); + $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail(); + return [$user, $token]; + } + +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b55398d2f..207466f38 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -116,22 +116,24 @@ class UserController extends Controller /** * Show the form for editing the specified user. - * @param int $id - * @param \BookStack\Auth\Access\SocialAuthService $socialAuthService - * @return Response */ - public function edit($id, SocialAuthService $socialAuthService) + public function edit(int $id, SocialAuthService $socialAuthService) { $this->checkPermissionOrCurrentUser('users-manage', $id); - $user = $this->user->findOrFail($id); + $user = $this->user->newQuery()->with(['apiTokens'])->findOrFail($id); $authMethod = ($user->system_name) ? 'system' : config('auth.method'); $activeSocialDrivers = $socialAuthService->getActiveDrivers(); $this->setPageTitle(trans('settings.user_profile')); $roles = $this->userRepo->getAllRoles(); - return view('users.edit', ['user' => $user, 'activeSocialDrivers' => $activeSocialDrivers, 'authMethod' => $authMethod, 'roles' => $roles]); + return view('users.edit', [ + 'user' => $user, + 'activeSocialDrivers' => $activeSocialDrivers, + 'authMethod' => $authMethod, + 'roles' => $roles + ]); } /** diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index f9752da09..c2016281a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -6,10 +6,7 @@ class Kernel extends HttpKernel { /** * The application's global HTTP middleware stack. - * * These middleware are run during every request to your application. - * - * @var array */ protected $middleware = [ \BookStack\Http\Middleware\CheckForMaintenanceMode::class, @@ -31,13 +28,14 @@ class Kernel extends HttpKernel \Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\Routing\Middleware\ThrottleRequests::class, \BookStack\Http\Middleware\VerifyCsrfToken::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, \BookStack\Http\Middleware\Localization::class, \BookStack\Http\Middleware\GlobalViewData::class, ], 'api' => [ - 'throttle:60,1', - 'bindings', + \BookStack\Http\Middleware\ThrottleApiRequests::class, + \BookStack\Http\Middleware\EncryptCookies::class, + \BookStack\Http\Middleware\StartSessionIfCookieExists::class, + \BookStack\Http\Middleware\ApiAuthenticate::class, ], ]; @@ -48,7 +46,6 @@ class Kernel extends HttpKernel */ protected $routeMiddleware = [ 'auth' => \BookStack\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, diff --git a/app/Http/Middleware/ApiAuthenticate.php b/app/Http/Middleware/ApiAuthenticate.php new file mode 100644 index 000000000..15962b3b0 --- /dev/null +++ b/app/Http/Middleware/ApiAuthenticate.php @@ -0,0 +1,66 @@ +ensureAuthorizedBySessionOrToken(); + } catch (UnauthorizedException $exception) { + return $this->unauthorisedResponse($exception->getMessage(), $exception->getCode()); + } + + return $next($request); + } + + /** + * Ensure the current user can access authenticated API routes, either via existing session + * authentication or via API Token authentication. + * @throws UnauthorizedException + */ + protected function ensureAuthorizedBySessionOrToken(): void + { + // Return if the user is already found to be signed in via session-based auth. + // This is to make it easy to browser the API via browser after just logging into the system. + if (signedInUser()) { + $this->ensureEmailConfirmedIfRequested(); + if (!auth()->user()->can('access-api')) { + throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); + } + return; + } + + // Set our api guard to be the default for this request lifecycle. + auth()->shouldUse('api'); + + // Validate the token and it's users API access + auth()->authenticate(); + $this->ensureEmailConfirmedIfRequested(); + } + + /** + * Provide a standard API unauthorised response. + */ + protected function unauthorisedResponse(string $message, int $code) + { + return response()->json([ + 'error' => [ + 'code' => $code, + 'message' => $message, + ] + ], $code); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index d840a9b2e..9a8affa88 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -3,38 +3,19 @@ namespace BookStack\Http\Middleware; use Closure; -use Illuminate\Contracts\Auth\Guard; +use Illuminate\Http\Request; class Authenticate { - /** - * The Guard implementation. - * @var Guard - */ - protected $auth; - - /** - * Create a new filter instance. - * @param Guard $auth - */ - public function __construct(Guard $auth) - { - $this->auth = $auth; - } + use ChecksForEmailConfirmation; /** * Handle an incoming request. - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed */ - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next) { - if ($this->auth->check()) { - $requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict')); - if ($requireConfirmation && !$this->auth->user()->email_confirmed) { - return redirect('/register/confirm/awaiting'); - } + if ($this->awaitingEmailConfirmation()) { + return $this->emailConfirmationErrorResponse($request); } if (!hasAppAccess()) { @@ -47,4 +28,22 @@ class Authenticate return $next($request); } + + /** + * Provide an error response for when the current user's email is not confirmed + * in a system which requires it. + */ + protected function emailConfirmationErrorResponse(Request $request) + { + if ($request->wantsJson()) { + return response()->json([ + 'error' => [ + 'code' => 401, + 'message' => trans('errors.email_confirmation_awaiting') + ] + ], 401); + } + + return redirect('/register/confirm/awaiting'); + } } diff --git a/app/Http/Middleware/ChecksForEmailConfirmation.php b/app/Http/Middleware/ChecksForEmailConfirmation.php new file mode 100644 index 000000000..4b1732810 --- /dev/null +++ b/app/Http/Middleware/ChecksForEmailConfirmation.php @@ -0,0 +1,36 @@ +awaitingEmailConfirmation()) { + throw new UnauthorizedException(trans('errors.email_confirmation_awaiting')); + } + } + + /** + * Check if email confirmation is required and the current user is awaiting confirmation. + */ + protected function awaitingEmailConfirmation(): bool + { + if (auth()->check()) { + $requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict')); + if ($requireConfirmation && !auth()->user()->email_confirmed) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/StartSessionIfCookieExists.php b/app/Http/Middleware/StartSessionIfCookieExists.php new file mode 100644 index 000000000..456508d98 --- /dev/null +++ b/app/Http/Middleware/StartSessionIfCookieExists.php @@ -0,0 +1,22 @@ +cookies->has($sessionCookieName)) { + return parent::handle($request, $next); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ThrottleApiRequests.php b/app/Http/Middleware/ThrottleApiRequests.php new file mode 100644 index 000000000..d08840cd1 --- /dev/null +++ b/app/Http/Middleware/ThrottleApiRequests.php @@ -0,0 +1,18 @@ +mapWebRoutes(); -// $this->mapApiRoutes(); + $this->mapApiRoutes(); } /** * Define the "web" routes for the application. @@ -63,7 +63,7 @@ class RouteServiceProvider extends ServiceProvider { Route::group([ 'middleware' => 'api', - 'namespace' => $this->namespace, + 'namespace' => $this->namespace . '\Api', 'prefix' => 'api', ], function ($router) { require base_path('routes/api.php'); diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 6fa5db2a5..c76979d7c 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -8,6 +8,7 @@ class Image extends Ownable { protected $fillable = ['name']; + protected $hidden = []; /** * Get a thumbnail for this image. diff --git a/app/helpers.php b/app/helpers.php index 6211f41be..65da1853b 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -42,7 +42,6 @@ function user(): User /** * Check if current user is a signed in user. - * @return bool */ function signedInUser(): bool { @@ -51,7 +50,6 @@ function signedInUser(): bool /** * Check if the current user has general access. - * @return bool */ function hasAppAccess(): bool { @@ -62,9 +60,6 @@ function hasAppAccess(): bool * Check if the current user has a permission. * If an ownable element is passed in the jointPermissions are checked against * that particular item. - * @param string $permission - * @param Ownable $ownable - * @return bool */ function userCan(string $permission, Ownable $ownable = null): bool { diff --git a/database/migrations/2019_07_07_112515_add_template_support.php b/database/migrations/2019_07_07_112515_add_template_support.php index a54508198..3fcc68227 100644 --- a/database/migrations/2019_07_07_112515_add_template_support.php +++ b/database/migrations/2019_07_07_112515_add_template_support.php @@ -46,9 +46,9 @@ class AddTemplateSupport extends Migration // Remove templates-manage permission $templatesManagePermission = DB::table('role_permissions') - ->where('name', '=', 'templates_manage')->first(); + ->where('name', '=', 'templates-manage')->first(); DB::table('permission_role')->where('permission_id', '=', $templatesManagePermission->id)->delete(); - DB::table('role_permissions')->where('name', '=', 'templates_manage')->delete(); + DB::table('role_permissions')->where('name', '=', 'templates-manage')->delete(); } } diff --git a/database/migrations/2019_12_29_120917_add_api_auth.php b/database/migrations/2019_12_29_120917_add_api_auth.php new file mode 100644 index 000000000..eff88247f --- /dev/null +++ b/database/migrations/2019_12_29_120917_add_api_auth.php @@ -0,0 +1,60 @@ +increments('id'); + $table->string('name'); + $table->string('token_id')->unique(); + $table->string('secret'); + $table->integer('user_id')->unsigned()->index(); + $table->date('expires_at')->index(); + $table->nullableTimestamps(); + }); + + // Add access-api permission + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; + $permissionId = DB::table('role_permissions')->insertGetId([ + 'name' => 'access-api', + 'display_name' => 'Access system API', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString() + ]); + DB::table('permission_role')->insert([ + 'role_id' => $adminRoleId, + 'permission_id' => $permissionId + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Remove API tokens table + Schema::dropIfExists('api_tokens'); + + // Remove access-api permission + $apiAccessPermission = DB::table('role_permissions') + ->where('name', '=', 'access-api')->first(); + + DB::table('permission_role')->where('permission_id', '=', $apiAccessPermission->id)->delete(); + DB::table('role_permissions')->where('name', '=', 'access-api')->delete(); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index deb1aa11c..6d902a196 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -1,6 +1,8 @@ create($byData); $largeBook->shelves()->attach($shelves->pluck('id')); + // Assign API permission to editor role and create an API key + $apiPermission = RolePermission::getByName('access-api'); + $editorRole->attachPermission($apiPermission); + $token = (new ApiToken())->forceFill([ + 'user_id' => $editorUser->id, + 'name' => 'Testing API key', + 'expires_at' => ApiToken::defaultExpiry(), + 'secret' => Hash::make('password'), + 'token_id' => 'apitoken', + ]); + $token->save(); + app(PermissionService::class)->buildJointPermissions(); app(SearchService::class)->indexAllEntities(); } diff --git a/dev/api/requests/books-create.json b/dev/api/requests/books-create.json new file mode 100644 index 000000000..4a6626619 --- /dev/null +++ b/dev/api/requests/books-create.json @@ -0,0 +1,4 @@ +{ + "name": "My own book", + "description": "This is my own little book" +} \ No newline at end of file diff --git a/dev/api/requests/books-update.json b/dev/api/requests/books-update.json new file mode 100644 index 000000000..fc67d5fcc --- /dev/null +++ b/dev/api/requests/books-update.json @@ -0,0 +1,4 @@ +{ + "name": "My updated book", + "description": "This is my book with updated details" +} \ No newline at end of file diff --git a/dev/api/responses/books-create.json b/dev/api/responses/books-create.json new file mode 100644 index 000000000..0b4336ab2 --- /dev/null +++ b/dev/api/responses/books-create.json @@ -0,0 +1,10 @@ +{ + "name": "My new book", + "description": "This is a book created via the API", + "created_by": 1, + "updated_by": 1, + "slug": "my-new-book", + "updated_at": "2020-01-12 14:05:11", + "created_at": "2020-01-12 14:05:11", + "id": 15 +} \ No newline at end of file diff --git a/dev/api/responses/books-list.json b/dev/api/responses/books-list.json new file mode 100644 index 000000000..29e83b1c0 --- /dev/null +++ b/dev/api/responses/books-list.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 1, + "name": "BookStack User Guide", + "slug": "bookstack-user-guide", + "description": "This is a general guide on using BookStack on a day-to-day basis.", + "created_at": "2019-05-05 21:48:46", + "updated_at": "2019-12-11 20:57:31", + "created_by": 1, + "updated_by": 1, + "image_id": 3 + }, + { + "id": 2, + "name": "Inventore inventore quia voluptatem.", + "slug": "inventore-inventore-quia-voluptatem", + "description": "Veniam nihil voluptas enim laborum corporis quos sint. Ab rerum voluptas ut iste voluptas magni quibusdam ut. Amet omnis enim voluptate neque facilis.", + "created_at": "2019-05-05 22:10:14", + "updated_at": "2019-12-11 20:57:23", + "created_by": 4, + "updated_by": 3, + "image_id": 34 + } + ], + "total": 14 +} \ No newline at end of file diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json new file mode 100644 index 000000000..e0570444f --- /dev/null +++ b/dev/api/responses/books-read.json @@ -0,0 +1,47 @@ +{ + "id": 16, + "name": "My own book", + "slug": "my-own-book", + "description": "This is my own little book", + "created_at": "2020-01-12 14:09:59", + "updated_at": "2020-01-12 14:11:51", + "created_by": { + "id": 1, + "name": "Admin", + "created_at": "2019-05-05 21:15:13", + "updated_at": "2019-12-16 12:18:37", + "image_id": 48 + }, + "updated_by": { + "id": 1, + "name": "Admin", + "created_at": "2019-05-05 21:15:13", + "updated_at": "2019-12-16 12:18:37", + "image_id": 48 + }, + "image_id": 452, + "tags": [ + { + "id": 13, + "entity_id": 16, + "entity_type": "BookStack\\Book", + "name": "Category", + "value": "Guide", + "order": 0, + "created_at": "2020-01-12 14:11:51", + "updated_at": "2020-01-12 14:11:51" + } + ], + "cover": { + "id": 452, + "name": "sjovall_m117hUWMu40.jpg", + "url": "http:\/\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", + "created_at": "2020-01-12 14:11:51", + "updated_at": "2020-01-12 14:11:51", + "created_by": 1, + "updated_by": 1, + "path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg", + "type": "cover_book", + "uploaded_to": 16 + } +} \ No newline at end of file diff --git a/dev/api/responses/books-update.json b/dev/api/responses/books-update.json new file mode 100644 index 000000000..8f20b5b9f --- /dev/null +++ b/dev/api/responses/books-update.json @@ -0,0 +1,11 @@ +{ + "id": 16, + "name": "My own book", + "slug": "my-own-book", + "description": "This is my own little book - updated", + "created_at": "2020-01-12 14:09:59", + "updated_at": "2020-01-12 14:16:10", + "created_by": 1, + "updated_by": 1, + "image_id": 452 +} \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 546829247..85538c446 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -50,5 +50,6 @@ + diff --git a/resources/js/components/code-highlighter.js b/resources/js/components/code-highlighter.js new file mode 100644 index 000000000..db6112887 --- /dev/null +++ b/resources/js/components/code-highlighter.js @@ -0,0 +1,10 @@ +import Code from "../services/code" +class CodeHighlighter { + + constructor(elem) { + Code.highlightWithin(elem); + } + +} + +export default CodeHighlighter; \ No newline at end of file diff --git a/resources/js/components/details-highlighter.js b/resources/js/components/details-highlighter.js new file mode 100644 index 000000000..18c5165fa --- /dev/null +++ b/resources/js/components/details-highlighter.js @@ -0,0 +1,18 @@ +import Code from "../services/code" +class DetailsHighlighter { + + constructor(elem) { + this.elem = elem; + this.dealtWith = false; + elem.addEventListener('toggle', this.onToggle.bind(this)); + } + + onToggle() { + if (this.dealtWith) return; + + Code.highlightWithin(this.elem); + this.dealtWith = true; + } +} + +export default DetailsHighlighter; \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index bbe059898..112827330 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,6 +30,8 @@ import settingColorPicker from "./setting-color-picker"; import entityPermissionsEditor from "./entity-permissions-editor"; import templateManager from "./template-manager"; import newUserPassword from "./new-user-password"; +import detailsHighlighter from "./details-highlighter"; +import codeHighlighter from "./code-highlighter"; const componentMapping = { 'dropdown': dropdown, @@ -64,6 +66,8 @@ const componentMapping = { 'entity-permissions-editor': entityPermissionsEditor, 'template-manager': templateManager, 'new-user-password': newUserPassword, + 'details-highlighter': detailsHighlighter, + 'code-highlighter': codeHighlighter, }; window.components = {}; diff --git a/resources/js/services/code.js b/resources/js/services/code.js index 26dee5bfb..0c5f75db3 100644 --- a/resources/js/services/code.js +++ b/resources/js/services/code.js @@ -87,9 +87,20 @@ const modeMap = { * Highlight pre elements on a page */ function highlight() { - let codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre'); - for (let i = 0; i < codeBlocks.length; i++) { - highlightElem(codeBlocks[i]); + const codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre'); + for (const codeBlock of codeBlocks) { + highlightElem(codeBlock); + } +} + +/** + * Highlight all code blocks within the given parent element + * @param {HTMLElement} parent + */ +function highlightWithin(parent) { + const codeBlocks = parent.querySelectorAll('pre'); + for (const codeBlock of codeBlocks) { + highlightElem(codeBlock); } } @@ -174,7 +185,7 @@ function getMode(suggestion, content) { * @returns {*|string} */ function getTheme() { - return window.codeTheme || 'base16-light'; + return window.codeTheme || 'mdn-like'; } /** @@ -308,6 +319,7 @@ function getMetaKey() { export default { highlight: highlight, + highlightWithin: highlightWithin, wysiwygView: wysiwygView, popupEditor: popupEditor, setMode: setMode, diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index a7c591c5d..bb7b6148c 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -13,6 +13,7 @@ return [ 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', 'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.', + 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', @@ -88,4 +89,12 @@ return [ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // API errors + 'api_no_authorization_found' => 'No authorization token found on the request', + 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', + 'api_user_token_not_found' => 'No matching API token was found for the provided authorization token', + 'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect', + 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', + 'api_user_token_expired' => 'The authorization token used has expired', + ]; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 6be7cc9cb..fb00c3cce 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -103,6 +103,7 @@ return [ 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', 'role_manage_page_templates' => 'Manage page templates', + 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_asset' => 'Asset Permissions', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', @@ -151,6 +152,32 @@ return [ 'users_social_disconnect' => 'Disconnect Account', 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Create Token', + 'users_api_tokens_expires' => 'Expires', + 'users_api_tokens_docs' => 'API Documentation', + + // API Tokens + 'user_api_token_create' => 'Create API Token', + 'user_api_token_name' => 'Name', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Expiry Date', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID"" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token_create_success' => 'API token successfully created', + 'user_api_token_update_success' => 'API token successfully updated', + 'user_api_token' => 'API Token', + 'user_api_token_id' => 'Token ID', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Token Secret', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Token Created :timeAgo', + 'user_api_token_updated' => 'Token Updated :timeAgo', + 'user_api_token_delete' => 'Delete Token', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + 'user_api_token_delete_success' => 'API token successfully deleted', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 2cb17a18d..cc42dc736 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -236,4 +236,26 @@ .tag-list div:last-child .tag-item { margin-bottom: 0; +} + +/** + * API Docs + */ +.api-method { + font-size: 0.75rem; + background-color: #888; + padding: $-xs; + line-height: 1.3; + opacity: 0.7; + vertical-align: top; + border-radius: 3px; + color: #FFF; + display: inline-block; + min-width: 60px; + text-align: center; + font-weight: bold; + &[data-method="GET"] { background-color: #077b70 } + &[data-method="POST"] { background-color: #cf4d03 } + &[data-method="PUT"] { background-color: #0288D1 } + &[data-method="DELETE"] { background-color: #ab0f0e } } \ No newline at end of file diff --git a/resources/sass/_codemirror.scss b/resources/sass/_codemirror.scss index 28c777608..92287c484 100644 --- a/resources/sass/_codemirror.scss +++ b/resources/sass/_codemirror.scss @@ -343,44 +343,51 @@ span.CodeMirror-selectedtext { background: none; } /* + MDN-LIKE Theme - Mozilla + Ported to CodeMirror by Peter Kroon + Report bugs/issues here: https://github.com/codemirror/CodeMirror/issues + GitHub: @peterkroon - Name: Base16 Default Light - Author: Chris Kempson (http://chriskempson.com) - - CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) - Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + The mdn-like theme is inspired on the displayed code examples at: https://developer.mozilla.org/en-US/docs/Web/CSS/animation */ +.cm-s-mdn-like.CodeMirror { color: #999; background-color: #fff; } +.cm-s-mdn-like div.CodeMirror-selected { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::selection, .cm-s-mdn-like .CodeMirror-line > span::selection, .cm-s-mdn-like .CodeMirror-line > span > span::selection { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span > span::-moz-selection { background: #cfc; } -.cm-s-base16-light.CodeMirror { background: #f8f8f8; color: #444444; } -.cm-s-base16-light div.CodeMirror-selected { background: #e0e0e0; } -.cm-s-base16-light .CodeMirror-line::selection, .cm-s-base16-light .CodeMirror-line > span::selection, .cm-s-base16-light .CodeMirror-line > span > span::selection { background: #e0e0e0; } -.cm-s-base16-light .CodeMirror-line::-moz-selection, .cm-s-base16-light .CodeMirror-line > span::-moz-selection, .cm-s-base16-light .CodeMirror-line > span > span::-moz-selection { background: #e0e0e0; } -.cm-s-base16-light .CodeMirror-gutters { background: #f5f5f5; border-right: 0px; } -.cm-s-base16-light .CodeMirror-guttermarker { color: #ac4142; } -.cm-s-base16-light .CodeMirror-guttermarker-subtle { color: #b0b0b0; } -.cm-s-base16-light .CodeMirror-linenumber { color: #b0b0b0; } -.cm-s-base16-light .CodeMirror-cursor { border-left: 1px solid #505050; } +.cm-s-mdn-like .CodeMirror-gutters { background: #f8f8f8; border-left: 6px solid rgba(0,83,159,0.65); color: #333; } +.cm-s-mdn-like .CodeMirror-linenumber { color: #aaa; padding-left: 8px; } +.cm-s-mdn-like .CodeMirror-cursor { border-left: 2px solid #222; } -.cm-s-base16-light span.cm-comment { color: #8f5536; } -.cm-s-base16-light span.cm-atom { color: #aa759f; } -.cm-s-base16-light span.cm-number { color: #aa759f; } +.cm-s-mdn-like .cm-keyword { color: #6262FF; } +.cm-s-mdn-like .cm-atom { color: #F90; } +.cm-s-mdn-like .cm-number { color: #ca7841; } +.cm-s-mdn-like .cm-def { color: #8DA6CE; } +.cm-s-mdn-like span.cm-variable-2, .cm-s-mdn-like span.cm-tag { color: #690; } +.cm-s-mdn-like span.cm-variable-3, .cm-s-mdn-like span.cm-def, .cm-s-mdn-like span.cm-type { color: #07a; } -.cm-s-base16-light span.cm-property, .cm-s-base16-light span.cm-attribute { color: #678c30; } -.cm-s-base16-light span.cm-keyword { color: #ac4142; } -.cm-s-base16-light span.cm-string { color: #e09c3c; } +.cm-s-mdn-like .cm-variable { color: #07a; } +.cm-s-mdn-like .cm-property { color: #905; } +.cm-s-mdn-like .cm-qualifier { color: #690; } -.cm-s-base16-light span.cm-builtin { color: #4c7f9e; } -.cm-s-base16-light span.cm-variable { color: #90a959; } -.cm-s-base16-light span.cm-variable-2 { color: #6a9fb5; } -.cm-s-base16-light span.cm-def { color: #d28445; } -.cm-s-base16-light span.cm-bracket { color: #202020; } -.cm-s-base16-light span.cm-tag { color: #ac4142; } -.cm-s-base16-light span.cm-link { color: #aa759f; } -.cm-s-base16-light span.cm-error { background: #ac4142; color: #505050; } +.cm-s-mdn-like .cm-operator { color: #cda869; } +.cm-s-mdn-like .cm-comment { color:#777; font-weight:normal; } +.cm-s-mdn-like .cm-string { color:#07a; font-style:italic; } +.cm-s-mdn-like .cm-string-2 { color:#bd6b18; } /*?*/ +.cm-s-mdn-like .cm-meta { color: #000; } /*?*/ +.cm-s-mdn-like .cm-builtin { color: #9B7536; } /*?*/ +.cm-s-mdn-like .cm-tag { color: #997643; } +.cm-s-mdn-like .cm-attribute { color: #d6bb6d; } /*?*/ +.cm-s-mdn-like .cm-header { color: #FF6400; } +.cm-s-mdn-like .cm-hr { color: #AEAEAE; } +.cm-s-mdn-like .cm-link { color:#ad9361; font-style:italic; text-decoration:none; } +.cm-s-mdn-like .cm-error { border-bottom: 1px solid red; } -.cm-s-base16-light .CodeMirror-activeline-background { background: #DDDCDC; } -.cm-s-base16-light .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } +div.cm-s-mdn-like .CodeMirror-activeline-background { background: #efefff; } +div.cm-s-mdn-like span.CodeMirror-matchingbracket { outline:1px solid grey; color: inherit; } + +.cm-s-mdn-like.CodeMirror { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFcAAAAyCAYAAAAp8UeFAAAHvklEQVR42s2b63bcNgyEQZCSHCdt2vd/0tWF7I+Q6XgMXiTtuvU5Pl57ZQKkKHzEAOtF5KeIJBGJ8uvL599FRFREZhFx8DeXv8trn68RuGaC8TRfo3SNp9dlDDHedyLyTUTeRWStXKPZrjtpZxaRw5hPqozRs1N8/enzIiQRWcCgy4MUA0f+XWliDhyL8Lfyvx7ei/Ae3iQFHyw7U/59pQVIMEEPEz0G7XiwdRjzSfC3UTtz9vchIntxvry5iMgfIhJoEflOz2CQr3F5h/HfeFe+GTdLaKcu9L8LTeQb/R/7GgbsfKedyNdoHsN31uRPWrfZ5wsj/NzzRQHuToIdU3ahwnsKPxXCjJITuOsi7XLc7SG/v5GdALs7wf8JjTFiB5+QvTEfRyGOfX3Lrx8wxyQi3sNq46O7QahQiCsRFgqddjBouVEHOKDgXAQHD9gJCr5sMKkEdjwsarG/ww3BMHBU7OBjXnzdyY7SfCxf5/z6ATccrwlKuwC/jhznnPF4CgVzhhVf4xp2EixcBActO75iZ8/fM9zAs2OMzKdslgXWJ9XG8PQoOAMA5fGcsvORgv0doBXyHrCwfLJAOwo71QLNkb8n2Pl6EWiR7OCibtkPaz4Kc/0NNAze2gju3zOwekALDaCFPI5vjPFmgGY5AZqyGEvH1x7QfIb8YtxMnA/b+QQ0aQDAwc6JMFg8CbQZ4qoYEEHbRwNojuK3EHwd7VALSgq+MNDKzfT58T8qdpADrgW0GmgcAS1lhzztJmkAzcPNOQbsWEALBDSlMKUG0Eq4CLAQWvEVQ9WU57gZJwZtgPO3r9oBTQ9WO8TjqXINx8R0EYpiZEUWOF3FxkbJkgU9B2f41YBrIj5ZfsQa0M5kTgiAAqM3ShXLgu8XMqcrQBvJ0CL5pnTsfMB13oB8athpAq2XOQmcGmoACCLydx7nToa23ATaSIY2ichfOdPTGxlasXMLaL0MLZAOwAKIM+y8CmicobGdCcbbK9DzN+yYGVoNNI5iUKTMyYOjPse4A8SM1MmcXgU0toOq1yO/v8FOxlASyc7TgeYaAMBJHcY1CcCwGI/TK4AmDbDyKYBBtFUkRwto8gygiQEaByFgJ00BH2M8JWwQS1nafDXQCidWyOI8AcjDCSjCLk8ngObuAm3JAHAdubAmOaK06V8MNEsKPJOhobSprwQa6gD7DclRQdqcwL4zxqgBrQcabUiBLclRDKAlWp+etPkBaNMA0AKlrHwTdEByZAA4GM+SNluSY6wAzcMNewxmgig5Ks0nkrSpBvSaQHMdKTBAnLojOdYyGpQ254602ZILPdTD1hdlggdIm74jbTp8vDwF5ZYUeLWGJpWsh6XNyXgcYwVoJQTEhhTYkxzZjiU5npU2TaB979TQehlaAVq4kaGpiPwwwLkYUuBbQwocyQTv1tA0+1UFWoJF3iv1oq+qoSk8EQdJmwHkziIF7oOZk14EGitibAdjLYYK78H5vZOhtWpoI0ATGHs0Q8OMb4Ey+2bU2UYztCtA0wFAs7TplGLRVQCcqaFdGSPCeTI1QNIC52iWNzof6Uib7xjEp07mNNoUYmVosVItHrHzRlLgBn9LFyRHaQCtVUMbtTNhoXWiTOO9k/V8BdAc1Oq0ArSQs6/5SU0hckNy9NnXqQY0PGYo5dWJ7nINaN6o958FWin27aBaWRka1r5myvLOAm0j30eBJqCxHLReVclxhxOEN2JfDWjxBtAC7MIH1fVaGdoOp4qJYDgKtKPSFNID2gSnGldrCqkFZ+5UeQXQBIRrSwocbdZYQT/2LwRahBPBXoHrB8nxaGROST62DKUbQOMMzZIC9abkuELfQzQALWTnDNAm8KHWFOJgJ5+SHIvTPcmx1xQyZRhNL5Qci689aXMEaN/uNIWkEwDAvFpOZmgsBaaGnbs1NPa1Jm32gBZAIh1pCtG7TSH4aE0y1uVY4uqoFPisGlpP2rSA5qTecWn5agK6BzSpgAyD+wFaqhnYoSZ1Vwr8CmlTQbrcO3ZaX0NAEyMbYaAlyquFoLKK3SPby9CeVUPThrSJmkCAE0CrKUQadi4DrdSlWhmah0YL9z9vClH59YGbHx1J8VZTyAjQepJjmXwAKTDQI3omc3p1U4gDUf6RfcdYfrUp5ClAi2J3Ba6UOXGo+K+bQrjjssitG2SJzshaLwMtXgRagUNpYYoVkMSBLM+9GGiJZMvduG6DRZ4qc04DMPtQQxOjEtACmhO7K1AbNbQDEggZyJwscFpAGwENhoBeUwh3bWolhe8BTYVKxQEWrSUn/uhcM5KhvUu/+eQu0Lzhi+VrK0PrZZNDQKs9cpYUuFYgMVpD4/NxenJTiMCNqdUEUf1qZWjppLT5qSkkUZbCwkbZMSuVnu80hfSkzRbQeqCZSAh6huR4VtoM2gHAlLf72smuWgE+VV7XpE25Ab2WFDgyhnSuKbs4GuGzCjR+tIoUuMFg3kgcWKLTwRqanJQ2W00hAsenfaApRC42hbCvK1SlE0HtE9BGgneJO+ELamitD1YjjOYnNYVcraGhtKkW0EqVVeDx733I2NH581k1NNxNLG0i0IJ8/NjVaOZ0tYZ2Vtr0Xv7tPV3hkWp9EFkgS/J0vosngTaSoaG06WHi+xObQkaAdlbanP8B2+2l0f90LmUAAAAASUVORK5CYII=); } /** * Custom BookStack overrides @@ -394,7 +401,8 @@ span.CodeMirror-selectedtext { background: none; } margin-bottom: $-l; border: 1px solid #DDD;; } -.cm-s-base16-light .CodeMirror-gutters { background: #f5f5f5; border-right: 1px solid #DDD; } + +.cm-s-mdn-like .CodeMirror-gutters { background: #f8f8f8; border-left: 0; color: #333; } .code-fill .CodeMirror { position: absolute; diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 3e7ff60f3..da0f7ef4c 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -19,6 +19,9 @@ &.disabled, &[disabled] { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==); } + &[readonly] { + background-color: #f8f8f8; + } &:focus { border-color: var(--color-primary); outline: 1px solid var(--color-primary); diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index cf78c162b..77e0773eb 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -213,6 +213,18 @@ blockquote { } } +.text-mono { + font-family: $mono; +} + +.text-uppercase { + text-transform: uppercase; +} + +.text-capitals { + text-transform: capitalize; +} + .code-base { background-color: #F8F8F8; font-size: 0.80em; diff --git a/resources/views/api-docs/index.blade.php b/resources/views/api-docs/index.blade.php new file mode 100644 index 000000000..e9583838c --- /dev/null +++ b/resources/views/api-docs/index.blade.php @@ -0,0 +1,238 @@ +@extends('simple-layout') + +@section('body') + +
+ +
+
+ +

Getting Started

+ + + + @foreach($docs as $model => $endpoints) +

{{ $model }}

+ + @foreach($endpoints as $endpoint) + + @endforeach + @endforeach +
+ +
+ +
+

Getting Started

+ +
Authentication
+

+ To access the API a user has to have the "Access System API" permission enabled on one of their assigned roles. + Permissions to content accessed via the API is limited by the roles & permissions assigned to the user that's used to access the API. +

+

Authentication to use the API is primarily done using API Tokens. Once the "Access System API" permission has been assigned to a user, a "API Tokens" section should be visible when editing their user profile. Choose "Create Token" and enter an appropriate name and expiry date, relevant for your API usage then press "Save". A "Token ID" and "Token Secret" will be immediately displayed. These values should be used as a header in API HTTP requests in the following format:

+
Authorization: Token <token_id>:<token_secret>
+

Here's an example of an authorized cURL request to list books in the system:

+
curl --request GET \
+  --url https://example.com/api/books \
+  --header 'Authorization: Token C6mdvEQTGnebsmVn3sFNeeuelGEBjyQp:NOvD3VlzuSVuBPNaf1xWHmy7nIRlaj22'
+

If already logged into the system within the browser, via a user account with permission to access the API, the system will also accept an existing session meaning you can browse API endpoints directly in the browser or use the browser devtools to play with the API.

+ +
+ +
Request Format
+

The API is primarily design to be interfaced using JSON so the majority of API endpoints, that accept data, will read JSON request data although application/x-www-form-urlencoded request data is also accepted. Endpoints that receive file data will need data sent in a multipart/form-data format although this will be highlighted in the documentation for such endpoints.

+

For endpoints in this documentation that accept data, a "Body Parameters" table will be available showing the parameters that will accepted in the request. Any rules for the values of such parameters, such as the data-type or if they're required, will be shown alongside the parameter name.

+ +
+ +
Listing Endpoints
+

Some endpoints will return a list of data models. These endpoints will return an array of the model data under a data property along with a numeric total property to indicate the total number of records found for the query within the system. Here's an example of a listing response:

+
{
+  "data": [
+    {
+      "id": 1,
+      "name": "BookStack User Guide",
+      "slug": "bookstack-user-guide",
+      "description": "This is a general guide on using BookStack on a day-to-day basis.",
+      "created_at": "2019-05-05 21:48:46",
+      "updated_at": "2019-12-11 20:57:31",
+      "created_by": 1,
+      "updated_by": 1,
+      "image_id": 3
+    }
+  ],
+  "total": 16
+}
+

+ There are a number of standard URL parameters that can be supplied to manipulate and page through the results returned from a listing endpoint: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDetailsExamples
count + Specify how many records will be returned in the response.
+ (Default: {{ config('api.default_item_count') }}, Max: {{ config('api.max_item_count') }}) +
Limit the count to 50
?count=50
offset + Specify how many records to skip over in the response.
+ (Default: 0) +
Skip over the first 100 records
?offset=100
sort + Specify what field is used to sort the data and the direction of the sort (Ascending or Descending).
+ Value is the name of a field, A + or - prefix dictates ordering.
+ Direction defaults to ascending.
+ Can use most fields shown in the response. +
+ Sort by name ascending
?sort=+name

+ Sort by "Created At" date descending
?sort=-created_at +
filter[<field>] + Specify a filter to be applied to the query. Can use most fields shown in the response.
+ By default a filter will apply a "where equals" query but the below operations are available using the format filter[<field>:<operation>]
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
eqWhere <field> equals the filter value.
neWhere <field> does not equal the filter value.
gtWhere <field> is greater than the filter value.
ltWhere <field> is less than the filter value.
gteWhere <field> is greater than or equal to the filter value.
lteWhere <field> is less than or equal to the filter value.
like + Where <field> is "like" the filter value.
+ % symbols can be used as wildcards. +
+
+ Filter where id is 5:
?filter[id]=5

+ Filter where id is not 5:
?filter[id:ne]=5

+ Filter where name contains "cat":
?filter[name:like]=%cat%

+ Filter where created after 2020-01-01:
?filter[created_at:gt]=2020-01-01 +
+ +
+ +
Error Handling
+

+ Successful responses will return a 200 or 204 HTTP response code. Errors will return a 4xx or a 5xx HTTP response code depending on the type of error. Errors follow a standard format as shown below. The message provided may be translated depending on the configured language of the system in addition to the API users' language preference. The code provided in the JSON response will match the HTTP response code. +

+ +
{
+	"error": {
+		"code": 401,
+		"message": "No authorization token found on the request"
+	}
+}
+
+ +
+ + @foreach($docs as $model => $endpoints) +
+

{{ $model }}

+ + @foreach($endpoints as $endpoint) +
{{ $endpoint['controller_method'] }}
+
+ {{ $endpoint['method'] }} + {{ url($endpoint['uri']) }} +
+

{{ $endpoint['description'] ?? '' }}

+ @if($endpoint['body_params'] ?? false) +
+ Body Parameters + + + + + + @foreach($endpoint['body_params'] as $paramName => $rules) + + + + + @endforeach +
Param NameValue Rules
{{ $paramName }} + @foreach($rules as $rule) + {{ $rule }} + @endforeach +
+
+ @endif + @if($endpoint['example_request'] ?? false) +
+ Example Request +
{{ $endpoint['example_request'] }}
+
+ @endif + @if($endpoint['example_response'] ?? false) +
+ Example Response +
{{ $endpoint['example_response'] }}
+
+ @endif + @if(!$loop->last) +
+ @endif + @endforeach +
+ @endforeach +
+ +
+ + +
+@stop \ No newline at end of file diff --git a/resources/views/form/date.blade.php b/resources/views/form/date.blade.php new file mode 100644 index 000000000..c2e70b9e3 --- /dev/null +++ b/resources/views/form/date.blade.php @@ -0,0 +1,9 @@ +has($name)) class="text-neg" @endif + placeholder="{{ $placeholder ?? 'YYYY-MM-DD' }}" + @if($autofocus ?? false) autofocus @endif + @if($disabled ?? false) disabled="disabled" @endif + @if(isset($model) || old($name)) value="{{ old($name) ?? $model->$name->format('Y-m-d') ?? ''}}" @endif> +@if($errors->has($name)) +
{{ $errors->first($name) }}
+@endif diff --git a/resources/views/form/text.blade.php b/resources/views/form/text.blade.php index 4b3631a06..fabfab451 100644 --- a/resources/views/form/text.blade.php +++ b/resources/views/form/text.blade.php @@ -3,6 +3,7 @@ @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif @if($autofocus ?? false) autofocus @endif @if($disabled ?? false) disabled="disabled" @endif + @if($readonly ?? false) readonly="readonly" @endif @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif> @if($errors->has($name))
{{ $errors->first($name) }}
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 4617b1f52..1fbc80b1f 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -34,12 +34,13 @@ {{ trans('common.toggle_all') }}
+
@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])
@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])
@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])
@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])
@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
-
@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
+
@include('settings.roles.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
diff --git a/resources/views/users/api-tokens/create.blade.php b/resources/views/users/api-tokens/create.blade.php new file mode 100644 index 000000000..46c3e0b8a --- /dev/null +++ b/resources/views/users/api-tokens/create.blade.php @@ -0,0 +1,33 @@ +@extends('simple-layout') + +@section('body') + +
+ +
+

{{ trans('settings.user_api_token_create') }}

+ +
+ {!! csrf_field() !!} + +
+ @include('users.api-tokens.form') + +
+

+ {{ trans('settings.user_api_token_create_secret_message') }} +

+
+
+ +
+ {{ trans('common.cancel') }} + +
+ +
+ +
+
+ +@stop diff --git a/resources/views/users/api-tokens/delete.blade.php b/resources/views/users/api-tokens/delete.blade.php new file mode 100644 index 000000000..8fcfcda95 --- /dev/null +++ b/resources/views/users/api-tokens/delete.blade.php @@ -0,0 +1,26 @@ +@extends('simple-layout') + +@section('body') +
+ +
+

{{ trans('settings.user_api_token_delete') }}

+ +

{{ trans('settings.user_api_token_delete_warning', ['tokenName' => $token->name]) }}

+ +
+

{{ trans('settings.user_api_token_delete_confirm') }}

+
+
+ {!! csrf_field() !!} + {!! method_field('delete') !!} + + {{ trans('common.cancel') }} + +
+
+
+ +
+
+@stop diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php new file mode 100644 index 000000000..821a00d93 --- /dev/null +++ b/resources/views/users/api-tokens/edit.blade.php @@ -0,0 +1,66 @@ +@extends('simple-layout') + +@section('body') + +
+ +
+

{{ trans('settings.user_api_token') }}

+ +
+ {!! method_field('put') !!} + {!! csrf_field() !!} + +
+ +
+
+ +

{{ trans('settings.user_api_token_id_desc') }}

+
+
+ @include('form.text', ['name' => 'token_id', 'readonly' => true]) +
+
+ + + @if( $secret ) +
+
+ +

{{ trans('settings.user_api_token_secret_desc') }}

+
+
+ +
+
+ @endif + + @include('users.api-tokens.form', ['model' => $token]) +
+ +
+ +
+ + {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }} + +
+ + {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }} + +
+ + +
+ +
+ +
+
+ +@stop diff --git a/resources/views/users/api-tokens/form.blade.php b/resources/views/users/api-tokens/form.blade.php new file mode 100644 index 000000000..d81a330d5 --- /dev/null +++ b/resources/views/users/api-tokens/form.blade.php @@ -0,0 +1,21 @@ + + +
+
+ +

{{ trans('settings.user_api_token_name_desc') }}

+
+
+ @include('form.text', ['name' => 'name']) +
+
+ +
+
+ +

{{ trans('settings.user_api_token_expiry_desc') }}

+
+
+ @include('form.date', ['name' => 'expires_at']) +
+
\ No newline at end of file diff --git a/resources/views/users/api-tokens/list.blade.php b/resources/views/users/api-tokens/list.blade.php new file mode 100644 index 000000000..ea1893372 --- /dev/null +++ b/resources/views/users/api-tokens/list.blade.php @@ -0,0 +1,34 @@ +
+
+

{{ trans('settings.users_api_tokens') }}

+ +
+ @if (count($user->apiTokens) > 0) + + + + + + + @foreach($user->apiTokens as $token) + + + + + + @endforeach +
{{ trans('common.name') }}{{ trans('settings.users_api_tokens_expires') }}
+ {{ $token->name }}
+ {{ $token->token_id }} +
{{ $token->expires_at->format('Y-m-d') ?? '' }} + {{ trans('common.edit') }} +
+ @else +

{{ trans('settings.users_api_tokens_none') }}

+ @endif +
\ No newline at end of file diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index ff1e7cbe5..f78c25ceb 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -87,6 +87,10 @@ @endif + + @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('users-manage')) + @include('users.api-tokens.list', ['user' => $user]) + @endif @stop diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 000000000..73f2faf79 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,16 @@ + 'auth'], function () { Route::put('/users/{id}', 'UserController@update'); Route::delete('/users/{id}', 'UserController@destroy'); + // User API Tokens + Route::get('/users/{userId}/create-api-token', 'UserApiTokenController@create'); + Route::post('/users/{userId}/create-api-token', 'UserApiTokenController@store'); + Route::get('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@edit'); + Route::put('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@update'); + Route::get('/users/{userId}/api-tokens/{tokenId}/delete', 'UserApiTokenController@delete'); + Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy'); + // Roles Route::get('/roles', 'PermissionController@listRoles'); Route::get('/roles/new', 'PermissionController@createRole'); diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php new file mode 100644 index 000000000..2ab81814b --- /dev/null +++ b/tests/Api/ApiAuthTest.php @@ -0,0 +1,148 @@ +getViewer(); + $this->giveUserPermissions($viewer, ['access-api']); + + $resp = $this->get($this->endpoint); + $resp->assertStatus(401); + + $this->actingAs($viewer, 'web'); + + $resp = $this->get($this->endpoint); + $resp->assertStatus(200); + } + + public function test_no_token_throws_error() + { + $resp = $this->get($this->endpoint); + $resp->assertStatus(401); + $resp->assertJson($this->errorResponse("No authorization token found on the request", 401)); + } + + public function test_bad_token_format_throws_error() + { + $resp = $this->get($this->endpoint, ['Authorization' => "Token abc123"]); + $resp->assertStatus(401); + $resp->assertJson($this->errorResponse("An authorization token was found on the request but the format appeared incorrect", 401)); + } + + public function test_token_with_non_existing_id_throws_error() + { + $resp = $this->get($this->endpoint, ['Authorization' => "Token abc:123"]); + $resp->assertStatus(401); + $resp->assertJson($this->errorResponse("No matching API token was found for the provided authorization token", 401)); + } + + public function test_token_with_bad_secret_value_throws_error() + { + $resp = $this->get($this->endpoint, ['Authorization' => "Token {$this->apiTokenId}:123"]); + $resp->assertStatus(401); + $resp->assertJson($this->errorResponse("The secret provided for the given used API token is incorrect", 401)); + } + + public function test_api_access_permission_required_to_access_api() + { + $resp = $this->get($this->endpoint, $this->apiAuthHeader()); + $resp->assertStatus(200); + auth()->logout(); + + $accessApiPermission = RolePermission::getByName('access-api'); + $editorRole = $this->getEditor()->roles()->first(); + $editorRole->detachPermission($accessApiPermission); + + $resp = $this->get($this->endpoint, $this->apiAuthHeader()); + $resp->assertStatus(403); + $resp->assertJson($this->errorResponse("The owner of the used API token does not have permission to make API calls", 403)); + } + + public function test_api_access_permission_required_to_access_api_with_session_auth() + { + $editor = $this->getEditor(); + $this->actingAs($editor, 'web'); + + $resp = $this->get($this->endpoint); + $resp->assertStatus(200); + auth('web')->logout(); + + $accessApiPermission = RolePermission::getByName('access-api'); + $editorRole = $this->getEditor()->roles()->first(); + $editorRole->detachPermission($accessApiPermission); + + $editor = User::query()->where('id', '=', $editor->id)->first(); + + $this->actingAs($editor, 'web'); + $resp = $this->get($this->endpoint); + $resp->assertStatus(403); + $resp->assertJson($this->errorResponse("The owner of the used API token does not have permission to make API calls", 403)); + } + + public function test_token_expiry_checked() + { + $editor = $this->getEditor(); + $token = $editor->apiTokens()->first(); + + $resp = $this->get($this->endpoint, $this->apiAuthHeader()); + $resp->assertStatus(200); + auth()->logout(); + + $token->expires_at = Carbon::now()->subDay()->format('Y-m-d'); + $token->save(); + + $resp = $this->get($this->endpoint, $this->apiAuthHeader()); + $resp->assertJson($this->errorResponse("The authorization token used has expired", 403)); + } + + public function test_email_confirmation_checked_using_api_auth() + { + $editor = $this->getEditor(); + $editor->email_confirmed = false; + $editor->save(); + + // Set settings and get user instance + $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']); + + $resp = $this->get($this->endpoint, $this->apiAuthHeader()); + $resp->assertStatus(401); + $resp->assertJson($this->errorResponse("The email address for the account in use needs to be confirmed", 401)); + } + + public function test_rate_limit_headers_active_on_requests() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 180); + $resp->assertHeader('x-ratelimit-remaining', 179); + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-remaining', 178); + } + + public function test_rate_limit_hit_gives_json_error() + { + config()->set(['api.requests_per_minute' => 1]); + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(200); + + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(429); + $resp->assertHeader('x-ratelimit-remaining', 0); + $resp->assertHeader('retry-after'); + $resp->assertJson([ + 'error' => [ + 'code' => 429, + ] + ]); + } +} \ No newline at end of file diff --git a/tests/Api/ApiConfigTest.php b/tests/Api/ApiConfigTest.php new file mode 100644 index 000000000..1b3da2f34 --- /dev/null +++ b/tests/Api/ApiConfigTest.php @@ -0,0 +1,58 @@ +actingAsApiEditor(); + + config()->set(['api.default_item_count' => 5]); + $resp = $this->get($this->endpoint); + $resp->assertJsonCount(5, 'data'); + + config()->set(['api.default_item_count' => 1]); + $resp = $this->get($this->endpoint); + $resp->assertJsonCount(1, 'data'); + } + + public function test_default_item_count_does_not_limit_count_param() + { + $this->actingAsApiEditor(); + config()->set(['api.default_item_count' => 1]); + $resp = $this->get($this->endpoint . '?count=5'); + $resp->assertJsonCount(5, 'data'); + } + + public function test_max_item_count_limits_listing_requests() + { + $this->actingAsApiEditor(); + + config()->set(['api.max_item_count' => 2]); + $resp = $this->get($this->endpoint); + $resp->assertJsonCount(2, 'data'); + + $resp = $this->get($this->endpoint . '?count=5'); + $resp->assertJsonCount(2, 'data'); + } + + public function test_requests_per_min_alters_rate_limit() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 180); + + config()->set(['api.requests_per_minute' => 10]); + + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertHeader('x-ratelimit-limit', 10); + } + +} \ No newline at end of file diff --git a/tests/Api/ApiDocsTest.php b/tests/Api/ApiDocsTest.php new file mode 100644 index 000000000..b240c1672 --- /dev/null +++ b/tests/Api/ApiDocsTest.php @@ -0,0 +1,42 @@ +getViewer(); + $resp = $this->actingAs($viewer)->get($this->endpoint); + $resp->assertStatus(403); + + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(200); + } + + public function test_docs_page_returns_view_with_docs_content() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint); + $resp->assertStatus(200); + $resp->assertSee(url('/api/docs.json')); + $resp->assertSee('Show a JSON view of the API docs data.'); + $resp->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + } + + public function test_docs_json_endpoint_returns_json() + { + $resp = $this->actingAsApiEditor()->get($this->endpoint . '.json'); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'application/json'); + $resp->assertJson([ + 'docs' => [ [ + 'name' => 'docs-display', + 'uri' => 'api/docs' + ] ] + ]); + } +} \ No newline at end of file diff --git a/tests/Api/ApiListingTest.php b/tests/Api/ApiListingTest.php new file mode 100644 index 000000000..741b9664b --- /dev/null +++ b/tests/Api/ApiListingTest.php @@ -0,0 +1,85 @@ +actingAsApiEditor(); + $bookCount = min(Book::visible()->count(), 100); + + $resp = $this->get($this->endpoint); + $resp->assertJsonCount($bookCount, 'data'); + + $resp = $this->get($this->endpoint . '?count=1'); + $resp->assertJsonCount(1, 'data'); + } + + public function test_offset_parameter() + { + $this->actingAsApiEditor(); + $books = Book::visible()->orderBy('id')->take(3)->get(); + + $resp = $this->get($this->endpoint . '?count=1'); + $resp->assertJsonMissing(['name' => $books[1]->name ]); + + $resp = $this->get($this->endpoint . '?count=1&offset=1000'); + $resp->assertJsonCount(0, 'data'); + } + + public function test_sort_parameter() + { + $this->actingAsApiEditor(); + + $sortChecks = [ + '-id' => Book::visible()->orderBy('id', 'desc')->first(), + '+name' => Book::visible()->orderBy('name', 'asc')->first(), + 'name' => Book::visible()->orderBy('name', 'asc')->first(), + '-name' => Book::visible()->orderBy('name', 'desc')->first() + ]; + + foreach ($sortChecks as $sortOption => $result) { + $resp = $this->get($this->endpoint . '?count=1&sort=' . $sortOption); + $resp->assertJson(['data' => [ + [ + 'id' => $result->id, + 'name' => $result->name, + ] + ]]); + } + } + + public function test_filter_parameter() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $nameSubstr = substr($book->name, 0, 4); + $encodedNameSubstr = rawurlencode($nameSubstr); + + $filterChecks = [ + // Test different types of filter + "filter[id]={$book->id}" => 1, + "filter[id:ne]={$book->id}" => Book::visible()->where('id', '!=', $book->id)->count(), + "filter[id:gt]={$book->id}" => Book::visible()->where('id', '>', $book->id)->count(), + "filter[id:gte]={$book->id}" => Book::visible()->where('id', '>=', $book->id)->count(), + "filter[id:lt]={$book->id}" => Book::visible()->where('id', '<', $book->id)->count(), + "filter[name:like]={$encodedNameSubstr}%" => Book::visible()->where('name', 'like', $nameSubstr . '%')->count(), + + // Test mulitple filters 'and' together + "filter[id]={$book->id}&filter[name]=random_non_existing_string" => 0, + ]; + + foreach ($filterChecks as $filterOption => $resultCount) { + $resp = $this->get($this->endpoint . '?count=1&' . $filterOption); + $resp->assertJson(['total' => $resultCount]); + } + } + +} \ No newline at end of file diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php new file mode 100644 index 000000000..a40e4c93b --- /dev/null +++ b/tests/Api/BooksApiTest.php @@ -0,0 +1,107 @@ +actingAsApiEditor(); + $firstBook = Book::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstBook->id, + 'name' => $firstBook->name, + 'slug' => $firstBook->slug, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $details = [ + 'name' => 'My API book', + 'description' => 'A book created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(200); + $newItem = Book::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertActivityExists('book_create', $newItem); + } + + public function test_book_name_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'description' => 'A book created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + "error" => [ + "message" => "The given data was invalid.", + "validation" => [ + "name" => ["The name field is required."] + ], + "code" => 422, + ], + ]); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$book->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $book->id, + 'slug' => $book->slug, + 'created_by' => [ + 'name' => $book->createdBy->name, + ], + 'updated_by' => [ + 'name' => $book->createdBy->name, + ] + ]); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $details = [ + 'name' => 'My updated API book', + 'description' => 'A book created via the API', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); + $book->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(array_merge($details, ['id' => $book->id, 'slug' => $book->slug])); + $this->assertActivityExists('book_update', $book); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$book->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('book_delete'); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 939a1a91e..f20b20fd8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,6 @@ $key]; + + if ($entity) { + $detailsToCheck['entity_type'] = $entity->getMorphClass(); + $detailsToCheck['entity_id'] = $entity->id; + } + + $this->assertDatabaseHas('activities', $detailsToCheck); + } } \ No newline at end of file diff --git a/tests/TestsApi.php b/tests/TestsApi.php new file mode 100644 index 000000000..0bb10a4cc --- /dev/null +++ b/tests/TestsApi.php @@ -0,0 +1,38 @@ +actingAs($this->getEditor(), 'api'); + return $this; + } + + /** + * Format the given items into a standardised error format. + */ + protected function errorResponse(string $message, int $code): array + { + return ["error" => ["code" => $code, "message" => $message]]; + } + + /** + * Get an approved API auth header. + */ + protected function apiAuthHeader(): array + { + return [ + "Authorization" => "Token {$this->apiTokenId}:{$this->apiTokenSecret}" + ]; + } + +} \ No newline at end of file diff --git a/tests/User/UserApiTokenTest.php b/tests/User/UserApiTokenTest.php new file mode 100644 index 000000000..7787e34fa --- /dev/null +++ b/tests/User/UserApiTokenTest.php @@ -0,0 +1,185 @@ + 'My test API token', + 'expires_at' => '2050-04-01', + ]; + + public function test_tokens_section_not_visible_without_access_api_permission() + { + $user = $this->getViewer(); + + $resp = $this->actingAs($user)->get($user->getEditUrl()); + $resp->assertDontSeeText('API Tokens'); + + $this->giveUserPermissions($user, ['access-api']); + + $resp = $this->actingAs($user)->get($user->getEditUrl()); + $resp->assertSeeText('API Tokens'); + $resp->assertSeeText('Create Token'); + } + + public function test_those_with_manage_users_can_view_other_user_tokens_but_not_create() + { + $viewer = $this->getViewer(); + $editor = $this->getEditor(); + $this->giveUserPermissions($viewer, ['users-manage']); + + $resp = $this->actingAs($viewer)->get($editor->getEditUrl()); + $resp->assertSeeText('API Tokens'); + $resp->assertDontSeeText('Create Token'); + } + + public function test_create_api_token() + { + $editor = $this->getEditor(); + + $resp = $this->asAdmin()->get($editor->getEditUrl('/create-api-token')); + $resp->assertStatus(200); + $resp->assertSee('Create API Token'); + $resp->assertSee('Token Secret'); + + $resp = $this->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + $resp->assertRedirect($editor->getEditUrl('/api-tokens/' . $token->id)); + $this->assertDatabaseHas('api_tokens', [ + 'user_id' => $editor->id, + 'name' => $this->testTokenData['name'], + 'expires_at' => $this->testTokenData['expires_at'], + ]); + + // Check secret token + $this->assertSessionHas('api-token-secret:' . $token->id); + $secret = session('api-token-secret:' . $token->id); + $this->assertDatabaseMissing('api_tokens', [ + 'secret' => $secret, + ]); + $this->assertTrue(\Hash::check($secret, $token->secret)); + + $this->assertTrue(strlen($token->token_id) === 32); + $this->assertTrue(strlen($secret) === 32); + + $this->assertSessionHas('success'); + } + + public function test_create_with_no_expiry_sets_expiry_hundred_years_away() + { + $editor = $this->getEditor(); + $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), ['name' => 'No expiry token', 'expires_at' => '']); + $token = ApiToken::query()->latest()->first(); + + $over = Carbon::now()->addYears(101); + $under = Carbon::now()->addYears(99); + $this->assertTrue( + ($token->expires_at < $over && $token->expires_at > $under), + "Token expiry set at 100 years in future" + ); + } + + public function test_created_token_displays_on_profile_page() + { + $editor = $this->getEditor(); + $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + + $resp = $this->get($editor->getEditUrl()); + $resp->assertElementExists('#api_tokens'); + $resp->assertElementContains('#api_tokens', $token->name); + $resp->assertElementContains('#api_tokens', $token->token_id); + $resp->assertElementContains('#api_tokens', $token->expires_at->format('Y-m-d')); + } + + public function test_secret_shown_once_after_creation() + { + $editor = $this->getEditor(); + $resp = $this->asAdmin()->followingRedirects()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $resp->assertSeeText('Token Secret'); + + $token = ApiToken::query()->latest()->first(); + $this->assertNull(session('api-token-secret:' . $token->id)); + + $resp = $this->get($editor->getEditUrl('/api-tokens/' . $token->id)); + $resp->assertDontSeeText('Client Secret'); + } + + public function test_token_update() + { + $editor = $this->getEditor(); + $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + $updateData = [ + 'name' => 'My updated token', + 'expires_at' => '2011-01-01', + ]; + + $resp = $this->put($editor->getEditUrl('/api-tokens/' . $token->id), $updateData); + $resp->assertRedirect($editor->getEditUrl('/api-tokens/' . $token->id)); + + $this->assertDatabaseHas('api_tokens', array_merge($updateData, ['id' => $token->id])); + $this->assertSessionHas('success'); + } + + public function test_token_update_with_blank_expiry_sets_to_hundred_years_away() + { + $editor = $this->getEditor(); + $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + + $resp = $this->put($editor->getEditUrl('/api-tokens/' . $token->id), [ + 'name' => 'My updated token', + 'expires_at' => '', + ]); + $token->refresh(); + + $over = Carbon::now()->addYears(101); + $under = Carbon::now()->addYears(99); + $this->assertTrue( + ($token->expires_at < $over && $token->expires_at > $under), + "Token expiry set at 100 years in future" + ); + } + + public function test_token_delete() + { + $editor = $this->getEditor(); + $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + + $tokenUrl = $editor->getEditUrl('/api-tokens/' . $token->id); + + $resp = $this->get($tokenUrl . '/delete'); + $resp->assertSeeText('Delete Token'); + $resp->assertSeeText($token->name); + $resp->assertElementExists('form[action="'.$tokenUrl.'"]'); + + $resp = $this->delete($tokenUrl); + $resp->assertRedirect($editor->getEditUrl('#api_tokens')); + $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]); + } + + public function test_user_manage_can_delete_token_without_api_permission_themselves() + { + $viewer = $this->getViewer(); + $editor = $this->getEditor(); + $this->giveUserPermissions($editor, ['users-manage']); + + $this->asAdmin()->post($viewer->getEditUrl('/create-api-token'), $this->testTokenData); + $token = ApiToken::query()->latest()->first(); + + $resp = $this->actingAs($editor)->get($viewer->getEditUrl('/api-tokens/' . $token->id)); + $resp->assertStatus(200); + $resp->assertSeeText('Delete Token'); + + $resp = $this->actingAs($editor)->delete($viewer->getEditUrl('/api-tokens/' . $token->id)); + $resp->assertRedirect($viewer->getEditUrl('#api_tokens')); + $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]); + } + +} \ No newline at end of file diff --git a/tests/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php similarity index 100% rename from tests/UserPreferencesTest.php rename to tests/User/UserPreferencesTest.php diff --git a/tests/UserProfileTest.php b/tests/User/UserProfileTest.php similarity index 100% rename from tests/UserProfileTest.php rename to tests/User/UserProfileTest.php