Merge pull request #1826 from BookStackApp/api_origins

Baseline API Implementation
This commit is contained in:
Dan Brown 2020-01-18 15:10:35 +00:00 committed by GitHub
commit 517687669c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2552 additions and 95 deletions

View File

@ -258,3 +258,9 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete. # Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null 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

View File

@ -0,0 +1,125 @@
<?php namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
class ApiDocsGenerator
{
protected $reflectionClasses = [];
protected $controllerClasses = [];
/**
* Generate API documentation.
*/
public function generate(): Collection
{
$apiRoutes = $this->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,
];
});
}
}

31
app/Api/ApiToken.php Normal file
View File

@ -0,0 +1,31 @@
<?php namespace BookStack\Api;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
class ApiToken extends Model
{
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => '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');
}
}

166
app/Api/ApiTokenGuard.php Normal file
View File

@ -0,0 +1,166 @@
<?php
namespace BookStack\Api;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Request;
class ApiTokenGuard implements Guard
{
use GuardHelpers;
/**
* The request instance.
*/
protected $request;
/**
* The last auth exception thrown in this request.
* @var ApiAuthException
*/
protected $lastAuthException;
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request)
{
$this->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;
}
}

View File

@ -0,0 +1,135 @@
<?php namespace BookStack\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
protected $request;
protected $fields;
protected $filterOperators = [
'eq' => '=',
'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);
}
}

View File

@ -72,7 +72,7 @@ class Role extends Model
*/ */
public function detachPermission(RolePermission $permission) public function detachPermission(RolePermission $permission)
{ {
$this->permissions()->detach($permission->id); $this->permissions()->detach([$permission->id]);
} }
/** /**

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Auth; <?php namespace BookStack\Auth;
use BookStack\Api\ApiToken;
use BookStack\Model; use BookStack\Model;
use BookStack\Notifications\ResetPassword; use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
@ -9,6 +10,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/** /**
@ -45,7 +47,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
* The attributes excluded from the model's JSON form. * The attributes excluded from the model's JSON form.
* @var array * @var array
*/ */
protected $hidden = ['password', 'remember_token']; protected $hidden = ['password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email'];
/** /**
* This holds the user's permissions when loaded. * This holds the user's permissions when loaded.
@ -218,19 +220,26 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
/** /**
* Get the url for editing this user. * Get the API tokens assigned to this user.
* @return string
*/ */
public function getEditUrl() public function apiTokens(): HasMany
{ {
return url('/settings/users/' . $this->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. * Get the url that links to this user's profile.
* @return mixed
*/ */
public function getProfileUrl() public function getProfileUrl(): string
{ {
return url('/user/' . $this->id); return url('/user/' . $this->id);
} }

View File

@ -194,6 +194,7 @@ class UserRepo
public function destroy(User $user) public function destroy(User $user)
{ {
$user->socialAccounts()->delete(); $user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->delete(); $user->delete();
// Delete user profile images // Delete user profile images

23
app/Config/api.php Normal file
View File

@ -0,0 +1,23 @@
<?php
/**
* API configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
return [
// The default number of items that are returned in listing API requests.
// This count can often be overridden, up the the max option, per-request via request options.
'default_item_count' => 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)
];

View File

@ -34,9 +34,7 @@ return [
], ],
'api' => [ 'api' => [
'driver' => 'token', 'driver' => 'api-token',
'provider' => 'users',
'hash' => false,
], ],
], ],

View File

@ -18,7 +18,8 @@ class Book extends Entity implements HasCoverImage
{ {
public $searchFactor = 2; public $searchFactor = 2;
protected $fillable = ['name', 'description', 'image_id']; protected $fillable = ['name', 'description'];
protected $hidden = ['restricted'];
/** /**
* Get the url for this book. * Get the url for this book.

View File

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ApiAuthException extends UnauthorizedException {
}

View File

@ -7,6 +7,9 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -47,6 +50,10 @@ class Handler extends ExceptionHandler
*/ */
public function render($request, Exception $e) public function render($request, Exception $e)
{ {
if ($this->isApiRequest($request)) {
return $this->renderApiException($e);
}
// Handle notify exceptions which will redirect to the // Handle notify exceptions which will redirect to the
// specified location then show a notification message. // specified location then show a notification message.
if ($this->isExceptionType($e, NotifyException::class)) { if ($this->isExceptionType($e, NotifyException::class)) {
@ -70,6 +77,41 @@ class Handler extends ExceptionHandler
return parent::render($request, $e); 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. * Check the exception chain to compare against the original exception type.
* @param Exception $e * @param Exception $e

View File

@ -0,0 +1,17 @@
<?php
namespace BookStack\Exceptions;
use Exception;
class UnauthorizedException extends Exception
{
/**
* ApiAuthException constructor.
*/
public function __construct($message, $code = 401)
{
parent::__construct($message, $code);
}
}

View File

@ -0,0 +1,30 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ListingResponseBuilder;
use BookStack\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
protected $rules = [];
/**
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
return $listing->toResponse();
}
/**
* Get the validation rules for this controller.
*/
public function getValdationRules(): array
{
return $this->rules;
}
}

View File

@ -0,0 +1,47 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiDocsGenerator;
use Cache;
use Illuminate\Support\Collection;
class ApiDocsController extends ApiController
{
/**
* Load the docs page for the API.
*/
public function display()
{
$docs = $this->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;
}
}

View File

@ -0,0 +1,101 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Book;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BooksApiController extends ApiController
{
protected $bookRepo;
protected $rules = [
'create' => [
'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);
}
}

View File

@ -0,0 +1,139 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Api\ApiToken;
use BookStack\Auth\User;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserApiTokenController extends Controller
{
/**
* Show the form to create a new API token.
*/
public function create(int $userId)
{
// Ensure user is has access-api permission and is the current user or has permission to manage the current user.
$this->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];
}
}

View File

@ -116,22 +116,24 @@ class UserController extends Controller
/** /**
* Show the form for editing the specified user. * 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); $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'); $authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers(); $activeSocialDrivers = $socialAuthService->getActiveDrivers();
$this->setPageTitle(trans('settings.user_profile')); $this->setPageTitle(trans('settings.user_profile'));
$roles = $this->userRepo->getAllRoles(); $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
]);
} }
/** /**

View File

@ -6,10 +6,7 @@ class Kernel extends HttpKernel
{ {
/** /**
* The application's global HTTP middleware stack. * The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application. * These middleware are run during every request to your application.
*
* @var array
*/ */
protected $middleware = [ protected $middleware = [
\BookStack\Http\Middleware\CheckForMaintenanceMode::class, \BookStack\Http\Middleware\CheckForMaintenanceMode::class,
@ -31,13 +28,14 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class, \Illuminate\Routing\Middleware\ThrottleRequests::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class, \BookStack\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\BookStack\Http\Middleware\Localization::class, \BookStack\Http\Middleware\Localization::class,
\BookStack\Http\Middleware\GlobalViewData::class, \BookStack\Http\Middleware\GlobalViewData::class,
], ],
'api' => [ 'api' => [
'throttle:60,1', \BookStack\Http\Middleware\ThrottleApiRequests::class,
'bindings', \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 = [ protected $routeMiddleware = [
'auth' => \BookStack\Http\Middleware\Authenticate::class, 'auth' => \BookStack\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class, 'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,

View File

@ -0,0 +1,66 @@
<?php
namespace BookStack\Http\Middleware;
use BookStack\Exceptions\ApiAuthException;
use BookStack\Exceptions\UnauthorizedException;
use Closure;
use Illuminate\Http\Request;
class ApiAuthenticate
{
use ChecksForEmailConfirmation;
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
// Validate the token and it's users API access
try {
$this->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);
}
}

View File

@ -3,38 +3,19 @@
namespace BookStack\Http\Middleware; namespace BookStack\Http\Middleware;
use Closure; use Closure;
use Illuminate\Contracts\Auth\Guard; use Illuminate\Http\Request;
class Authenticate class Authenticate
{ {
/** use ChecksForEmailConfirmation;
* The Guard implementation.
* @var Guard
*/
protected $auth;
/**
* Create a new filter instance.
* @param Guard $auth
*/
public function __construct(Guard $auth)
{
$this->auth = $auth;
}
/** /**
* Handle an incoming request. * 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()) { if ($this->awaitingEmailConfirmation()) {
$requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict')); return $this->emailConfirmationErrorResponse($request);
if ($requireConfirmation && !$this->auth->user()->email_confirmed) {
return redirect('/register/confirm/awaiting');
}
} }
if (!hasAppAccess()) { if (!hasAppAccess()) {
@ -47,4 +28,22 @@ class Authenticate
return $next($request); 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');
}
} }

View File

@ -0,0 +1,36 @@
<?php
namespace BookStack\Http\Middleware;
use BookStack\Exceptions\UnauthorizedException;
use Illuminate\Http\Request;
trait ChecksForEmailConfirmation
{
/**
* Check if the current user has a confirmed email if the instance deems it as required.
* Throws if confirmation is required by the user.
* @throws UnauthorizedException
*/
protected function ensureEmailConfirmedIfRequested()
{
if ($this->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;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace BookStack\Http\Middleware;
use Closure;
use Illuminate\Session\Middleware\StartSession as Middleware;
class StartSessionIfCookieExists extends Middleware
{
/**
* Handle an incoming request.
*/
public function handle($request, Closure $next)
{
$sessionCookieName = config('session.cookie');
if ($request->cookies->has($sessionCookieName)) {
return parent::handle($request, $next);
}
return $next($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace BookStack\Http\Middleware;
use Illuminate\Routing\Middleware\ThrottleRequests as Middleware;
class ThrottleApiRequests extends Middleware
{
/**
* Resolve the number of attempts if the user is authenticated or not.
*/
protected function resolveMaxAttempts($request, $maxAttempts)
{
return (int) config('api.requests_per_minute');
}
}

View File

@ -3,6 +3,7 @@
namespace BookStack\Providers; namespace BookStack\Providers;
use Auth; use Auth;
use BookStack\Api\ApiTokenGuard;
use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\LdapService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -15,7 +16,9 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
// Auth::extend('api-token', function ($app, $name, array $config) {
return new ApiTokenGuard($app['request']);
});
} }
/** /**

View File

@ -34,7 +34,7 @@ class RouteServiceProvider extends ServiceProvider
public function map() public function map()
{ {
$this->mapWebRoutes(); $this->mapWebRoutes();
// $this->mapApiRoutes(); $this->mapApiRoutes();
} }
/** /**
* Define the "web" routes for the application. * Define the "web" routes for the application.
@ -63,7 +63,7 @@ class RouteServiceProvider extends ServiceProvider
{ {
Route::group([ Route::group([
'middleware' => 'api', 'middleware' => 'api',
'namespace' => $this->namespace, 'namespace' => $this->namespace . '\Api',
'prefix' => 'api', 'prefix' => 'api',
], function ($router) { ], function ($router) {
require base_path('routes/api.php'); require base_path('routes/api.php');

View File

@ -8,6 +8,7 @@ class Image extends Ownable
{ {
protected $fillable = ['name']; protected $fillable = ['name'];
protected $hidden = [];
/** /**
* Get a thumbnail for this image. * Get a thumbnail for this image.

View File

@ -42,7 +42,6 @@ function user(): User
/** /**
* Check if current user is a signed in user. * Check if current user is a signed in user.
* @return bool
*/ */
function signedInUser(): bool function signedInUser(): bool
{ {
@ -51,7 +50,6 @@ function signedInUser(): bool
/** /**
* Check if the current user has general access. * Check if the current user has general access.
* @return bool
*/ */
function hasAppAccess(): bool function hasAppAccess(): bool
{ {
@ -62,9 +60,6 @@ function hasAppAccess(): bool
* Check if the current user has a permission. * Check if the current user has a permission.
* If an ownable element is passed in the jointPermissions are checked against * If an ownable element is passed in the jointPermissions are checked against
* that particular item. * that particular item.
* @param string $permission
* @param Ownable $ownable
* @return bool
*/ */
function userCan(string $permission, Ownable $ownable = null): bool function userCan(string $permission, Ownable $ownable = null): bool
{ {

View File

@ -46,9 +46,9 @@ class AddTemplateSupport extends Migration
// Remove templates-manage permission // Remove templates-manage permission
$templatesManagePermission = DB::table('role_permissions') $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('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();
} }
} }

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
class AddApiAuth extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Add API tokens table
Schema::create('api_tokens', function(Blueprint $table) {
$table->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();
}
}

View File

@ -1,6 +1,8 @@
<?php <?php
use BookStack\Api\ApiToken;
use BookStack\Auth\Permissions\PermissionService; use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Bookshelf; use BookStack\Entities\Bookshelf;
@ -52,6 +54,18 @@ class DummyContentSeeder extends Seeder
$shelves = factory(Bookshelf::class, 10)->create($byData); $shelves = factory(Bookshelf::class, 10)->create($byData);
$largeBook->shelves()->attach($shelves->pluck('id')); $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(PermissionService::class)->buildJointPermissions();
app(SearchService::class)->indexAllEntities(); app(SearchService::class)->indexAllEntities();
} }

View File

@ -0,0 +1,4 @@
{
"name": "My own book",
"description": "This is my own little book"
}

View File

@ -0,0 +1,4 @@
{
"name": "My updated book",
"description": "This is my book with updated details"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -50,5 +50,6 @@
<server name="APP_URL" value="http://bookstack.dev"/> <server name="APP_URL" value="http://bookstack.dev"/>
<server name="DEBUGBAR_ENABLED" value="false"/> <server name="DEBUGBAR_ENABLED" value="false"/>
<server name="SAML2_ENABLED" value="false"/> <server name="SAML2_ENABLED" value="false"/>
<server name="API_REQUESTS_PER_MIN" value="180"/>
</php> </php>
</phpunit> </phpunit>

View File

@ -0,0 +1,10 @@
import Code from "../services/code"
class CodeHighlighter {
constructor(elem) {
Code.highlightWithin(elem);
}
}
export default CodeHighlighter;

View File

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

View File

@ -30,6 +30,8 @@ import settingColorPicker from "./setting-color-picker";
import entityPermissionsEditor from "./entity-permissions-editor"; import entityPermissionsEditor from "./entity-permissions-editor";
import templateManager from "./template-manager"; import templateManager from "./template-manager";
import newUserPassword from "./new-user-password"; import newUserPassword from "./new-user-password";
import detailsHighlighter from "./details-highlighter";
import codeHighlighter from "./code-highlighter";
const componentMapping = { const componentMapping = {
'dropdown': dropdown, 'dropdown': dropdown,
@ -64,6 +66,8 @@ const componentMapping = {
'entity-permissions-editor': entityPermissionsEditor, 'entity-permissions-editor': entityPermissionsEditor,
'template-manager': templateManager, 'template-manager': templateManager,
'new-user-password': newUserPassword, 'new-user-password': newUserPassword,
'details-highlighter': detailsHighlighter,
'code-highlighter': codeHighlighter,
}; };
window.components = {}; window.components = {};

View File

@ -87,9 +87,20 @@ const modeMap = {
* Highlight pre elements on a page * Highlight pre elements on a page
*/ */
function highlight() { function highlight() {
let codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre'); const codeBlocks = document.querySelectorAll('.page-content pre, .comment-box .content pre');
for (let i = 0; i < codeBlocks.length; i++) { for (const codeBlock of codeBlocks) {
highlightElem(codeBlocks[i]); 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} * @returns {*|string}
*/ */
function getTheme() { function getTheme() {
return window.codeTheme || 'base16-light'; return window.codeTheme || 'mdn-like';
} }
/** /**
@ -308,6 +319,7 @@ function getMetaKey() {
export default { export default {
highlight: highlight, highlight: highlight,
highlightWithin: highlightWithin,
wysiwygView: wysiwygView, wysiwygView: wysiwygView,
popupEditor: popupEditor, popupEditor: popupEditor,
setMode: setMode, setMode: setMode,

View File

@ -13,6 +13,7 @@ return [
'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', '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_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_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_anonymous' => 'LDAP access failed using anonymous bind',
'ldap_fail_authed' => 'LDAP access failed using given dn & password details', 'ldap_fail_authed' => 'LDAP access failed using given dn & password details',
'ldap_extension_not_installed' => 'LDAP PHP extension not installed', 'ldap_extension_not_installed' => 'LDAP PHP extension not installed',
@ -88,4 +89,12 @@ return [
'app_down' => ':appName is down right now', 'app_down' => ':appName is down right now',
'back_soon' => 'It will be back up soon.', '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',
]; ];

View File

@ -103,6 +103,7 @@ return [
'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions',
'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages',
'role_manage_page_templates' => 'Manage page templates', 'role_manage_page_templates' => 'Manage page templates',
'role_access_api' => 'Access system API',
'role_manage_settings' => 'Manage app settings', 'role_manage_settings' => 'Manage app settings',
'role_asset' => 'Asset Permissions', '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.', '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_disconnect' => 'Disconnect Account',
'users_social_connected' => ':socialAccount account was successfully attached to your profile.', 'users_social_connected' => ':socialAccount account was successfully attached to your profile.',
'users_social_disconnected' => ':socialAccount account was successfully disconnected from 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 //! If editing translations files directly please ignore this in all
//! languages apart from en. Content will be auto-copied from en. //! languages apart from en. Content will be auto-copied from en.

View File

@ -237,3 +237,25 @@
.tag-list div:last-child .tag-item { .tag-list div:last-child .tag-item {
margin-bottom: 0; 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 }
}

View File

@ -343,44 +343,51 @@ span.CodeMirror-selectedtext { background: none; }
/* /*
MDN-LIKE Theme - Mozilla
Ported to CodeMirror by Peter Kroon <plakroon@gmail.com>
Report bugs/issues here: https://github.com/codemirror/CodeMirror/issues
GitHub: @peterkroon
Name: Base16 Default Light The mdn-like theme is inspired on the displayed code examples at: https://developer.mozilla.org/en-US/docs/Web/CSS/animation
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)
*/ */
.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-mdn-like .CodeMirror-gutters { background: #f8f8f8; border-left: 6px solid rgba(0,83,159,0.65); color: #333; }
.cm-s-base16-light div.CodeMirror-selected { background: #e0e0e0; } .cm-s-mdn-like .CodeMirror-linenumber { color: #aaa; padding-left: 8px; }
.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-mdn-like .CodeMirror-cursor { border-left: 2px solid #222; }
.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-base16-light span.cm-comment { color: #8f5536; } .cm-s-mdn-like .cm-keyword { color: #6262FF; }
.cm-s-base16-light span.cm-atom { color: #aa759f; } .cm-s-mdn-like .cm-atom { color: #F90; }
.cm-s-base16-light span.cm-number { color: #aa759f; } .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-mdn-like .cm-variable { color: #07a; }
.cm-s-base16-light span.cm-keyword { color: #ac4142; } .cm-s-mdn-like .cm-property { color: #905; }
.cm-s-base16-light span.cm-string { color: #e09c3c; } .cm-s-mdn-like .cm-qualifier { color: #690; }
.cm-s-base16-light span.cm-builtin { color: #4c7f9e; } .cm-s-mdn-like .cm-operator { color: #cda869; }
.cm-s-base16-light span.cm-variable { color: #90a959; } .cm-s-mdn-like .cm-comment { color:#777; font-weight:normal; }
.cm-s-base16-light span.cm-variable-2 { color: #6a9fb5; } .cm-s-mdn-like .cm-string { color:#07a; font-style:italic; }
.cm-s-base16-light span.cm-def { color: #d28445; } .cm-s-mdn-like .cm-string-2 { color:#bd6b18; } /*?*/
.cm-s-base16-light span.cm-bracket { color: #202020; } .cm-s-mdn-like .cm-meta { color: #000; } /*?*/
.cm-s-base16-light span.cm-tag { color: #ac4142; } .cm-s-mdn-like .cm-builtin { color: #9B7536; } /*?*/
.cm-s-base16-light span.cm-link { color: #aa759f; } .cm-s-mdn-like .cm-tag { color: #997643; }
.cm-s-base16-light span.cm-error { background: #ac4142; color: #505050; } .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; } div.cm-s-mdn-like .CodeMirror-activeline-background { background: #efefff; }
.cm-s-base16-light .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } 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 * Custom BookStack overrides
@ -394,7 +401,8 @@ span.CodeMirror-selectedtext { background: none; }
margin-bottom: $-l; margin-bottom: $-l;
border: 1px solid #DDD;; 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 { .code-fill .CodeMirror {
position: absolute; position: absolute;

View File

@ -19,6 +19,9 @@
&.disabled, &[disabled] { &.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==); background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
} }
&[readonly] {
background-color: #f8f8f8;
}
&:focus { &:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
outline: 1px solid var(--color-primary); outline: 1px solid var(--color-primary);

View File

@ -213,6 +213,18 @@ blockquote {
} }
} }
.text-mono {
font-family: $mono;
}
.text-uppercase {
text-transform: uppercase;
}
.text-capitals {
text-transform: capitalize;
}
.code-base { .code-base {
background-color: #F8F8F8; background-color: #F8F8F8;
font-size: 0.80em; font-size: 0.80em;

View File

@ -0,0 +1,238 @@
@extends('simple-layout')
@section('body')
<div class="container pt-xl">
<div class="grid right-focus reverse-collapse">
<div>
<p class="text-uppercase text-muted mb-xm mt-l"><strong>Getting Started</strong></p>
<div class="text-mono">
<div class="mb-xs"><a href="#authentication">Authentication</a></div>
<div class="mb-xs"><a href="#request-format">Request Format</a></div>
<div class="mb-xs"><a href="#listing-endpoints">Listing Endpoints</a></div>
<div class="mb-xs"><a href="#error-handling">Error Handling</a></div>
</div>
@foreach($docs as $model => $endpoints)
<p class="text-uppercase text-muted mb-xm mt-l"><strong>{{ $model }}</strong></p>
@foreach($endpoints as $endpoint)
<div class="mb-xs">
<a href="#{{ $endpoint['name'] }}" class="text-mono inline block mr-s">
<span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
</a>
<a href="#{{ $endpoint['name'] }}" class="text-mono">
{{ $endpoint['controller_method'] }}
</a>
</div>
@endforeach
@endforeach
</div>
<div style="overflow: auto;">
<section code-highlighter class="card content-wrap auto-height">
<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
<h5 id="authentication" class="text-mono mb-m">Authentication</h5>
<p>
To access the API a user has to have the <em>"Access System API"</em> 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.
</p>
<p>Authentication to use the API is primarily done using API Tokens. Once the <em>"Access System API"</em> 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:</p>
<pre><code class="language-css">Authorization: Token &lt;token_id&gt;:&lt;token_secret&gt;</code></pre>
<p>Here's an example of an authorized cURL request to list books in the system:</p>
<pre><code class="language-shell">curl --request GET \
--url https://example.com/api/books \
--header 'Authorization: Token C6mdvEQTGnebsmVn3sFNeeuelGEBjyQp:NOvD3VlzuSVuBPNaf1xWHmy7nIRlaj22'</code></pre>
<p>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.</p>
<hr>
<h5 id="request-format" class="text-mono mb-m">Request Format</h5>
<p>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 <code>application/x-www-form-urlencoded</code> request data is also accepted. Endpoints that receive file data will need data sent in a <code>multipart/form-data</code> format although this will be highlighted in the documentation for such endpoints.</p>
<p>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.</p>
<hr>
<h5 id="listing-endpoints" class="text-mono mb-m">Listing Endpoints</h5>
<p>Some endpoints will return a list of data models. These endpoints will return an array of the model data under a <code>data</code> property along with a numeric <code>total</code> property to indicate the total number of records found for the query within the system. Here's an example of a listing response:</p>
<pre><code class="language-json">{
"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
}</code></pre>
<p>
There are a number of standard URL parameters that can be supplied to manipulate and page through the results returned from a listing endpoint:
</p>
<table class="table">
<tr>
<th>Parameter</th>
<th>Details</th>
<th width="30%">Examples</th>
</tr>
<tr>
<td>count</td>
<td>
Specify how many records will be returned in the response. <br>
(Default: {{ config('api.default_item_count') }}, Max: {{ config('api.max_item_count') }})
</td>
<td>Limit the count to 50<br><code>?count=50</code></td>
</tr>
<tr>
<td>offset</td>
<td>
Specify how many records to skip over in the response. <br>
(Default: 0)
</td>
<td>Skip over the first 100 records<br><code>?offset=100</code></td>
</tr>
<tr>
<td>sort</td>
<td>
Specify what field is used to sort the data and the direction of the sort (Ascending or Descending).<br>
Value is the name of a field, A <code>+</code> or <code>-</code> prefix dictates ordering. <br>
Direction defaults to ascending. <br>
Can use most fields shown in the response.
</td>
<td>
Sort by name ascending<br><code>?sort=+name</code> <br> <br>
Sort by "Created At" date descending<br><code>?sort=-created_at</code>
</td>
</tr>
<tr>
<td>filter[&lt;field&gt;]</td>
<td>
Specify a filter to be applied to the query. Can use most fields shown in the response. <br>
By default a filter will apply a "where equals" query but the below operations are available using the format filter[&lt;field&gt;:&lt;operation&gt;] <br>
<table>
<tr>
<td>eq</td>
<td>Where <code>&lt;field&gt;</code> equals the filter value.</td>
</tr>
<tr>
<td>ne</td>
<td>Where <code>&lt;field&gt;</code> does not equal the filter value.</td>
</tr>
<tr>
<td>gt</td>
<td>Where <code>&lt;field&gt;</code> is greater than the filter value.</td>
</tr>
<tr>
<td>lt</td>
<td>Where <code>&lt;field&gt;</code> is less than the filter value.</td>
</tr>
<tr>
<td>gte</td>
<td>Where <code>&lt;field&gt;</code> is greater than or equal to the filter value.</td>
</tr>
<tr>
<td>lte</td>
<td>Where <code>&lt;field&gt;</code> is less than or equal to the filter value.</td>
</tr>
<tr>
<td>like</td>
<td>
Where <code>&lt;field&gt;</code> is "like" the filter value. <br>
<code>%</code> symbols can be used as wildcards.
</td>
</tr>
</table>
</td>
<td>
Filter where id is 5: <br><code>?filter[id]=5</code><br><br>
Filter where id is not 5: <br><code>?filter[id:ne]=5</code><br><br>
Filter where name contains "cat": <br><code>?filter[name:like]=%cat%</code><br><br>
Filter where created after 2020-01-01: <br><code>?filter[created_at:gt]=2020-01-01</code>
</td>
</tr>
</table>
<hr>
<h5 id="error-handling" class="text-mono mb-m">Error Handling</h5>
<p>
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.
</p>
<pre><code class="language-json">{
"error": {
"code": 401,
"message": "No authorization token found on the request"
}
}
</code></pre>
</section>
@foreach($docs as $model => $endpoints)
<section class="card content-wrap auto-height">
<h1 class="list-heading text-capitals">{{ $model }}</h1>
@foreach($endpoints as $endpoint)
<h6 class="text-uppercase text-muted float right">{{ $endpoint['controller_method'] }}</h6>
<h5 id="{{ $endpoint['name'] }}" class="text-mono mb-m">
<span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
{{ url($endpoint['uri']) }}
</h5>
<p class="mb-m">{{ $endpoint['description'] ?? '' }}</p>
@if($endpoint['body_params'] ?? false)
<details class="mb-m">
<summary class="text-muted">Body Parameters</summary>
<table class="table">
<tr>
<th>Param Name</th>
<th>Value Rules</th>
</tr>
@foreach($endpoint['body_params'] as $paramName => $rules)
<tr>
<td>{{ $paramName }}</td>
<td>
@foreach($rules as $rule)
<code class="mr-xs">{{ $rule }}</code>
@endforeach
</td>
</tr>
@endforeach
</table>
</details>
@endif
@if($endpoint['example_request'] ?? false)
<details details-highlighter class="mb-m">
<summary class="text-muted">Example Request</summary>
<pre><code class="language-json">{{ $endpoint['example_request'] }}</code></pre>
</details>
@endif
@if($endpoint['example_response'] ?? false)
<details details-highlighter class="mb-m">
<summary class="text-muted">Example Response</summary>
<pre><code class="language-json">{{ $endpoint['example_response'] }}</code></pre>
</details>
@endif
@if(!$loop->last)
<hr>
@endif
@endforeach
</section>
@endforeach
</div>
</div>
</div>
@stop

View File

@ -0,0 +1,9 @@
<input type="date" id="{{ $name }}" name="{{ $name }}"
@if($errors->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))
<div class="text-neg text-small">{{ $errors->first($name) }}</div>
@endif

View File

@ -3,6 +3,7 @@
@if(isset($placeholder)) placeholder="{{$placeholder}}" @endif @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
@if($autofocus ?? false) autofocus @endif @if($autofocus ?? false) autofocus @endif
@if($disabled ?? false) disabled="disabled" @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(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
@if($errors->has($name)) @if($errors->has($name))
<div class="text-neg text-small">{{ $errors->first($name) }}</div> <div class="text-neg text-small">{{ $errors->first($name) }}</div>

View File

@ -34,12 +34,13 @@
<a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a> <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
</div> </div>
<div class="toggle-switch-list"> <div class="toggle-switch-list">
<div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div> <div>@include('settings.roles.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,33 @@
@extends('simple-layout')
@section('body')
<div class="container small pt-xl">
<main class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('settings.user_api_token_create') }}</h1>
<form action="{{ $user->getEditUrl('/create-api-token') }}" method="post">
{!! csrf_field() !!}
<div class="setting-list">
@include('users.api-tokens.form')
<div>
<p class="text-warn italic">
{{ trans('settings.user_api_token_create_secret_message') }}
</p>
</div>
</div>
<div class="form-group text-right">
<a href="{{ $user->getEditUrl('#api_tokens') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button class="button" type="submit">{{ trans('common.save') }}</button>
</div>
</form>
</main>
</div>
@stop

View File

@ -0,0 +1,26 @@
@extends('simple-layout')
@section('body')
<div class="container small pt-xl">
<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('settings.user_api_token_delete') }}</h1>
<p>{{ trans('settings.user_api_token_delete_warning', ['tokenName' => $token->name]) }}</p>
<div class="grid half">
<p class="text-neg"><strong>{{ trans('settings.user_api_token_delete_confirm') }}</strong></p>
<div>
<form action="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" method="POST" class="text-right">
{!! csrf_field() !!}
{!! method_field('delete') !!}
<a href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('common.confirm') }}</button>
</form>
</div>
</div>
</div>
</div>
@stop

View File

@ -0,0 +1,66 @@
@extends('simple-layout')
@section('body')
<div class="container small pt-xl">
<main class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('settings.user_api_token') }}</h1>
<form action="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" method="post">
{!! method_field('put') !!}
{!! csrf_field() !!}
<div class="setting-list">
<div class="grid half gap-xl v-center">
<div>
<label class="setting-list-label">{{ trans('settings.user_api_token_id') }}</label>
<p class="small">{{ trans('settings.user_api_token_id_desc') }}</p>
</div>
<div>
@include('form.text', ['name' => 'token_id', 'readonly' => true])
</div>
</div>
@if( $secret )
<div class="grid half gap-xl v-center">
<div>
<label class="setting-list-label">{{ trans('settings.user_api_token_secret') }}</label>
<p class="small text-warn">{{ trans('settings.user_api_token_secret_desc') }}</p>
</div>
<div>
<input type="text" readonly="readonly" value="{{ $secret }}">
</div>
</div>
@endif
@include('users.api-tokens.form', ['model' => $token])
</div>
<div class="grid half gap-xl v-center">
<div class="text-muted text-small">
<span title="{{ $token->created_at }}">
{{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }}
</span>
<br>
<span title="{{ $token->updated_at }}">
{{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }}
</span>
</div>
<div class="form-group text-right">
<a href="{{ $user->getEditUrl('#api_tokens') }}" class="button outline">{{ trans('common.back') }}</a>
<a href="{{ $user->getEditUrl('/api-tokens/' . $token->id . '/delete') }}" class="button outline">{{ trans('settings.user_api_token_delete') }}</a>
<button class="button" type="submit">{{ trans('common.save') }}</button>
</div>
</div>
</form>
</main>
</div>
@stop

View File

@ -0,0 +1,21 @@
<div class="grid half gap-xl v-center">
<div>
<label class="setting-list-label">{{ trans('settings.user_api_token_name') }}</label>
<p class="small">{{ trans('settings.user_api_token_name_desc') }}</p>
</div>
<div>
@include('form.text', ['name' => 'name'])
</div>
</div>
<div class="grid half gap-xl v-center">
<div>
<label class="setting-list-label">{{ trans('settings.user_api_token_expiry') }}</label>
<p class="small">{{ trans('settings.user_api_token_expiry_desc') }}</p>
</div>
<div class="text-right">
@include('form.date', ['name' => 'expires_at'])
</div>
</div>

View File

@ -0,0 +1,34 @@
<section class="card content-wrap auto-height" id="api_tokens">
<div class="grid half mb-s">
<div><h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2></div>
<div class="text-right pt-xs">
@if(userCan('access-api'))
<a href="{{ url('/api/docs') }}" class="button outline">{{ trans('settings.users_api_tokens_docs') }}</a>
<a href="{{ $user->getEditUrl('/create-api-token') }}" class="button outline">{{ trans('settings.users_api_tokens_create') }}</a>
@endif
</div>
</div>
@if (count($user->apiTokens) > 0)
<table class="table">
<tr>
<th>{{ trans('common.name') }}</th>
<th>{{ trans('settings.users_api_tokens_expires') }}</th>
<th></th>
</tr>
@foreach($user->apiTokens as $token)
<tr>
<td>
{{ $token->name }} <br>
<span class="small text-muted italic">{{ $token->token_id }}</span>
</td>
<td>{{ $token->expires_at->format('Y-m-d') ?? '' }}</td>
<td class="text-right">
<a class="button outline small" href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}">{{ trans('common.edit') }}</a>
</td>
</tr>
@endforeach
</table>
@else
<p class="text-muted italic py-m">{{ trans('settings.users_api_tokens_none') }}</p>
@endif
</section>

View File

@ -87,6 +87,10 @@
</div> </div>
</section> </section>
@endif @endif
@if(($currentUser->id === $user->id && userCan('access-api')) || userCan('users-manage'))
@include('users.api-tokens.list', ['user' => $user])
@endif
</div> </div>
@stop @stop

16
routes/api.php Normal file
View File

@ -0,0 +1,16 @@
<?php
/**
* Routes for the BookStack API.
* Routes have a uri prefix of /api/.
* Controllers are all within app/Http/Controllers/Api
*/
Route::get('docs', 'ApiDocsController@display');
Route::get('docs.json', 'ApiDocsController@json');
Route::get('books', 'BooksApiController@list');
Route::post('books', 'BooksApiController@create');
Route::get('books/{id}', 'BooksApiController@read');
Route::put('books/{id}', 'BooksApiController@update');
Route::delete('books/{id}', 'BooksApiController@delete');

View File

@ -187,6 +187,14 @@ Route::group(['middleware' => 'auth'], function () {
Route::put('/users/{id}', 'UserController@update'); Route::put('/users/{id}', 'UserController@update');
Route::delete('/users/{id}', 'UserController@destroy'); 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 // Roles
Route::get('/roles', 'PermissionController@listRoles'); Route::get('/roles', 'PermissionController@listRoles');
Route::get('/roles/new', 'PermissionController@createRole'); Route::get('/roles/new', 'PermissionController@createRole');

148
tests/Api/ApiAuthTest.php Normal file
View File

@ -0,0 +1,148 @@
<?php
namespace Tests;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\User;
use Carbon\Carbon;
class ApiAuthTest extends TestCase
{
use TestsApi;
protected $endpoint = '/api/books';
public function test_requests_succeed_with_default_auth()
{
$viewer = $this->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,
]
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Tests;
use BookStack\Auth\Permissions\RolePermission;
use Carbon\Carbon;
class ApiConfigTest extends TestCase
{
use TestsApi;
protected $endpoint = '/api/books';
public function test_default_item_count_reflected_in_listing_requests()
{
$this->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);
}
}

42
tests/Api/ApiDocsTest.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace Tests;
class ApiDocsTest extends TestCase
{
use TestsApi;
protected $endpoint = '/api/docs';
public function test_docs_page_not_visible_to_normal_viewers()
{
$viewer = $this->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'
] ]
]);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Tests;
use BookStack\Entities\Book;
class ApiListingTest extends TestCase
{
use TestsApi;
protected $endpoint = '/api/books';
public function test_count_parameter_limits_responses()
{
$this->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]);
}
}
}

107
tests/Api/BooksApiTest.php Normal file
View File

@ -0,0 +1,107 @@
<?php namespace Tests;
use BookStack\Entities\Book;
class BooksApiTest extends TestCase
{
use TestsApi;
protected $baseEndpoint = '/api/books';
public function test_index_endpoint_returns_expected_book()
{
$this->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');
}
}

View File

@ -1,5 +1,6 @@
<?php namespace Tests; <?php namespace Tests;
use BookStack\Entities\Entity;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@ -60,4 +61,20 @@ abstract class TestCase extends BaseTestCase
{ {
return TestResponse::fromBaseResponse($response); return TestResponse::fromBaseResponse($response);
} }
/**
* Assert that an activity entry exists of the given key.
* Checks the activity belongs to the given entity if provided.
*/
protected function assertActivityExists(string $key, Entity $entity = null)
{
$detailsToCheck = ['key' => $key];
if ($entity) {
$detailsToCheck['entity_type'] = $entity->getMorphClass();
$detailsToCheck['entity_id'] = $entity->id;
}
$this->assertDatabaseHas('activities', $detailsToCheck);
}
} }

38
tests/TestsApi.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace Tests;
trait TestsApi
{
protected $apiTokenId = 'apitoken';
protected $apiTokenSecret = 'password';
/**
* Set the API editor role as the current user via the API driver.
*/
protected function actingAsApiEditor()
{
$this->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}"
];
}
}

View File

@ -0,0 +1,185 @@
<?php namespace Test;
use BookStack\Api\ApiToken;
use Carbon\Carbon;
use Tests\TestCase;
class UserApiTokenTest extends TestCase
{
protected $testTokenData = [
'name' => '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]);
}
}