Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
176 changes: 176 additions & 0 deletions app/Http/Controllers/Api/ApiLinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
}
}
7 changes: 7 additions & 0 deletions app/Http/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
131 changes: 131 additions & 0 deletions docs/developer-guide/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading