mirror of
https://github.com/cydrobolt/polr.git
synced 2024-09-19 23:21:47 +02:00
Merge branch 'master' into fix-toastr-error
This commit is contained in:
commit
2ee1e70eb1
40
app/Exceptions/Api/ApiException.php
Normal file
40
app/Exceptions/Api/ApiException.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class ApiException extends \Exception {
|
||||||
|
/**
|
||||||
|
* Catch an API exception.
|
||||||
|
*
|
||||||
|
* @param string $text_code
|
||||||
|
* @param string $message
|
||||||
|
* @param integer $status_code
|
||||||
|
* @param string $response_type
|
||||||
|
* @param \Exception $previous
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function __construct($text_code='SERVER_ERROR', $message, $status_code = 0, $response_type='plain_text', Exception $previous = null) {
|
||||||
|
$this->response_type = $response_type;
|
||||||
|
$this->text_code = $text_code;
|
||||||
|
parent::__construct($message, $status_code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeJsonResponse($status_code, $message, $text_code) {
|
||||||
|
$response = [
|
||||||
|
'status_code' => $status_code,
|
||||||
|
'error_code' => $text_code,
|
||||||
|
'error' => $message
|
||||||
|
];
|
||||||
|
|
||||||
|
return json_encode($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEncodedErrorMessage() {
|
||||||
|
if ($this->response_type == 'json') {
|
||||||
|
return $this->encodeJsonResponse($this->code, $this->message, $this->text_code);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return $this->code . ' ' . $this->message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
use Laravel\Lumen\Exceptions\Handler as ExceptionHandler;
|
use Laravel\Lumen\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Support\Facades\Response;
|
use Illuminate\Support\Facades\Response;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler {
|
class Handler extends ExceptionHandler {
|
||||||
/**
|
/**
|
||||||
* A list of the exception types that should not be reported.
|
* A list of the exception types that should not be reported.
|
||||||
@ -43,6 +45,7 @@ class Handler extends ExceptionHandler {
|
|||||||
if (env('APP_DEBUG') != true) {
|
if (env('APP_DEBUG') != true) {
|
||||||
// Render nice error pages if debug is off
|
// Render nice error pages if debug is off
|
||||||
if ($e instanceof NotFoundHttpException) {
|
if ($e instanceof NotFoundHttpException) {
|
||||||
|
// Handle 404 exceptions
|
||||||
if (env('SETTING_REDIRECT_404')) {
|
if (env('SETTING_REDIRECT_404')) {
|
||||||
// Redirect 404s to SETTING_INDEX_REDIRECT
|
// Redirect 404s to SETTING_INDEX_REDIRECT
|
||||||
return redirect()->to(env('SETTING_INDEX_REDIRECT'));
|
return redirect()->to(env('SETTING_INDEX_REDIRECT'));
|
||||||
@ -51,6 +54,7 @@ class Handler extends ExceptionHandler {
|
|||||||
return view('errors.404');
|
return view('errors.404');
|
||||||
}
|
}
|
||||||
if ($e instanceof HttpException) {
|
if ($e instanceof HttpException) {
|
||||||
|
// Handle HTTP exceptions thrown by public-facing controllers
|
||||||
$status_code = $e->getStatusCode();
|
$status_code = $e->getStatusCode();
|
||||||
$status_message = $e->getMessage();
|
$status_message = $e->getMessage();
|
||||||
|
|
||||||
@ -63,6 +67,20 @@ class Handler extends ExceptionHandler {
|
|||||||
return response(view('errors.generic', ['status_code' => $status_code, 'status_message' => $status_message]), $status_code);
|
return response(view('errors.generic', ['status_code' => $status_code, 'status_message' => $status_message]), $status_code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($e instanceof ApiException) {
|
||||||
|
// Handle HTTP exceptions thrown by API controllers
|
||||||
|
$status_code = $e->getCode();
|
||||||
|
$encoded_status_message = $e->getEncodedErrorMessage();
|
||||||
|
if ($e->response_type == 'json') {
|
||||||
|
return response($encoded_status_message, $status_code)
|
||||||
|
->header('Content-Type', 'application/json')
|
||||||
|
->header('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($encoded_status_message, $status_code)
|
||||||
|
->header('Content-Type', 'text/plain')
|
||||||
|
->header('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::render($request, $e);
|
return parent::render($request, $e);
|
||||||
|
@ -55,14 +55,14 @@ class LinkFactory {
|
|||||||
looks like a shortened URL.');
|
looks like a shortened URL.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$is_secret && !$custom_ending && (LinkHelper::longLinkExists($long_url) !== false)) {
|
if (!$is_secret && (!isset($custom_ending) || $custom_ending === '') && (LinkHelper::longLinkExists($long_url) !== false)) {
|
||||||
// if link is not specified as secret, is non-custom, and
|
// if link is not specified as secret, is non-custom, and
|
||||||
// already exists in Polr, lookup the value and return
|
// already exists in Polr, lookup the value and return
|
||||||
$existing_link = LinkHelper::longLinkExists($long_url);
|
$existing_link = LinkHelper::longLinkExists($long_url);
|
||||||
return self::formatLink($existing_link);
|
return self::formatLink($existing_link);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($custom_ending) {
|
if (isset($custom_ending) && $custom_ending !== '') {
|
||||||
// has custom ending
|
// has custom ending
|
||||||
$ending_conforms = LinkHelper::validateEnding($custom_ending);
|
$ending_conforms = LinkHelper::validateEnding($custom_ending);
|
||||||
if (!$ending_conforms) {
|
if (!$ending_conforms) {
|
||||||
|
78
app/Helpers/StatsHelper.php
Normal file
78
app/Helpers/StatsHelper.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Helpers;
|
||||||
|
use App\Models\Click;
|
||||||
|
use App\Models\Link;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StatsHelper {
|
||||||
|
function __construct($link_id, $left_bound, $right_bound) {
|
||||||
|
$this->link_id = $link_id;
|
||||||
|
$this->left_bound_parsed = Carbon::parse($left_bound);
|
||||||
|
$this->right_bound_parsed = Carbon::parse($right_bound);
|
||||||
|
|
||||||
|
if (!$this->left_bound_parsed->lte($this->right_bound_parsed)) {
|
||||||
|
// If left bound is not less than or equal to right bound
|
||||||
|
throw new \Exception('Invalid bounds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$days_diff = $this->left_bound_parsed->diffInDays($this->right_bound_parsed);
|
||||||
|
$max_days_diff = env('_ANALYTICS_MAX_DAYS_DIFF') ?: 365;
|
||||||
|
|
||||||
|
if ($days_diff > $max_days_diff) {
|
||||||
|
throw new \Exception('Bounds too broad.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBaseRows() {
|
||||||
|
/**
|
||||||
|
* Fetches base rows given left date bound, right date bound, and link id
|
||||||
|
*
|
||||||
|
* @param integer $link_id
|
||||||
|
* @param string $left_bound
|
||||||
|
* @param string $right_bound
|
||||||
|
*
|
||||||
|
* @return DB rows
|
||||||
|
*/
|
||||||
|
|
||||||
|
return DB::table('clicks')
|
||||||
|
->where('link_id', $this->link_id)
|
||||||
|
->where('created_at', '>=', $this->left_bound_parsed)
|
||||||
|
->where('created_at', '<=', $this->right_bound_parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDayStats() {
|
||||||
|
// Return stats by day from the last 30 days
|
||||||
|
// date => x
|
||||||
|
// clicks => y
|
||||||
|
$stats = $this->getBaseRows()
|
||||||
|
->select(DB::raw("DATE_FORMAT(created_at, '%Y-%m-%d') AS x, count(*) AS y"))
|
||||||
|
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m-%d')"))
|
||||||
|
->orderBy('x', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountryStats() {
|
||||||
|
$stats = $this->getBaseRows()
|
||||||
|
->select(DB::raw("country AS label, count(*) AS clicks"))
|
||||||
|
->groupBy('country')
|
||||||
|
->orderBy('clicks', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRefererStats() {
|
||||||
|
$stats = $this->getBaseRows()
|
||||||
|
->select(DB::raw("COALESCE(referer_host, 'Direct') as label, count(*) as clicks"))
|
||||||
|
->groupBy('referer_host')
|
||||||
|
->orderBy('clicks', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
@ -31,9 +31,8 @@ class UserHelper {
|
|||||||
return ctype_alnum($username);
|
return ctype_alnum($username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function validateEmail($email) {
|
public static function userIsAdmin($username) {
|
||||||
// TODO validate email here
|
return (self::getUserByUsername($username)->role == self::$USER_ROLES['admin']);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function checkCredentials($username, $password) {
|
public static function checkCredentials($username, $password) {
|
||||||
@ -57,7 +56,6 @@ class UserHelper {
|
|||||||
|
|
||||||
public static function resetRecoveryKey($username) {
|
public static function resetRecoveryKey($username) {
|
||||||
$recovery_key = CryptoHelper::generateRandomHex(50);
|
$recovery_key = CryptoHelper::generateRandomHex(50);
|
||||||
|
|
||||||
$user = self::getUserByUsername($username);
|
$user = self::getUserByUsername($username);
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
@ -72,7 +70,6 @@ class UserHelper {
|
|||||||
|
|
||||||
public static function userResetKeyCorrect($username, $recovery_key, $inactive=false) {
|
public static function userResetKeyCorrect($username, $recovery_key, $inactive=false) {
|
||||||
// Given a username and a recovery key, return true if they match.
|
// Given a username and a recovery key, return true if they match.
|
||||||
|
|
||||||
$user = self::getUserByUsername($username, $inactive);
|
$user = self::getUserByUsername($username, $inactive);
|
||||||
|
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
74
app/Http/Controllers/Api/ApiAnalyticsController.php
Normal file
74
app/Http/Controllers/Api/ApiAnalyticsController.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
use App\Helpers\LinkHelper;
|
||||||
|
use App\Helpers\UserHelper;
|
||||||
|
use App\Helpers\StatsHelper;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
|
||||||
|
class ApiAnalyticsController extends ApiController {
|
||||||
|
public function lookupLinkStats (Request $request, $stats_type=false) {
|
||||||
|
$user = $request->user;
|
||||||
|
$response_type = $request->input('response_type') ?: 'json';
|
||||||
|
|
||||||
|
if ($response_type != 'json') {
|
||||||
|
throw new ApiException('JSON_ONLY', 'Only JSON-encoded data is available for this endpoint.', 401, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = \Validator::make($request->all(), [
|
||||||
|
'url_ending' => 'required|alpha_dash',
|
||||||
|
'stats_type' => 'alpha_num',
|
||||||
|
'left_bound' => 'date',
|
||||||
|
'right_bound' => 'date'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url_ending = $request->input('url_ending');
|
||||||
|
$stats_type = $request->input('stats_type');
|
||||||
|
$left_bound = $request->input('left_bound');
|
||||||
|
$right_bound = $request->input('right_bound');
|
||||||
|
$stats_type = $request->input('stats_type');
|
||||||
|
|
||||||
|
// ensure user can only read own analytics or user is admin
|
||||||
|
$link = LinkHelper::linkExists($url_ending);
|
||||||
|
|
||||||
|
if ($link === false) {
|
||||||
|
throw new ApiException('NOT_FOUND', 'Link not found.', 404, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($link->creator != $user->username) &&
|
||||||
|
!(UserHelper::userIsAdmin($user->username))){
|
||||||
|
// If user does not own link and is not an admin
|
||||||
|
throw new ApiException('ACCESS_DENIED', 'Unauthorized.', 401, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stats = new StatsHelper($link->id, $left_bound, $right_bound);
|
||||||
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
|
throw new ApiException('ANALYTICS_ERROR', $e->getMessage(), 400, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stats_type == 'day') {
|
||||||
|
$fetched_stats = $stats->getDayStats();
|
||||||
|
}
|
||||||
|
else if ($stats_type == 'country') {
|
||||||
|
$fetched_stats = $stats->getCountryStats();
|
||||||
|
}
|
||||||
|
else if ($stats_type == 'referer') {
|
||||||
|
$fetched_stats = $stats->getRefererStats();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new ApiException('INVALID_ANALYTICS_TYPE', 'Invalid analytics type requested.', 400, $response_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::encodeResponse([
|
||||||
|
'url_ending' => $link->short_url,
|
||||||
|
'data' => $fetched_stats,
|
||||||
|
], 'data_link_' . $stats_type, $response_type, false);
|
||||||
|
}
|
||||||
|
}
|
@ -2,48 +2,8 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Helpers\ApiHelper;
|
|
||||||
|
|
||||||
class ApiController extends Controller {
|
class ApiController extends Controller {
|
||||||
protected static function getApiUserInfo(Request $request) {
|
|
||||||
$api_key = $request->input('key');
|
|
||||||
|
|
||||||
if (!$api_key) {
|
|
||||||
// no API key provided -- check whether anonymous API is on
|
|
||||||
|
|
||||||
if (env('SETTING_ANON_API')) {
|
|
||||||
$username = 'ANONIP-' . $request->ip();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
abort(401, "Authentication token required.");
|
|
||||||
}
|
|
||||||
$user = (object) [
|
|
||||||
'username' => $username
|
|
||||||
];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$user = User::where('active', 1)
|
|
||||||
->where('api_key', $api_key)
|
|
||||||
->where('api_active', 1)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
abort(401, "Invalid authentication token.");
|
|
||||||
}
|
|
||||||
$username = $user->username;
|
|
||||||
}
|
|
||||||
|
|
||||||
$api_limit_reached = ApiHelper::checkUserApiQuota($username);
|
|
||||||
|
|
||||||
if ($api_limit_reached) {
|
|
||||||
abort(403, "Quota exceeded.");
|
|
||||||
}
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function encodeResponse($result, $action, $response_type='json', $plain_text_response=false) {
|
protected static function encodeResponse($result, $action, $response_type='json', $plain_text_response=false) {
|
||||||
$response = [
|
$response = [
|
||||||
"action" => $action,
|
"action" => $action,
|
||||||
@ -64,7 +24,6 @@ class ApiController extends Controller {
|
|||||||
return response($result)
|
return response($result)
|
||||||
->header('Content-Type', 'text/plain')
|
->header('Content-Type', 'text/plain')
|
||||||
->header('Access-Control-Allow-Origin', '*');
|
->header('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,11 +4,12 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
use App\Factories\LinkFactory;
|
use App\Factories\LinkFactory;
|
||||||
use App\Helpers\LinkHelper;
|
use App\Helpers\LinkHelper;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
|
||||||
class ApiLinkController extends ApiController {
|
class ApiLinkController extends ApiController {
|
||||||
public function shortenLink(Request $request) {
|
public function shortenLink(Request $request) {
|
||||||
$response_type = $request->input('response_type');
|
$response_type = $request->input('response_type');
|
||||||
$user = self::getApiUserInfo($request);
|
$user = $request->user;
|
||||||
|
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
// Encode spaces as %20 to avoid validator conflicts
|
// Encode spaces as %20 to avoid validator conflicts
|
||||||
@ -19,7 +20,7 @@ class ApiLinkController extends ApiController {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return abort(400, 'Parameters invalid or missing.');
|
throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
$long_url = $request->input('url'); // * required
|
$long_url = $request->input('url'); // * required
|
||||||
@ -32,15 +33,15 @@ class ApiLinkController extends ApiController {
|
|||||||
$formatted_link = LinkFactory::createLink($long_url, $is_secret, $custom_ending, $link_ip, $user->username, false, true);
|
$formatted_link = LinkFactory::createLink($long_url, $is_secret, $custom_ending, $link_ip, $user->username, false, true);
|
||||||
}
|
}
|
||||||
catch (\Exception $e) {
|
catch (\Exception $e) {
|
||||||
abort(400, $e->getMessage());
|
throw new ApiException('CREATION_ERROR', $e->getMessage(), 400, $response_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::encodeResponse($formatted_link, 'shorten', $response_type);
|
return self::encodeResponse($formatted_link, 'shorten', $response_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function lookupLink(Request $request) {
|
public function lookupLink(Request $request) {
|
||||||
|
$user = $request->user;
|
||||||
$response_type = $request->input('response_type');
|
$response_type = $request->input('response_type');
|
||||||
$user = self::getApiUserInfo($request);
|
|
||||||
|
|
||||||
// Validate URL form data
|
// Validate URL form data
|
||||||
$validator = \Validator::make($request->all(), [
|
$validator = \Validator::make($request->all(), [
|
||||||
@ -48,7 +49,7 @@ class ApiLinkController extends ApiController {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return abort(400, 'Parameters invalid or missing.');
|
throw new ApiException('MISSING_PARAMETERS', 'Invalid or missing parameters.', 400, $response_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url_ending = $request->input('url_ending');
|
$url_ending = $request->input('url_ending');
|
||||||
@ -60,7 +61,7 @@ class ApiLinkController extends ApiController {
|
|||||||
|
|
||||||
if ($link['secret_key']) {
|
if ($link['secret_key']) {
|
||||||
if ($url_key != $link['secret_key']) {
|
if ($url_key != $link['secret_key']) {
|
||||||
abort(401, "Invalid URL code for secret URL.");
|
throw new ApiException('ACCESS_DENIED', 'Invalid URL code for secret URL.', 401, $response_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,8 +75,7 @@ class ApiLinkController extends ApiController {
|
|||||||
], 'lookup', $response_type, $link['long_url']);
|
], 'lookup', $response_type, $link['long_url']);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
abort(404, "Link not found.");
|
throw new ApiException('NOT_FOUND', 'Link not found.', 404, $response_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,53 +6,17 @@ use Carbon\Carbon;
|
|||||||
|
|
||||||
use App\Models\Link;
|
use App\Models\Link;
|
||||||
use App\Models\Clicks;
|
use App\Models\Clicks;
|
||||||
|
use App\Helpers\StatsHelper;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class StatsController extends Controller {
|
class StatsController extends Controller {
|
||||||
const DAYS_TO_FETCH = 30;
|
const DAYS_TO_FETCH = 30;
|
||||||
|
|
||||||
private function getBaseRows($link_id) {
|
|
||||||
// Get past month rows
|
|
||||||
return DB::table('clicks')
|
|
||||||
->where('link_id', $link_id)
|
|
||||||
->where('created_at', '>=', Carbon::now()->subDays(self::DAYS_TO_FETCH));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getDayStats($link_id) {
|
|
||||||
// Return stats by day from the last 30 days
|
|
||||||
// date => x
|
|
||||||
// clicks => y
|
|
||||||
$stats = $this->getBaseRows($link_id)
|
|
||||||
->select(DB::raw("DATE_FORMAT(created_at, '%Y-%m-%d') AS x, count(*) AS y"))
|
|
||||||
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m-%d')"))
|
|
||||||
->orderBy('x', 'asc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getCountryStats($link_id) {
|
|
||||||
$stats = $this->getBaseRows($link_id)
|
|
||||||
->select(DB::raw("country AS label, count(*) AS clicks"))
|
|
||||||
->groupBy('country')
|
|
||||||
->orderBy('clicks', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getRefererStats($link_id) {
|
|
||||||
$stats = $this->getBaseRows($link_id)
|
|
||||||
->select(DB::raw("COALESCE(referer_host, 'Direct') as label, count(*) as clicks"))
|
|
||||||
->groupBy('referer_host')
|
|
||||||
->orderBy('clicks', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function displayStats(Request $request, $short_url) {
|
public function displayStats(Request $request, $short_url) {
|
||||||
|
// Carbon bounds for StatHelper
|
||||||
|
$left_bound = Carbon::now()->subDays(self::DAYS_TO_FETCH);
|
||||||
|
$right_bound = Carbon::now();
|
||||||
|
|
||||||
if (!$this->isLoggedIn()) {
|
if (!$this->isLoggedIn()) {
|
||||||
return redirect(route('login'))->with('error', 'Please login to view link stats.');
|
return redirect(route('login'))->with('error', 'Please login to view link stats.');
|
||||||
}
|
}
|
||||||
@ -74,9 +38,12 @@ class StatsController extends Controller {
|
|||||||
return redirect(route('admin'))->with('error', 'You do not have permission to view stats for this link.');
|
return redirect(route('admin'))->with('error', 'You do not have permission to view stats for this link.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$day_stats = $this->getDayStats($link_id);
|
// Fetch base rows for StatHelper
|
||||||
$country_stats = $this->getCountryStats($link_id);
|
$stats = new StatsHelper($link_id, $left_bound, $right_bound);
|
||||||
$referer_stats = $this->getRefererStats($link_id);
|
|
||||||
|
$day_stats = $stats->getDayStats();
|
||||||
|
$country_stats = $stats->getCountryStats();
|
||||||
|
$referer_stats = $stats->getRefererStats();
|
||||||
|
|
||||||
return view('link_stats', [
|
return view('link_stats', [
|
||||||
'link' => $link,
|
'link' => $link,
|
||||||
|
@ -78,12 +78,6 @@ class UserController extends Controller {
|
|||||||
return redirect(route('signup'))->with('error', 'Sorry, your email or username already exists. Try again.');
|
return redirect(route('signup'))->with('error', 'Sorry, your email or username already exists. Try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$email_valid = UserHelper::validateEmail($email);
|
|
||||||
|
|
||||||
if ($email_valid == false) {
|
|
||||||
return redirect(route('signup'))->with('error', 'Please use a valid email to sign up.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$acct_activation_needed = env('POLR_ACCT_ACTIVATION');
|
$acct_activation_needed = env('POLR_ACCT_ACTIVATION');
|
||||||
|
|
||||||
if ($acct_activation_needed == false) {
|
if ($acct_activation_needed == false) {
|
||||||
|
62
app/Http/Middleware/ApiMiddleware.php
Normal file
62
app/Http/Middleware/ApiMiddleware.php
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Helpers\ApiHelper;
|
||||||
|
use App\Exceptions\Api\ApiException;
|
||||||
|
|
||||||
|
class ApiMiddleware {
|
||||||
|
protected static function getApiUserInfo(Request $request) {
|
||||||
|
$api_key = $request->input('key');
|
||||||
|
$response_type = $request->input('response_type');
|
||||||
|
|
||||||
|
if (!$api_key) {
|
||||||
|
// no API key provided; check whether anonymous API is enabled
|
||||||
|
|
||||||
|
if (env('SETTING_ANON_API')) {
|
||||||
|
$username = 'ANONIP-' . $request->ip();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new ApiException('AUTH_ERROR', 'Authentication token required.', 401, $response_type);
|
||||||
|
}
|
||||||
|
$user = (object) [
|
||||||
|
'username' => $username
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$user = User::where('active', 1)
|
||||||
|
->where('api_key', $api_key)
|
||||||
|
->where('api_active', 1)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
throw new ApiException('AUTH_ERROR', 'Authentication token required.', 401, $response_type);
|
||||||
|
}
|
||||||
|
$username = $user->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_limit_reached = ApiHelper::checkUserApiQuota($username);
|
||||||
|
|
||||||
|
if ($api_limit_reached) {
|
||||||
|
throw new ApiException('QUOTA_EXCEEDED', 'Quota exceeded.', 429, $response_type);
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @param \Closure $next
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function handle($request, Closure $next) {
|
||||||
|
$request->user = $this->getApiUserInfo($request);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
@ -11,7 +11,7 @@ class VerifyCsrfToken extends BaseVerifier
|
|||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
public function handle($request, \Closure $next) {
|
public function handle($request, \Closure $next) {
|
||||||
if ($request->is('api/v*/action/*')) {
|
if ($request->is('api/v*/action/*') || $request->is('api/v*/data/*')) {
|
||||||
// Exclude public API from CSRF protection
|
// Exclude public API from CSRF protection
|
||||||
// but do not exclude private API endpoints
|
// but do not exclude private API endpoints
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
@ -17,7 +17,7 @@ if (env('POLR_ALLOW_ACCT_CREATION')) {
|
|||||||
$app->get('/', ['as' => 'index', 'uses' => 'IndexController@showIndexPage']);
|
$app->get('/', ['as' => 'index', 'uses' => 'IndexController@showIndexPage']);
|
||||||
$app->get('/logout', ['as' => 'logout', 'uses' => 'UserController@performLogoutUser']);
|
$app->get('/logout', ['as' => 'logout', 'uses' => 'UserController@performLogoutUser']);
|
||||||
$app->get('/login', ['as' => 'login', 'uses' => 'UserController@displayLoginPage']);
|
$app->get('/login', ['as' => 'login', 'uses' => 'UserController@displayLoginPage']);
|
||||||
$app->get('/about', ['as' => 'about', 'uses' => 'StaticPageController@displayAbout']);
|
$app->get('/about-polr', ['as' => 'about', 'uses' => 'StaticPageController@displayAbout']);
|
||||||
|
|
||||||
$app->get('/lost_password', ['as' => 'lost_password', 'uses' => 'UserController@displayLostPasswordPage']);
|
$app->get('/lost_password', ['as' => 'lost_password', 'uses' => 'UserController@displayLostPasswordPage']);
|
||||||
$app->get('/activate/{username}/{recovery_key}', ['as' => 'activate', 'uses' => 'UserController@performActivation']);
|
$app->get('/activate/{username}/{recovery_key}', ['as' => 'activate', 'uses' => 'UserController@performActivation']);
|
||||||
@ -59,13 +59,18 @@ $app->group(['prefix' => '/api/v2', 'namespace' => 'App\Http\Controllers'], func
|
|||||||
$app->get('admin/get_admin_users', ['as' => 'api_get_admin_users', 'uses' => 'AdminPaginationController@paginateAdminUsers']);
|
$app->get('admin/get_admin_users', ['as' => 'api_get_admin_users', 'uses' => 'AdminPaginationController@paginateAdminUsers']);
|
||||||
$app->get('admin/get_admin_links', ['as' => 'api_get_admin_links', 'uses' => 'AdminPaginationController@paginateAdminLinks']);
|
$app->get('admin/get_admin_links', ['as' => 'api_get_admin_links', 'uses' => 'AdminPaginationController@paginateAdminLinks']);
|
||||||
$app->get('admin/get_user_links', ['as' => 'api_get_user_links', 'uses' => 'AdminPaginationController@paginateUserLinks']);
|
$app->get('admin/get_user_links', ['as' => 'api_get_user_links', 'uses' => 'AdminPaginationController@paginateUserLinks']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$app->group(['prefix' => '/api/v2', 'namespace' => 'App\Http\Controllers\Api', 'middleware' => 'api'], function ($app) {
|
||||||
/* API shorten endpoints */
|
/* API shorten endpoints */
|
||||||
$app->post('action/shorten', ['as' => 'api_shorten_url', 'uses' => 'Api\ApiLinkController@shortenLink']);
|
$app->post('action/shorten', ['as' => 'api_shorten_url', 'uses' => 'ApiLinkController@shortenLink']);
|
||||||
$app->get('action/shorten', ['as' => 'api_shorten_url', 'uses' => 'Api\ApiLinkController@shortenLink']);
|
$app->get('action/shorten', ['as' => 'api_shorten_url', 'uses' => 'ApiLinkController@shortenLink']);
|
||||||
|
|
||||||
/* API lookup endpoints */
|
/* API lookup endpoints */
|
||||||
$app->post('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'Api\ApiLinkController@lookupLink']);
|
$app->post('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'ApiLinkController@lookupLink']);
|
||||||
$app->get('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'Api\ApiLinkController@lookupLink']);
|
$app->get('action/lookup', ['as' => 'api_lookup_url', 'uses' => 'ApiLinkController@lookupLink']);
|
||||||
|
|
||||||
|
/* 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']);
|
||||||
});
|
});
|
||||||
|
@ -61,12 +61,12 @@ $app->middleware([
|
|||||||
// Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
// Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
Illuminate\Session\Middleware\StartSession::class,
|
Illuminate\Session\Middleware\StartSession::class,
|
||||||
Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
App\Http\Middleware\VerifyCsrfToken::class
|
App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// $app->routeMiddleware([
|
$app->routeMiddleware([
|
||||||
|
'api' => App\Http\Middleware\ApiMiddleware::class,
|
||||||
// ]);
|
]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
@ -11,3 +11,9 @@ img {
|
|||||||
width: auto !important;
|
width: auto !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code:not(.hljs) {
|
||||||
|
/* Do not wrap pre-formatted code snippets */
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
@ -29,6 +29,9 @@ The Polr API will reply in `plain_text` or `json`. The response type can be
|
|||||||
set by providing the `response_type` argument to the request. If not provided,
|
set by providing the `response_type` argument to the request. If not provided,
|
||||||
the response type will default to `plain_text`.
|
the response type will default to `plain_text`.
|
||||||
|
|
||||||
|
Data endpoints will only return JSON-formatted data and will default to `json` if no
|
||||||
|
`response_type` is provided.
|
||||||
|
|
||||||
Example `json` responses:
|
Example `json` responses:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@ -72,6 +75,7 @@ Arguments:
|
|||||||
Response: A JSON or plain text representation of the shortened URL.
|
Response: A JSON or plain text representation of the shortened URL.
|
||||||
|
|
||||||
Example: GET `http://example.com/api/v2/action/shorten?key=API_KEY_HERE&url=https://google.com&custom_ending=CUSTOM_ENDING&is_secret=false`
|
Example: GET `http://example.com/api/v2/action/shorten?key=API_KEY_HERE&url=https://google.com&custom_ending=CUSTOM_ENDING&is_secret=false`
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@ -95,6 +99,7 @@ Arguments:
|
|||||||
Remember that the `url` argument must be URL encoded.
|
Remember that the `url` argument must be URL encoded.
|
||||||
|
|
||||||
Example: GET `http://example.com/api/v2/action/lookup?key=API_KEY_HERE&url_ending=2`
|
Example: GET `http://example.com/api/v2/action/lookup?key=API_KEY_HERE&url_ending=2`
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
@ -103,6 +108,77 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### /api/v2/data/link
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
- `url_ending`: the link ending for the URL to look up. (e.g `5ga`)
|
||||||
|
- `left_bound`: left date bound (e.g `2017-02-28 22:41:43`)
|
||||||
|
- `right_bound`: right date bound (e.g `2017-03-13 22:41:43`)
|
||||||
|
- `stats_type`: the type of data to fetch
|
||||||
|
- `day`: click counts for each day from `left_bound` to `right_bound`
|
||||||
|
- `country`: click counts per country
|
||||||
|
- `referer`: click counts per referer
|
||||||
|
|
||||||
|
The dates must be formatted for the `strtotime` PHP function and must be parsable by Carbon.
|
||||||
|
By default, this API endpoint will only allow users to fetch a maximum of 365 days of data. This setting
|
||||||
|
can be modified in the `.env` configuration file.
|
||||||
|
|
||||||
|
An API key granted to a regular user can only fetch data for their own links.
|
||||||
|
Admins can fetch data for any link.
|
||||||
|
|
||||||
|
Response: A JSON representation of the requested analytics data.
|
||||||
|
|
||||||
|
Example: GET `http://example.com/api/v2/data/link?stats_type=day&key=API_KEY_HERE&url_ending=5gk&response_type=json&left_bound=2017-02-28%2022:41:43&right_bound=2017-03-13%2022:21:43`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"action":"data_link_day",
|
||||||
|
"result": {
|
||||||
|
"url_ending":"5gk",
|
||||||
|
"data": [
|
||||||
|
{"x":"2017-03-10","y":42},
|
||||||
|
{"x":"2017-03-11","y":1},
|
||||||
|
{"x":"2017-03-12","y":5}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: GET `http://example.com/api/v2/data/link?stats_type=country&key=API_KEY_HERE&url_ending=5gk&response_type=json&left_bound=2017-02-28%2022:41:43&right_bound=2017-03-13%2022:21:43`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"action":"data_link_day",
|
||||||
|
"result": {
|
||||||
|
"url_ending":"5gk",
|
||||||
|
"data": [
|
||||||
|
{"label":"FR","clicks":1},
|
||||||
|
{"label":"US","clicks":6},
|
||||||
|
{"label":"CA","clicks":41}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: GET `http://example.com/api/v2/data/link?stats_type=country&key=API_KEY_HERE&url_ending=5gk&response_type=json&left_bound=2017-02-28%2022:41:43&right_bound=2017-03-13%2022:21:43`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"action":"data_link_day",
|
||||||
|
"result": {
|
||||||
|
"url_ending":"5gk",
|
||||||
|
"data": [
|
||||||
|
{"label":"Direct","clicks":6},
|
||||||
|
{"label":"reddit.com","clicks":12},
|
||||||
|
{"label":"facebook.com","clicks":30}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## HTTP Error Codes
|
## HTTP Error Codes
|
||||||
The API will return an error code if your request was malformed or another error occured while processing your request.
|
The API will return an error code if your request was malformed or another error occured while processing your request.
|
||||||
|
|
||||||
@ -142,14 +218,17 @@ This status code is returned in the following circumstances:
|
|||||||
Example `json` error response:
|
Example `json` error response:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
"error": "custom ending already in use"
|
"status_code":429,
|
||||||
|
"error_code":"QUOTA_EXCEEDED",
|
||||||
|
"error":"Quota exceeded."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example `plain_text` error response:
|
Example `plain_text` error response:
|
||||||
|
|
||||||
`custom ending already in use`
|
`429 Quota exceeded.`
|
||||||
|
|
||||||
## Testing the API
|
## Testing the API
|
||||||
|
|
||||||
You may test your integrations on http://demo.polr.me with the credentials "demo-admin"/"demo-admin". Keep in mind the instance is only a demo and may be cleared at any time.
|
You may test your integrations on http://demo.polr.me with the credentials "demo-admin"/"demo-admin".
|
||||||
|
The demo instance is reset every day.
|
||||||
|
23
docs/developer-guide/api_errors.md
Normal file
23
docs/developer-guide/api_errors.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
## API Error Text Codes
|
||||||
|
To diagnose an unexpected or unhandled error, turn on the `APP_DEBUG` flag by setting
|
||||||
|
it to `true` in `.env`
|
||||||
|
|
||||||
|
`SERVER_ERROR`: A generic, unhandled error has occured.
|
||||||
|
|
||||||
|
`JSON_ONLY`: Only JSON-encoded data is available for this endpoint.
|
||||||
|
|
||||||
|
`MISSING_PARAMETERS`: Invalid or missing parameters.
|
||||||
|
|
||||||
|
`NOT_FOUND`: Object not found.
|
||||||
|
|
||||||
|
`ACCESS_DENIED`: User is not authorized to access the object.
|
||||||
|
|
||||||
|
`INVALID_ANALYTICS_TYPE`: Invalid analytics type requested.
|
||||||
|
|
||||||
|
`CREATION_ERROR`: An error occurred while creating the object.
|
||||||
|
|
||||||
|
`AUTH_ERROR`: An error occured while attempting to authenticate the user to the API.
|
||||||
|
|
||||||
|
`QUOTA_EXCEEDED`: User's API usage has exceeded alloted quota.
|
||||||
|
|
||||||
|
`ANALYTICS_ERROR`: Invalid bounds or unexpected error while fetching analytics data.
|
@ -31,7 +31,7 @@ $ sudo su
|
|||||||
# switch to Polr directory (replace with other directory path if applicable)
|
# switch to Polr directory (replace with other directory path if applicable)
|
||||||
$ cd /var/www
|
$ cd /var/www
|
||||||
# clone Polr
|
# clone Polr
|
||||||
$ git clone https://github.com/cydrobolt/polr.git
|
$ git clone https://github.com/cydrobolt/polr.git --depth=1
|
||||||
# set correct permissions
|
# set correct permissions
|
||||||
$ chmod -R 755 polr
|
$ chmod -R 755 polr
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ pages:
|
|||||||
- Developer Guide:
|
- Developer Guide:
|
||||||
- 'Libraries': 'developer-guide/libraries.md'
|
- 'Libraries': 'developer-guide/libraries.md'
|
||||||
- 'API Documentation': 'developer-guide/api.md'
|
- 'API Documentation': 'developer-guide/api.md'
|
||||||
|
- 'API Errors': 'developer-guide/api_errors.md'
|
||||||
- About:
|
- About:
|
||||||
- 'License': 'about/license.md'
|
- 'License': 'about/license.md'
|
||||||
- 'Contributors': 'about/contributors.md'
|
- 'Contributors': 'about/contributors.md'
|
||||||
|
@ -99,6 +99,7 @@ SESSION_DRIVER=file
|
|||||||
QUEUE_DRIVER=database
|
QUEUE_DRIVER=database
|
||||||
|
|
||||||
_API_KEY_LENGTH=15
|
_API_KEY_LENGTH=15
|
||||||
|
_ANALYTICS_MAX_DAYS_DIFF=365
|
||||||
_PSEUDO_RANDOM_KEY_LENGTH=5
|
_PSEUDO_RANDOM_KEY_LENGTH=5
|
||||||
|
|
||||||
# FILESYSTEM_DRIVER=local
|
# FILESYSTEM_DRIVER=local
|
||||||
|
Loading…
Reference in New Issue
Block a user