diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..97a9427c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [master, "feature/**"] + pull_request: + +jobs: + test: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 7.2 is the floor: polr's committed composer.lock pins doctrine/lexer + # 1.2.1 (requires >=7.2), so the locked deps won't install below 7.2. + php: ["7.2", "7.3"] + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: polrci + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_mysql, mbstring + tools: composer:v1 + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare test environment + run: | + cp tests/test_env .env + sed -i 's/^DB_HOST=.*/DB_HOST=127.0.0.1/' .env + + - name: Run migrations + run: php artisan migrate --force + + - name: Run test suite + run: vendor/bin/phpunit diff --git a/app/Http/Controllers/Api/ApiLinkController.php b/app/Http/Controllers/Api/ApiLinkController.php index b1f377c3c..cd2df3212 100644 --- a/app/Http/Controllers/Api/ApiLinkController.php +++ b/app/Http/Controllers/Api/ApiLinkController.php @@ -4,6 +4,8 @@ use App\Factories\LinkFactory; use App\Helpers\LinkHelper; +use App\Helpers\UserHelper; +use App\Models\Link; use App\Exceptions\Api\ApiException; class ApiLinkController extends ApiController { @@ -137,4 +139,178 @@ public function lookupLink(Request $request) { throw new ApiException('NOT_FOUND', 'Link not found.', 404, $response_type); } } + + /** + * Resolve the link for $url_ending and ensure the API user may manage it. + * Ownership mirrors AjaxController::editLinkLongUrl: a user may manage their + * own links; admins may manage any. Anonymous API users own nothing. + * + * @return \App\Models\Link + */ + protected function getOwnedLink($url_ending, $user, $response_type) { + if (!empty($user->anonymous)) { + throw new ApiException('ACCESS_DENIED', 'Anonymous API users cannot manage links.', 401, $response_type); + } + + $link = LinkHelper::linkExists($url_ending); + if (!$link) { + throw new ApiException('NOT_FOUND', 'Link not found.', 404, $response_type); + } + + if ($link->creator !== $user->username && !UserHelper::userIsAdmin($user->username)) { + throw new ApiException('ACCESS_DENIED', 'You do not have permission to manage this link.', 401, $response_type); + } + + return $link; + } + + public function listLinks(Request $request) { + $user = $request->user; + $response_type = $request->input('response_type'); + + if (!empty($user->anonymous)) { + throw new ApiException('ACCESS_DENIED', 'Anonymous API users cannot list links.', 401, $response_type); + } + + $query = Link::orderBy('created_at', 'desc'); + + // Non-admins may only see links they created. + if (!UserHelper::userIsAdmin($user->username)) { + $query = $query->where('creator', $user->username); + } + + // Optional case-insensitive substring filter on slug or destination. + $filter = $request->input('query'); + if ($filter !== null && $filter !== '') { + $query = $query->where(function ($q) use ($filter) { + $q->where('short_url', 'like', '%' . $filter . '%') + ->orWhere('long_url', 'like', '%' . $filter . '%'); + }); + } + + $links = []; + foreach ($query->get() as $link) { + $links[] = [ + 'short_url' => $link->short_url, + 'long_url' => $link->long_url, + 'clicks' => $link->clicks, + 'is_disabled' => (bool) $link->is_disabled, + 'is_secret' => $link->secret_key ? true : false, + 'created_at' => (string) $link->created_at, + ]; + } + + return self::encodeResponse(['links' => $links], 'list', $response_type); + } + + public function renameLink(Request $request) { + $user = $request->user; + $response_type = $request->input('response_type'); + + $validator = \Validator::make($request->all(), [ + 'url_ending' => 'required|alpha_dash', + 'new_ending' => 'required|alpha_dash', + ]); + if ($validator->fails()) { + throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type); + } + + $old_ending = $request->input('url_ending'); + $new_ending = $request->input('new_ending'); + + $link = $this->getOwnedLink($old_ending, $user, $response_type); + + if (!LinkHelper::validateEnding($new_ending)) { + throw new ApiException('CREATION_ERROR', + 'Custom endings can only contain alphanumeric characters, hyphens, and underscores.', 400, $response_type); + } + if ($new_ending === $old_ending) { + throw new ApiException('CREATION_ERROR', 'The new ending is identical to the current one.', 400, $response_type); + } + if (LinkHelper::linkExists($new_ending)) { + throw new ApiException('CREATION_ERROR', 'This URL ending is already in use.', 400, $response_type); + } + + $link->short_url = $new_ending; + $link->is_custom = 1; + $link->save(); + + $short_url = env('APP_PROTOCOL') . env('APP_ADDRESS') . '/' . $new_ending; + return self::encodeResponse([ + 'old_ending' => $old_ending, + 'new_ending' => $new_ending, + 'short_url' => $short_url, + 'long_url' => $link->long_url, + ], 'rename', $response_type, $short_url); + } + + public function updateLink(Request $request) { + $user = $request->user; + $response_type = $request->input('response_type'); + + $validator = \Validator::make(array_merge([ + 'long_url' => str_replace(' ', '%20', $request->input('long_url')) + ], $request->except('long_url')), [ + 'url_ending' => 'required|alpha_dash', + 'long_url' => 'required|url', + ]); + if ($validator->fails()) { + throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type); + } + + $url_ending = $request->input('url_ending'); + $long_url = $request->input('long_url'); + + $link = $this->getOwnedLink($url_ending, $user, $response_type); + + // setLongUrlAttribute recomputes the crc32 hash for us. + $link->long_url = $long_url; + $link->save(); + + return self::encodeResponse([ + 'short_url' => env('APP_PROTOCOL') . env('APP_ADDRESS') . '/' . $url_ending, + 'long_url' => $link->long_url, + ], 'update', $response_type, $link->long_url); + } + + public function toggleLink(Request $request) { + $user = $request->user; + $response_type = $request->input('response_type'); + + $validator = \Validator::make($request->all(), [ + 'url_ending' => 'required|alpha_dash', + ]); + if ($validator->fails()) { + throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type); + } + + $url_ending = $request->input('url_ending'); + $link = $this->getOwnedLink($url_ending, $user, $response_type); + + $link->is_disabled = $link->is_disabled ? 0 : 1; + $link->save(); + + return self::encodeResponse([ + 'url_ending' => $url_ending, + 'is_disabled' => (bool) $link->is_disabled, + ], 'toggle', $response_type, $link->is_disabled ? 'disabled' : 'enabled'); + } + + public function deleteLink(Request $request) { + $user = $request->user; + $response_type = $request->input('response_type'); + + $validator = \Validator::make($request->all(), [ + 'url_ending' => 'required|alpha_dash', + ]); + if ($validator->fails()) { + throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type); + } + + $url_ending = $request->input('url_ending'); + $link = $this->getOwnedLink($url_ending, $user, $response_type); + $link->delete(); + + return self::encodeResponse(['deleted' => $url_ending], 'delete', $response_type, 'OK'); + } } diff --git a/app/Http/routes.php b/app/Http/routes.php index e936cb10f..c9312606f 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -72,6 +72,13 @@ $app->post('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'ApiLinkController@lookupLink']); $app->get('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'ApiLinkController@lookupLink']); + /* API link management endpoints (own links, or any if admin) */ + $app->get('action/list', ['as' => 'api_list_links', 'uses' => 'ApiLinkController@listLinks']); + $app->post('action/rename', ['as' => 'api_rename_link', 'uses' => 'ApiLinkController@renameLink']); + $app->post('action/update', ['as' => 'api_update_link', 'uses' => 'ApiLinkController@updateLink']); + $app->post('action/toggle', ['as' => 'api_toggle_link', 'uses' => 'ApiLinkController@toggleLink']); + $app->post('action/delete', ['as' => 'api_delete_link', 'uses' => 'ApiLinkController@deleteLink']); + /* API data endpoints */ $app->get('data/link', ['as' => 'api_link_analytics', 'uses' => 'ApiAnalyticsController@lookupLinkStats']); $app->post('data/link', ['as' => 'api_link_analytics', 'uses' => 'ApiAnalyticsController@lookupLinkStats']); diff --git a/docs/developer-guide/api.md b/docs/developer-guide/api.md index bcd192716..d45db6737 100644 --- a/docs/developer-guide/api.md +++ b/docs/developer-guide/api.md @@ -160,6 +160,137 @@ Response: } ``` +### /api/v2/action/list +Lists the links you created. Admins receive every link. + +Arguments: + + - `query` (optional): case-insensitive substring filter, matched against both the + link ending and the destination URL. + +An API key granted to a regular user only lists their own links; admins list all links. + +Example: GET `http://example.com/api/v2/action/list?key=API_KEY_HERE&query=blog&response_type=json` + +Response: +``` +{ + "action": "list", + "result": { + "links": [ + { + "short_url": "blog", + "long_url": "https://example.com/my-blog", + "clicks": 12, + "is_disabled": false, + "is_secret": false, + "created_at": "2026-06-11 22:41:43" + } + ] + } +} +``` + +### /api/v2/action/rename + +_`POST` only_ + +Renames a link ending in place (the destination, clicks, and creation date are kept; +only the short URL changes). You may rename links you created; admins may rename any link. + +Arguments: + + - `url_ending`: the current link ending (e.g `5ga`) + - `new_ending`: the new link ending. Must be unused and may only contain + alphanumeric characters, hyphens, and underscores. + +Example: POST `http://example.com/api/v2/action/rename` with `key`, `url_ending=5ga`, `new_ending=my-blog` + +Response: +``` +{ + "action": "rename", + "result": { + "old_ending": "5ga", + "new_ending": "my-blog", + "short_url": "https://example.com/my-blog", + "long_url": "https://google.com" + } +} +``` + +### /api/v2/action/update + +_`POST` only_ + +Changes the destination (long URL) of an existing link. You may update links you +created; admins may update any link. + +Arguments: + + - `url_ending`: the link ending to update (e.g `5ga`) + - `long_url`: the new destination URL (must be URL encoded) + +Example: POST `http://example.com/api/v2/action/update` with `key`, `url_ending=5ga`, `long_url=https://example.org` + +Response: +``` +{ + "action": "update", + "result": { + "short_url": "https://example.com/5ga", + "long_url": "https://example.org" + } +} +``` + +### /api/v2/action/toggle + +_`POST` only_ + +Enables or disables a link without deleting it. A disabled link stops redirecting +but keeps its ending, destination, and stats. You may toggle links you created; +admins may toggle any link. + +Arguments: + + - `url_ending`: the link ending to enable/disable (e.g `5ga`) + +Example: POST `http://example.com/api/v2/action/toggle` with `key`, `url_ending=5ga` + +Response: +``` +{ + "action": "toggle", + "result": { + "url_ending": "5ga", + "is_disabled": true + } +} +``` + +### /api/v2/action/delete + +_`POST` only_ + +Permanently deletes a link. You may delete links you created; admins may delete any link. + +Arguments: + + - `url_ending`: the link ending to delete (e.g `5ga`) + +Example: POST `http://example.com/api/v2/action/delete` with `key`, `url_ending=5ga` + +Response: +``` +{ + "action": "delete", + "result": { + "deleted": "5ga" + } +} +``` + ### /api/v2/data/link Arguments: diff --git a/tests/ApiLinkManagementTest.php b/tests/ApiLinkManagementTest.php new file mode 100644 index 000000000..f948a42e5 --- /dev/null +++ b/tests/ApiLinkManagementTest.php @@ -0,0 +1,206 @@ +call($method, '/api/v2/action/' . $action, $params); + return [$response, json_decode($response->getContent(), true)]; + } + + /* ---------------- auth / ownership ---------------- */ + + public function testListRequiresKey() { + $response = $this->call('GET', '/api/v2/action/list', ['response_type' => 'json']); + $this->assertEquals(401, $response->getStatusCode()); + } + + public function testDeleteRequiresKey() { + $response = $this->call('POST', '/api/v2/action/delete', ['url_ending' => 'x', 'response_type' => 'json']); + $this->assertEquals(401, $response->getStatusCode()); + } + + public function testCannotManageOthersLink() { + $this->makeApiUser('alice'); + $this->makeApiUser('bob'); + $this->makeLink('bobslink', 'bob'); + + list($response, $json) = $this->apiCall('POST', 'delete', [ + 'key' => 'KEY-alice', 'url_ending' => 'bobslink', 'response_type' => 'json' + ]); + $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals('ACCESS_DENIED', $json['error_code']); + // bob's link untouched + $this->assertNotEquals(false, LinkHelper::linkExists('bobslink')); + } + + public function testAdminCanManageAnyLink() { + $this->makeApiUser('boss', 'admin'); + $this->makeApiUser('carol'); + $this->makeLink('carolslink', 'carol'); + + list($response, $json) = $this->apiCall('POST', 'delete', [ + 'key' => 'KEY-boss', 'url_ending' => 'carolslink', 'response_type' => 'json' + ]); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('carolslink', $json['result']['deleted']); + $this->assertEquals(false, LinkHelper::linkExists('carolslink')); + } + + /* ---------------- list ---------------- */ + + public function testListReturnsOnlyOwnLinks() { + $this->makeApiUser('dave'); + $this->makeApiUser('erin'); + $this->makeLink('dave1', 'dave'); + $this->makeLink('dave2', 'dave'); + $this->makeLink('erin1', 'erin'); + + list($response, $json) = $this->apiCall('GET', 'list', [ + 'key' => 'KEY-dave', 'response_type' => 'json' + ]); + $this->assertEquals(200, $response->getStatusCode()); + $slugs = array_map(function ($l) { return $l['short_url']; }, $json['result']['links']); + $this->assertContains('dave1', $slugs); + $this->assertContains('dave2', $slugs); + $this->assertNotContains('erin1', $slugs); + } + + public function testListFilter() { + $this->makeApiUser('fred'); + $this->makeLink('alpha-one', 'fred'); + $this->makeLink('beta-two', 'fred'); + + list($response, $json) = $this->apiCall('GET', 'list', [ + 'key' => 'KEY-fred', 'query' => 'alpha', 'response_type' => 'json' + ]); + $slugs = array_map(function ($l) { return $l['short_url']; }, $json['result']['links']); + $this->assertContains('alpha-one', $slugs); + $this->assertNotContains('beta-two', $slugs); + } + + /* ---------------- rename ---------------- */ + + public function testRenameSuccess() { + $this->makeApiUser('gina'); + $this->makeLink('oldslug', 'gina'); + + list($response, $json) = $this->apiCall('POST', 'rename', [ + 'key' => 'KEY-gina', 'url_ending' => 'oldslug', 'new_ending' => 'newslug', 'response_type' => 'json' + ]); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('newslug', $json['result']['new_ending']); + $this->assertNotEquals(false, LinkHelper::linkExists('newslug')); + $this->assertEquals(false, LinkHelper::linkExists('oldslug')); + } + + public function testRenameCollisionRejected() { + $this->makeApiUser('hugo'); + $this->makeLink('taken', 'hugo'); + $this->makeLink('mine', 'hugo'); + + list($response, $json) = $this->apiCall('POST', 'rename', [ + 'key' => 'KEY-hugo', 'url_ending' => 'mine', 'new_ending' => 'taken', 'response_type' => 'json' + ]); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals('CREATION_ERROR', $json['error_code']); + // both still exist + $this->assertNotEquals(false, LinkHelper::linkExists('mine')); + $this->assertNotEquals(false, LinkHelper::linkExists('taken')); + } + + public function testRenameInvalidEndingRejected() { + $this->makeApiUser('ivan'); + $this->makeLink('ivanslink', 'ivan'); + + list($response, $json) = $this->apiCall('POST', 'rename', [ + 'key' => 'KEY-ivan', 'url_ending' => 'ivanslink', 'new_ending' => 'bad ending!', 'response_type' => 'json' + ]); + $this->assertEquals(400, $response->getStatusCode()); + } + + /* ---------------- update ---------------- */ + + public function testUpdateLongUrlSuccess() { + $this->makeApiUser('jane'); + $this->makeLink('jslug', 'jane', 'http://old.example.com'); + + list($response, $json) = $this->apiCall('POST', 'update', [ + 'key' => 'KEY-jane', 'url_ending' => 'jslug', 'long_url' => 'http://new.example.com', 'response_type' => 'json' + ]); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('http://new.example.com', $json['result']['long_url']); + $link = LinkHelper::linkExists('jslug'); + $this->assertEquals('http://new.example.com', $link->long_url); + } + + public function testUpdateRejectsBadUrl() { + $this->makeApiUser('karl'); + $this->makeLink('kslug', 'karl'); + + list($response, $json) = $this->apiCall('POST', 'update', [ + 'key' => 'KEY-karl', 'url_ending' => 'kslug', 'long_url' => 'not-a-url', 'response_type' => 'json' + ]); + $this->assertEquals(400, $response->getStatusCode()); + } + + /* ---------------- toggle ---------------- */ + + public function testToggleDisablesThenEnables() { + $this->makeApiUser('lena'); + $this->makeLink('lslug', 'lena'); + + list($r1, $j1) = $this->apiCall('POST', 'toggle', [ + 'key' => 'KEY-lena', 'url_ending' => 'lslug', 'response_type' => 'json' + ]); + $this->assertEquals(200, $r1->getStatusCode()); + $this->assertEquals(true, $j1['result']['is_disabled']); + + list($r2, $j2) = $this->apiCall('POST', 'toggle', [ + 'key' => 'KEY-lena', 'url_ending' => 'lslug', 'response_type' => 'json' + ]); + $this->assertEquals(false, $j2['result']['is_disabled']); + } + + /* ---------------- delete ---------------- */ + + public function testDeleteSuccess() { + $this->makeApiUser('mike'); + $this->makeLink('mslug', 'mike'); + + list($response, $json) = $this->apiCall('POST', 'delete', [ + 'key' => 'KEY-mike', 'url_ending' => 'mslug', 'response_type' => 'json' + ]); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(false, LinkHelper::linkExists('mslug')); + } + + public function testDeleteNotFound() { + $this->makeApiUser('nina'); + list($response, $json) = $this->apiCall('POST', 'delete', [ + 'key' => 'KEY-nina', 'url_ending' => 'doesnotexist', 'response_type' => 'json' + ]); + $this->assertEquals(404, $response->getStatusCode()); + $this->assertEquals('NOT_FOUND', $json['error_code']); + } +}