Merge pull request #27 from creative-commoners/pulls/2.0/update-for-four

This commit is contained in:
Robbie Averill 2017-11-27 19:41:26 +13:00 committed by GitHub
commit 789ce91e1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1059 additions and 937 deletions

1
.gitattributes vendored
View File

@ -4,3 +4,4 @@
/.gitignore export-ignore
/.travis.yml export-ignore
/.scrutinizer.yml export-ignore
/codecov.yml export-ignore

View File

@ -1,69 +1,15 @@
inherit: true
build:
nodes:
analysis:
tests:
override: [php-scrutinizer-run]
checks:
php:
verify_property_names: true
verify_argument_usable_as_reference: true
verify_access_scope_valid: true
useless_calls: true
use_statement_alias_conflict: true
variable_existence: true
unused_variables: true
unused_properties: true
unused_parameters: true
unused_methods: true
unreachable_code: true
too_many_arguments: true
sql_injection_vulnerabilities: true
simplify_boolean_return: true
side_effects_or_types: true
security_vulnerabilities: true
return_doc_comments: true
return_doc_comment_if_not_inferrable: true
require_scope_for_properties: true
require_scope_for_methods: true
require_php_tag_first: true
psr2_switch_declaration: true
psr2_class_declaration: true
property_assignments: true
prefer_while_loop_over_for_loop: true
precedence_mistakes: true
precedence_in_conditions: true
phpunit_assertions: true
php5_style_constructor: true
parse_doc_comments: true
parameter_non_unique: true
parameter_doc_comments: true
param_doc_comment_if_not_inferrable: true
optional_parameters_at_the_end: true
one_class_per_file: true
no_unnecessary_if: true
no_trailing_whitespace: true
no_property_on_interface: true
no_non_implemented_abstract_methods: true
no_error_suppression: true
no_duplicate_arguments: true
no_commented_out_code: true
newline_at_end_of_file: true
missing_arguments: true
method_calls_on_non_object: true
instanceof_class_exists: true
foreach_traversable: true
fix_line_ending: true
fix_doc_comments: true
duplication: true
deprecated_code_usage: true
deadlock_detection_in_loops: true
code_rating: true
closure_use_not_conflicting: true
catch_class_exists: true
blank_line_after_namespace_declaration: false
avoid_multiple_statements_on_same_line: true
avoid_duplicate_types: true
avoid_conflicting_incrementers: true
avoid_closing_tag: true
assignment_of_null_return: true
argument_type_checks: true
duplication: true
filter:
paths: [code/*, tests/*]
paths: [src/*, tests/*]

View File

@ -1,34 +1,35 @@
# See https://github.com/silverstripe/silverstripe-travis-support for setup details
sudo: false
language: php
php:
- 5.3
- 5.4
- 5.5
env:
- DB=MYSQL CORE_RELEASE=3.5
global:
- COMPOSER_ROOT_VERSION=2.0.x-dev
matrix:
include:
- php: 5.6
env: DB=MYSQL CORE_RELEASE=3
- php: 5.6
env: DB=MYSQL CORE_RELEASE=3.1
- php: 5.6
env: DB=PGSQL CORE_RELEASE=3.2
env: DB=MYSQL PHPCS_TEST=1 PHPUNIT_TEST=1
- php: 7.0
env: DB=MYSQL PHPUNIT_TEST=1
- php: 7.1
env: DB=MYSQL CORE_RELEASE=3.6
env: DB=PGSQL PHPUNIT_COVERAGE_TEST=1
- php: 7.2
env: DB=MYSQL PHPUNIT_TEST=1
before_script:
- composer self-update || true
- git clone git://github.com/silverstripe/silverstripe-travis-support.git ~/travis-support
- php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss
- cd ~/builds/ss
- composer install
# Init PHP
- phpenv rehash
- phpenv config-rm xdebug.ini
# Install composer dependencies
- composer validate
- composer require --no-update silverstripe/installer 4.0.x-dev
- if [[ $DB == PGSQL ]]; then composer require --no-update silverstripe/postgresql 2.0.x-dev; fi
- composer install --prefer-dist --no-interaction --no-progress --no-suggest --optimize-autoloader --verbose --profile
script:
- vendor/bin/phpunit externallinks/tests
- if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit; fi
- if [[ $PHPUNIT_COVERAGE_TEST ]]; then phpdbg -qrr vendor/bin/phpunit --coverage-clover=coverage.xml; fi
- if [[ $PHPCS_TEST ]]; then vendor/bin/phpcs src/ tests/ *.php; fi
after_success:
- if [[ $PHPUNIT_COVERAGE_TEST ]]; then bash <(curl -s https://codecov.io/bash) -f coverage.xml; fi

10
.upgrade.yml Normal file
View File

@ -0,0 +1,10 @@
mappings:
CMSExternalLinks_Controller: SilverStripe\ExternalLinks\Controllers\CMSExternalLinksController
CheckExternalLinksJob: SilverStripe\ExternalLinks\Jobs\CheckExternalLinksJob
BrokenExternalLink: SilverStripe\ExternalLinks\Model\BrokenExternalLink
BrokenExternalPageTrack: SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack
BrokenExternalPageTrackStatus: SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus
BrokenExternalLinksReport: SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport
CheckExternalLinksTask: SilverStripe\ExternalLinks\Tasks\CheckExternalLinksTask
CurlLinkChecker: SilverStripe\ExternalLinks\Tasks\CurlLinkChecker
LinkChecker: SilverStripe\ExternalLinks\Tasks\LinkChecker

View File

@ -1,6 +1,8 @@
# External links
[![Build Status](https://travis-ci.org/silverstripe/silverstripe-externallinks.svg?branch=master)](https://travis-ci.org/silverstripe/silverstripe-externallinks)
[![Build Status](http://img.shields.io/travis/silverstripe/silverstripe-externallinks.svg?style=flat)](https://travis-ci.org/silverstripe/silverstripe-externallinks)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/silverstripe/silverstripe-externallinks/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/silverstripe/silverstripe-externallinks/?branch=master)
[![codecov](https://codecov.io/gh/silverstripe/silverstripe-externallinks/branch/master/graph/badge.svg)](https://codecov.io/gh/silverstripe/silverstripe-externallinks)
## Introduction
@ -12,21 +14,19 @@ The external links module is a task and ModelAdmin to track and to report on bro
## Requirements
* SilverStripe 3.1 +
* SilverStripe ^4.0
**Note:** For a SilverStripe 3.x compatible version, please use [the 1.x release line](https://github.com/silverstripe/silverstripe-externallinks/tree/1.0).
## Features
* Add external links to broken links reports
* Add a task to track external broken links
See the [changelog](CHANGELOG.md) for version history.
## Installation
1. If you have composer you can use `composer require silverstripe/externallinks:*`. Otherwise,
download the module from GitHub and extract to the 'externallinks' folder. Place this directory
in your sites root directory. This is the one with framework and cms in it.
2. Run in your browser - `/dev/build` to rebuild the database.
1. Require the module via composer: `composer require silverstripe/externallinks`
2. Run `/dev/build` in your browser to rebuild the database.
3. Run the following task *http://path.to.silverstripe/dev/tasks/CheckExternalLinks* to check for
broken external links
@ -63,20 +63,17 @@ broken links.
## Queued job ##
If you have the queuedjobs module installed you can set the task to be run every so ofter
Add the following yml config to config.yml in mysite/_config have the the task run once every day (86400 seconds)
CheckExternalLinks:
Delay: 86400
If you have the queuedjobs module installed you can set the task to be run every so often.
## Whitelisting codes ##
If you want to ignore or whitelist certain http codes this can be setup via IgnoreCodes in the config.yml
file in mysite/_config
file in `mysite/_config`
CheckExternalLinks:
Delay: 60
```yml
SilverStripe\ExternalLinks\Tasks\CheckExternalLinksTask:
IgnoreCodes:
- 401
- 403
- 501
```

View File

@ -1,5 +1,9 @@
---
Name: externallinksdependencies
---
Injector:
LinkChecker: CurlLinkChecker
SilverStripe\Core\Injector\Injector:
SilverStripe\ExternalLinks\Tasks\LinkChecker: SilverStripe\ExternalLinks\Tasks\CurlLinkChecker
Psr\SimpleCache\CacheInterface.CurlLinkChecker:
factory: SilverStripe\Core\Cache\CacheFactory
constructor:
namespace: 'curllinkchecker'

View File

@ -1,7 +1,7 @@
---
Name: externallink
After: framework/routes
Name: externallinkroutes
Before: '#adminroutes'
---
Director:
SilverStripe\Control\Director:
rules:
'admin/externallinks//$Action': 'CMSExternalLinks_Controller'
'admin/externallinks//$Action': SilverStripe\ExternalLinks\Controllers\CMSExternalLinksController

View File

@ -1,54 +0,0 @@
<?php
class CMSExternalLinks_Controller extends Controller {
private static $allowed_actions = array('getJobStatus', 'start');
/*
* Respond to Ajax requests for info on a running job
*
* @return string JSON string detailing status of the job
*/
public function getJobStatus() {
// Set headers
HTTP::set_cache_age(0);
HTTP::add_cache_headers($this->response);
$this->response
->addHeader('Content-Type', 'application/json')
->addHeader('Content-Encoding', 'UTF-8')
->addHeader('X-Content-Type-Options', 'nosniff');
// Format status
$track = BrokenExternalPageTrackStatus::get_latest();
if($track) return json_encode(array(
'TrackID' => $track->ID,
'Status' => $track->Status,
'Completed' => $track->getCompletedPages(),
'Total' => $track->getTotalPages()
));
}
/*
* Starts a broken external link check
*/
public function start() {
// return if the a job is already running
$status = BrokenExternalPageTrackStatus::get_latest();
if ($status && $status->Status == 'Running') return;
// Create a new job
if (class_exists('QueuedJobService')) {
// Force the creation of a new run
BrokenExternalPageTrackStatus::create_status();
$checkLinks = new CheckExternalLinksJob();
singleton('QueuedJobService')->queueJob($checkLinks);
} else {
//TODO this hangs as it waits for the connection to be released
// should return back and continue processing
// http://us3.php.net/manual/en/features.connection-handling.php
$task = CheckExternalLinksTask::create();
$task->runLinksCheck();
}
}
}

View File

@ -1,34 +0,0 @@
<?php
if(!class_exists('AbstractQueuedJob')) return;
/**
* A Job for running a external link check for published pages
*
*/
class CheckExternalLinksJob extends AbstractQueuedJob implements QueuedJob {
public function getTitle() {
return _t('CheckExternalLiksJob.TITLE', 'Checking for external broken links');
}
public function getJobType() {
return QueuedJob::QUEUED;
}
public function getSignature() {
return md5(get_class($this));
}
/**
* Check an individual page
*/
public function process() {
$task = CheckExternalLinksTask::create();
$track = $task->runLinksCheck(1);
$this->currentStep = $track->CompletedPages;
$this->totalSteps = $track->TotalPages;
$this->isComplete = $track->Status === 'Completed';
}
}

View File

@ -1,71 +0,0 @@
<?php
/**
* Represents a single link checked for a single run that is broken
*
* @method BrokenExternalPageTrack Track()
* @method BrokenExternalPageTrackStatus Status()
*/
class BrokenExternalLink extends DataObject {
private static $db = array(
'Link' => 'Varchar(2083)', // 2083 is the maximum length of a URL in Internet Explorer.
'HTTPCode' =>'Int'
);
private static $has_one = array(
'Track' => 'BrokenExternalPageTrack',
'Status' => 'BrokenExternalPageTrackStatus'
);
private static $summary_fields = array(
'Created' => 'Checked',
'Link' => 'External Link',
'HTTPCodeDescription' => 'HTTP Error Code',
'Page.Title' => 'Page link is on'
);
private static $searchable_fields = array(
'HTTPCode' => array('title' => 'HTTP Code')
);
/**
* @return SiteTree
*/
public function Page() {
return $this->Track()->Page();
}
public function canEdit($member = false) {
return false;
}
public function canView($member = false) {
$member = $member ? $member : Member::currentUser();
$codes = array('content-authors', 'administrators');
return Permission::checkMember($member, $codes);
}
/**
* Retrieve a human readable description of a response code
*
* @return string
*/
public function getHTTPCodeDescription() {
$code = $this->HTTPCode;
if(empty($code)) {
// Assume that $code = 0 means there was no response
$description = _t('BrokenExternalLink.NOTAVAILABLE', 'Server Not Available');
} elseif(
($descriptions = Config::inst()->get('SS_HTTPResponse', 'status_codes'))
&& isset($descriptions[$code])
) {
$description = $descriptions[$code];
} else {
$description = _t('BrokenExternalLink.UNKNOWNRESPONSE', 'Unknown Response Code');
}
return sprintf("%d (%s)", $code, $description);
}
}

View File

@ -1,28 +0,0 @@
<?php
/**
* Represents a track for a single page
*/
class BrokenExternalPageTrack extends DataObject {
private static $db = array(
'Processed' => 'Boolean'
);
private static $has_one = array(
'Page' => 'SiteTree',
'Status' => 'BrokenExternalPageTrackStatus'
);
private static $has_many = array(
'BrokenLinks' => 'BrokenExternalLink'
);
/**
* @return SiteTree
*/
public function Page() {
return Versioned::get_by_stage('SiteTree', 'Stage')
->byID($this->PageID);
}
}

View File

@ -1,128 +0,0 @@
<?php
/**
* Represents the status of a track run
*
* @method DataList TrackedPages()
* @method DataList BrokenLinks()
* @property int $TotalPages Get total pages count
* @property int $CompletedPages Get completed pages count
*/
class BrokenExternalPageTrackStatus extends DataObject {
private static $db = array(
'Status' => 'Enum("Completed, Running", "Running")',
'JobInfo' => 'Varchar(255)'
);
private static $has_many = array(
'TrackedPages' => 'BrokenExternalPageTrack',
'BrokenLinks' => 'BrokenExternalLink'
);
/**
* Get the latest track status
*
* @return self
*/
public static function get_latest() {
return self::get()
->sort('ID', 'DESC')
->first();
}
/**
* Gets the list of Pages yet to be checked
*
* @return DataList
*/
public function getIncompletePageList() {
$pageIDs = $this
->getIncompleteTracks()
->column('PageID');
if($pageIDs) return Versioned::get_by_stage('SiteTree', 'Stage')
->byIDs($pageIDs);
}
/**
* Get the list of incomplete BrokenExternalPageTrack
*
* @return DataList
*/
public function getIncompleteTracks() {
return $this
->TrackedPages()
->filter('Processed', 0);
}
/**
* Get total pages count
*/
public function getTotalPages() {
return $this->TrackedPages()->count();
}
/**
* Get completed pages count
*/
public function getCompletedPages() {
return $this
->TrackedPages()
->filter('Processed', 1)
->count();
}
/**
* Returns the latest run, or otherwise creates a new one
*
* @return self
*/
public static function get_or_create() {
// Check the current status
$status = self::get_latest();
if ($status && $status->Status == 'Running') {
$status->updateStatus();
return $status;
}
return self::create_status();
}
/*
* Create and prepare a new status
*
* @return self
*/
public static function create_status() {
// If the script is to be started create a new status
$status = self::create();
$status->updateJobInfo('Creating new tracking object');
// Setup all pages to test
$pageIDs = Versioned::get_by_stage('SiteTree', 'Stage')
->column('ID');
foreach ($pageIDs as $pageID) {
$trackPage = BrokenExternalPageTrack::create();
$trackPage->PageID = $pageID;
$trackPage->StatusID = $status->ID;
$trackPage->write();
}
return $status;
}
public function updateJobInfo($message) {
$this->JobInfo = $message;
$this->write();
}
/**
* Self check status
*/
public function updateStatus() {
if ($this->CompletedPages == $this->TotalPages) {
$this->Status = 'Completed';
$this->updateJobInfo('Setting to completed');
}
}
}

View File

@ -1,83 +0,0 @@
<?php
/**
* Content side-report listing pages with external broken links
* @package externallinks
* @subpackage content
*/
class BrokenExternalLinksReport extends SS_Report {
/**
* Returns the report title
*
* @return string
*/
public function title() {
return _t('ExternalBrokenLinksReport.EXTERNALBROKENLINKS', "External broken links report");
}
public function columns() {
return array(
"Created" => "Checked",
'Link' => array(
'title' => 'External Link',
'formatting' => function($value, $item) {
return sprintf(
'<a target="_blank" href="%s">%s</a>',
Convert::raw2att($item->Link),
Convert::raw2xml($item->Link)
);
}
),
'HTTPCodeDescription' => 'HTTP Error Code',
"Title" => array(
"title" => 'Page link is on',
'formatting' => function($value, $item) {
$page = $item->Page();
return sprintf(
'<a href="%s">%s</a>',
Convert::raw2att($page->CMSEditLink()),
Convert::raw2xml($page->Title)
);
}
)
);
}
/**
* Alias of columns(), to support the export to csv action
* in {@link GridFieldExportButton} generateExportFileData method.
* @return array
*/
public function getColumns() {
return $this->columns();
}
public function sourceRecords() {
$track = BrokenExternalPageTrackStatus::get_latest();
if ($track) return $track->BrokenLinks();
return new ArrayList();
}
public function getCMSFields() {
Requirements::javascript('externallinks/javascript/BrokenExternalLinksReport.js');
$fields = parent::getCMSFields();
$reportResultSpan = '</ br></ br><h3 id="ReportHolder"></h3>';
$reportResult = new LiteralField('ResultTitle', $reportResultSpan);
$fields->push($reportResult);
$button = '<button id="externalLinksReport" type="button">%s</button>';
$runReportButton = new LiteralField(
'runReport',
sprintf(
$button,
_t('ExternalBrokenLinksReport.RUNREPORT', 'Create new report')
)
);
$fields->push($runReportButton);
return $fields;
}
}

View File

@ -1,188 +0,0 @@
<?php
class CheckExternalLinksTask extends BuildTask {
private static $dependencies = array(
'LinkChecker' => '%$LinkChecker'
);
/**
* @var bool
*/
protected $silent = false;
/**
* @var LinkChecker
*/
protected $linkChecker;
protected $title = 'Checking broken External links in the SiteTree';
protected $description = 'A task that records external broken links in the SiteTree';
protected $enabled = true;
/**
* Log a message
*
* @param string $message
*/
protected function log($message) {
if(!$this->silent) Debug::message($message);
}
public function run($request) {
$this->runLinksCheck();
}
/**
* Turn on or off message output
*
* @param bool $silent
*/
public function setSilent($silent) {
$this->silent = $silent;
}
/**
* @param LinkChecker $linkChecker
*/
public function setLinkChecker(LinkChecker $linkChecker) {
$this->linkChecker = $linkChecker;
}
/**
* @return LinkChecker
*/
public function getLinkChecker() {
return $this->linkChecker;
}
/**
* Check the status of a single link on a page
*
* @param BrokenExternalPageTrack $pageTrack
* @param DOMNode $link
*/
protected function checkPageLink(BrokenExternalPageTrack $pageTrack, DOMNode $link) {
$class = $link->getAttribute('class');
$href = $link->getAttribute('href');
$markedBroken = preg_match('/\b(ss-broken)\b/', $class);
// Check link
$httpCode = $this->linkChecker->checkLink($href);
if($httpCode === null) return; // Null link means uncheckable, such as an internal link
// If this code is broken then mark as such
if($foundBroken = $this->isCodeBroken($httpCode)) {
// Create broken record
$brokenLink = new BrokenExternalLink();
$brokenLink->Link = $href;
$brokenLink->HTTPCode = $httpCode;
$brokenLink->TrackID = $pageTrack->ID;
$brokenLink->StatusID = $pageTrack->StatusID; // Slight denormalisation here for performance reasons
$brokenLink->write();
}
// Check if we need to update CSS class, otherwise return
if($markedBroken == $foundBroken) return;
if($foundBroken) {
$class .= ' ss-broken';
} else {
$class = preg_replace('/\s*\b(ss-broken)\b\s*/', ' ', $class);
}
$link->setAttribute('class', trim($class));
}
/**
* Determine if the given HTTP code is "broken"
*
* @param int $httpCode
* @return bool True if this is a broken code
*/
protected function isCodeBroken($httpCode) {
// Null represents no request attempted
if($httpCode === null) return false;
// do we have any whitelisted codes
$ignoreCodes = Config::inst()->get('CheckExternalLinks', 'IgnoreCodes');
if(is_array($ignoreCodes) && in_array($httpCode, $ignoreCodes)) return false;
// Check if code is outside valid range
return $httpCode < 200 || $httpCode > 302;
}
/**
* Runs the links checker and returns the track used
*
* @param int $limit Limit to number of pages to run, or null to run all
* @return BrokenExternalPageTrackStatus
*/
public function runLinksCheck($limit = null) {
// Check the current status
$status = BrokenExternalPageTrackStatus::get_or_create();
// Calculate pages to run
$pageTracks = $status->getIncompleteTracks();
if($limit) $pageTracks = $pageTracks->limit($limit);
// Check each page
foreach ($pageTracks as $pageTrack) {
// Flag as complete
$pageTrack->Processed = 1;
$pageTrack->write();
// Check value of html area
$page = $pageTrack->Page();
$this->log("Checking {$page->Title}");
$htmlValue = Injector::inst()->create('HTMLValue', $page->Content);
if (!$htmlValue->isValid()) continue;
// Check each link
$links = $htmlValue->getElementsByTagName('a');
foreach($links as $link) {
$this->checkPageLink($pageTrack, $link);
}
// Update content of page based on link fixes / breakages
$htmlValue->saveHTML();
$page->Content = $htmlValue->getContent();
$page->write();
// Once all links have been created for this page update HasBrokenLinks
$count = $pageTrack->BrokenLinks()->count();
$this->log("Found {$count} broken links");
if($count) {
// Bypass the ORM as syncLinkTracking does not allow you to update HasBrokenLink to true
DB::query(sprintf(
'UPDATE "SiteTree" SET "HasBrokenLink" = 1 WHERE "ID" = \'%d\'',
intval($pageTrack->ID)
));
}
}
$status->updateJobInfo('Updating completed pages');
$status->updateStatus();
return $status;
}
private function updateCompletedPages($trackID = 0) {
$noPages = BrokenExternalPageTrack::get()
->filter(array(
'TrackID' => $trackID,
'Processed' => 1
))
->count();
$track = BrokenExternalPageTrackStatus::get_latest();
$track->CompletedPages = $noPages;
$track->write();
return $noPages;
}
private function updateJobInfo($message) {
$track = BrokenExternalPageTrackStatus::get_latest();
if($track) {
$track->JobInfo = $message;
$track->write();
}
}
}

View File

@ -1,49 +0,0 @@
<?php
/**
* Check links using curl
*/
class CurlLinkChecker implements LinkChecker {
/**
* Return cache
*
* @return Zend_Cache_Frontend
*/
protected function getCache() {
return SS_Cache::factory(
__CLASS__,
'Output',
array('automatic_serialization' => true)
);
}
/**
* Determine the http status code for a given link
*
* @param string $href URL to check
* @return int HTTP status code, or null if not checkable (not a link)
*/
public function checkLink($href) {
// Skip non-external links
if(!preg_match('/^https?[^:]*:\/\//', $href)) return null;
// Check if we have a cached result
$cacheKey = md5($href);
$result = $this->getCache()->load($cacheKey);
if($result !== false) return $result;
// No cached result so just request
$handle = curl_init($href);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($handle, CURLOPT_TIMEOUT, 10);
curl_exec($handle);
$httpCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
curl_close($handle);
// Cache result
$this->getCache()->save($httpCode, $cacheKey);
return $httpCode;
}
}

View File

@ -1,15 +0,0 @@
<?php
/**
* Provides an interface for checking that a link is valid
*/
interface LinkChecker {
/**
* Determine the http status code for a given link
*
* @param string $href URL to check
* @return int HTTP status code, or null if not checkable (not a link)
*/
public function checkLink($href);
}

2
codecov.yml Normal file
View File

@ -0,0 +1,2 @@
comment: false

View File

@ -1,13 +1,9 @@
{
"name": "silverstripe/externallinks",
"description": "Adds tracking of external broken links to the SilverStripe CMS",
"type": "silverstripe-module",
"keywords": [
"silverstripe",
"broken",
"links",
"href"
],
"description":
"Adds tracking of broken external links to the SilverStripe CMS",
"type": "silverstripe-vendormodule",
"keywords": ["silverstripe", "broken", "links", "href"],
"license": "BSD-3-Clause",
"authors": [
{
@ -16,19 +12,28 @@
}
],
"require": {
"silverstripe/framework": "~3.1",
"silverstripe/cms": "~3.1"
"silverstripe/recipe-cms": "^1.0"
},
"require-dev": {
"hafriedlander/silverstripe-phockito": "*",
"phpunit/PHPUnit": "~3.7@stable"
"phpunit/PHPUnit": "^5.7",
"squizlabs/php_codesniffer": "^3.0"
},
"suggest": {
"silverstripe/queuedjobs": "Speeds up running the job for Content Editors fropm the report"
"silverstripe/queuedjobs":
"Provides a more efficient method of generating/updating the report"
},
"autoload": {
"psr-4": {
"SilverStripe\\ExternalLinks\\": "src/",
"SilverStripe\\ExternalLinks\\Tests\\": "tests/"
}
},
"extra": {
"expose": ["javascript"],
"branch-alias": {
"dev-master": "2.x-dev"
}
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

View File

@ -1,8 +1,8 @@
de:
BrokenExternalLink:
SilverStripe\ExternalLinks\Model\BrokenExternalLink:
NOTAVAILABLE: 'Server nicht verfügbar'
PLURALNAME: 'Defekte externe Links'
SINGULARNAME: 'Defekter externer Link'
UNKNOWNRESPONSE: 'Unbekannter Antwortcode'
ExternalBrokenLinksReport:
SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport:
RUNREPORT: 'Generiere neuen Bericht'

View File

@ -1,17 +1,17 @@
en:
BrokenExternalLink:
SilverStripe\ExternalLinks\Model\BrokenExternalLink:
NOTAVAILABLE: 'Server Not Available'
PLURALNAME: 'Broken External Links'
SINGULARNAME: 'Broken External Link'
UNKNOWNRESPONSE: 'Unknown Response Code'
BrokenExternalPageTrack:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack:
PLURALNAME: 'Broken External Page Tracks'
SINGULARNAME: 'Broken External Page Track'
BrokenExternalPageTrackStatus:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus:
PLURALNAME: 'Broken External Page Track Statuss'
SINGULARNAME: 'Broken External Page Track Status'
CheckExternalLiksJob:
SilverStripe\ExternalLinks\Jobs\CheckExternalLinksJob:
TITLE: 'Checking for external broken links'
ExternalBrokenLinksReport:
SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport:
EXTERNALBROKENLINKS: 'External broken links report'
RUNREPORT: 'Create new report'

View File

@ -1,17 +1,17 @@
eo:
BrokenExternalLink:
SilverStripe\ExternalLinks\Model\BrokenExternalLink:
NOTAVAILABLE: 'Servilo estas neatingenbla'
PLURALNAME: 'Rompitaj eksteraj ligiloj'
SINGULARNAME: 'Rompita ekstera ligilo'
UNKNOWNRESPONSE: 'Nekonata respondokodo'
BrokenExternalPageTrack:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack:
PLURALNAME: 'Rompitaj eksteraj paĝaj trakoj'
SINGULARNAME: 'Rompita ekstera paĝa trako'
BrokenExternalPageTrackStatus:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus:
PLURALNAME: 'Stato de rompitaj eksteraj paĝaj trakoj'
SINGULARNAME: 'Stato de rompita ekstera paĝa trako'
CheckExternalLiksJob:
SilverStripe\ExternalLinks\Jobs\CheckExternalLinksJob:
TITLE: 'Kontrolas por eksteraj rompitaj ligiloj'
ExternalBrokenLinksReport:
SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport:
EXTERNALBROKENLINKS: 'Raporto pri eksteraj rompitaj ligiloj'
RUNREPORT: 'Krei novan raporton'

View File

@ -1,17 +1,17 @@
pl:
BrokenExternalLink:
SilverStripe\ExternalLinks\Model\BrokenExternalLink:
NOTAVAILABLE: 'Serwer niedostępny'
PLURALNAME: 'Uszkodzone linki zewnętrzne'
SINGULARNAME: 'Uszkodzony link zewnętrzny'
UNKNOWNRESPONSE: 'Nieznany kod odpowiedzi'
BrokenExternalPageTrack:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack:
PLURALNAME: 'Wykrywania wadliwych stron zewnętrznych'
SINGULARNAME: 'Wykrywanie wadliwych stron zewnętrznych'
BrokenExternalPageTrackStatus:
SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus:
PLURALNAME: 'Statusy wykrywania wadliwych stron zewnętrznych'
SINGULARNAME: 'Status wykrywania wadliwych stron zewnętrznych'
CheckExternalLiksJob:
SilverStripe\ExternalLinks\Jobs\CheckExternalLinksJob:
TITLE: 'Wyszukiwanie uszkodzonych linków zewnętrznych'
ExternalBrokenLinksReport:
SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport:
EXTERNALBROKENLINKS: 'Raport uszkodzonych linków zewnętrznych'
RUNREPORT: 'Stwórz nowy raport'

View File

@ -1,17 +0,0 @@
ru:
BrokenExternalLink:
NOTAVAILABLE: 'Сервер не доступен'
PLURALNAME: 'Недоступные внешние ссылки'
SINGULARNAME: 'Недоступная внешняя ссылка'
UNKNOWNRESPONSE: 'Неизвестный ответ сервера'
BrokenExternalPageTrack:
PLURALNAME: 'Внешнее отслеживание страниц нарушено'
SINGULARNAME: 'Внешнее отслеживание страниц нарушено'
BrokenExternalPageTrackStatus:
PLURALNAME: 'Внешнее отслеживание страниц нарушено'
SINGULARNAME: 'Внешнее отслеживание страниц нарушено'
CheckExternalLiksJob:
TITLE: 'Проверяю внешние ссылки'
ExternalBrokenLinksReport:
EXTERNALBROKENLINKS: 'Отчёт о неработающих внешних ссылках'
RUNREPORT: 'Создать новый отчёт'

View File

@ -1,4 +1,4 @@
Copyright (c) 2016, SilverStripe Limited
Copyright (c) 2017, SilverStripe Limited
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

10
phpcs.xml.dist Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<rule ref="PSR2" >
<!-- Current exclusions -->
<exclude name="PSR1.Methods.CamelCapsMethodName" />
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols" />
</rule>
</ruleset>

13
phpunit.xml.dist Normal file
View File

@ -0,0 +1,13 @@
<phpunit bootstrap="vendor/silverstripe/cms/tests/bootstrap.php" colors="true">
<testsuite name="Default">
<directory>tests/</directory>
</testsuite>
<filter>
<whitelist addUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src/</directory>
<exclude>
<directory suffix=".php">tests/</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

@ -0,0 +1,73 @@
<?php
namespace SilverStripe\ExternalLinks\Controllers;
use SilverStripe\Control\HTTP;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\ExternalLinks\Jobs\CheckExternalLinksJob;
use SilverStripe\ExternalLinks\Tasks\CheckExternalLinksTask;
use SilverStripe\Control\Controller;
use Symbiote\QueuedJobs\Services\QueuedJobService;
class CMSExternalLinksController extends Controller
{
private static $allowed_actions = [
'getJobStatus',
'start'
];
/**
* Respond to Ajax requests for info on a running job
*
* @return string JSON string detailing status of the job
*/
public function getJobStatus()
{
// Set headers
HTTP::set_cache_age(0);
HTTP::add_cache_headers($this->response);
$this->response
->addHeader('Content-Type', 'application/json')
->addHeader('Content-Encoding', 'UTF-8')
->addHeader('X-Content-Type-Options', 'nosniff');
// Format status
$track = BrokenExternalPageTrackStatus::get_latest();
if ($track) {
return json_encode([
'TrackID' => $track->ID,
'Status' => $track->Status,
'Completed' => $track->getCompletedPages(),
'Total' => $track->getTotalPages()
]);
}
}
/**
* Starts a broken external link check
*/
public function start()
{
// return if the a job is already running
$status = BrokenExternalPageTrackStatus::get_latest();
if ($status && $status->Status == 'Running') {
return;
}
// Create a new job
if (class_exists(QueuedJobService::class)) {
// Force the creation of a new run
BrokenExternalPageTrackStatus::create_status();
$checkLinks = new CheckExternalLinksJob();
singleton(QueuedJobService::class)->queueJob($checkLinks);
} else {
//TODO this hangs as it waits for the connection to be released
// should return back and continue processing
// http://us3.php.net/manual/en/features.connection-handling.php
$task = CheckExternalLinksTask::create();
$task->runLinksCheck();
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace SilverStripe\ExternalLinks\Jobs;
use Symbiote\QueuedJobs\Services\AbstractQueuedJob;
use Symbiote\QueuedJobs\Services\QueuedJob;
use SilverStripe\ExternalLinks\Tasks\CheckExternalLinksTask;
if (!class_exists(AbstractQueuedJob::class)) {
return;
}
/**
* A Job for running a external link check for published pages
*
*/
class CheckExternalLinksJob extends AbstractQueuedJob implements QueuedJob
{
public function getTitle()
{
return _t(__CLASS__ . '.TITLE', 'Checking for external broken links');
}
public function getJobType()
{
return QueuedJob::QUEUED;
}
public function getSignature()
{
return md5(get_class($this));
}
/**
* Check an individual page
*/
public function process()
{
$task = CheckExternalLinksTask::create();
$track = $task->runLinksCheck(1);
$this->currentStep = $track->CompletedPages;
$this->totalSteps = $track->TotalPages;
$this->isComplete = $track->Status === 'Completed';
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace SilverStripe\ExternalLinks\Model;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\Security\Member;
use SilverStripe\Security\Permission;
use SilverStripe\Core\Config\Config;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\ORM\DataObject;
/**
* Represents a single link checked for a single run that is broken
*
* @method BrokenExternalPageTrack Track()
* @method BrokenExternalPageTrackStatus Status()
*/
class BrokenExternalLink extends DataObject
{
private static $table_name = 'BrokenExternalLink';
private static $db = array(
'Link' => 'Varchar(2083)', // 2083 is the maximum length of a URL in Internet Explorer.
'HTTPCode' =>'Int'
);
private static $has_one = array(
'Track' => BrokenExternalPageTrack::class,
'Status' => BrokenExternalPageTrackStatus::class
);
private static $summary_fields = array(
'Created' => 'Checked',
'Link' => 'External Link',
'HTTPCodeDescription' => 'HTTP Error Code',
'Page.Title' => 'Page link is on'
);
private static $searchable_fields = array(
'HTTPCode' => array('title' => 'HTTP Code')
);
/**
* @return SiteTree
*/
public function Page()
{
return $this->Track()->Page();
}
public function canEdit($member = false)
{
return false;
}
public function canView($member = false)
{
$member = $member ? $member : Member::currentUser();
$codes = array('content-authors', 'administrators');
return Permission::checkMember($member, $codes);
}
/**
* Retrieve a human readable description of a response code
*
* @return string
*/
public function getHTTPCodeDescription()
{
$code = $this->HTTPCode;
try {
$response = HTTPResponse::create('', $code);
// Assume that $code = 0 means there was no response
$description = $code ?
$response->getStatusDescription() :
_t(__CLASS__ . '.NOTAVAILABLE', 'Server Not Available');
} catch (InvalidArgumentException $e) {
$description = _t(__CLASS__ . '.UNKNOWNRESPONSE', 'Unknown Response Code');
}
return sprintf("%d (%s)", $code, $description);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace SilverStripe\ExternalLinks\Model;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\ExternalLinks\Model\BrokenExternalLink;
use SilverStripe\Versioned\Versioned;
use SilverStripe\ORM\DataObject;
/**
* Represents a track for a single page
*/
class BrokenExternalPageTrack extends DataObject
{
private static $table_name = 'BrokenExternalPageTrack';
private static $db = array(
'Processed' => 'Boolean'
);
private static $has_one = array(
'Page' => SiteTree::class,
'Status' => BrokenExternalPageTrackStatus::class
);
private static $has_many = array(
'BrokenLinks' => BrokenExternalLink::class
);
/**
* @return SiteTree
*/
public function Page()
{
return Versioned::get_by_stage(SiteTree::class, 'Stage')
->byID($this->PageID);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace SilverStripe\ExternalLinks\Model;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack;
use SilverStripe\ExternalLinks\Model\BrokenExternalLink;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Versioned\Versioned;
use SilverStripe\ORM\DataObject;
/**
* Represents the status of a track run
*
* @method DataList TrackedPages()
* @method DataList BrokenLinks()
* @property int $TotalPages Get total pages count
* @property int $CompletedPages Get completed pages count
*/
class BrokenExternalPageTrackStatus extends DataObject
{
private static $table_name = 'BrokenExternalPageTrackStatus';
private static $db = array(
'Status' => 'Enum("Completed, Running", "Running")',
'JobInfo' => 'Varchar(255)'
);
private static $has_many = array(
'TrackedPages' => BrokenExternalPageTrack::class,
'BrokenLinks' => BrokenExternalLink::class
);
/**
* Get the latest track status
*
* @return BrokenExternalPageTrackStatus
*/
public static function get_latest()
{
return self::get()
->sort('ID', 'DESC')
->first();
}
/**
* Gets the list of Pages yet to be checked
*
* @return DataList
*/
public function getIncompletePageList()
{
$pageIDs = $this
->getIncompleteTracks()
->column('PageID');
if ($pageIDs) {
return Versioned::get_by_stage(SiteTree::class, 'Stage')
->byIDs($pageIDs);
}
}
/**
* Get the list of incomplete BrokenExternalPageTrack
*
* @return DataList
*/
public function getIncompleteTracks()
{
return $this
->TrackedPages()
->filter('Processed', 0);
}
/**
* Get total pages count
*
* @return int
*/
public function getTotalPages()
{
return $this->TrackedPages()->count();
}
/**
* Get completed pages count
*
* @return int
*/
public function getCompletedPages()
{
return $this
->TrackedPages()
->filter('Processed', 1)
->count();
}
/**
* Returns the latest run, or otherwise creates a new one
*
* @return BrokenExternalPageTrackStatus
*/
public static function get_or_create()
{
// Check the current status
$status = self::get_latest();
if ($status && $status->Status == 'Running') {
$status->updateStatus();
return $status;
}
return self::create_status();
}
/**
* Create and prepare a new status
*
* @return BrokenExternalPageTrackStatus
*/
public static function create_status()
{
// If the script is to be started create a new status
$status = self::create();
$status->updateJobInfo('Creating new tracking object');
// Setup all pages to test
$pageIDs = Versioned::get_by_stage(SiteTree::class, 'Stage')
->column('ID');
foreach ($pageIDs as $pageID) {
$trackPage = BrokenExternalPageTrack::create();
$trackPage->PageID = $pageID;
$trackPage->StatusID = $status->ID;
$trackPage->write();
}
return $status;
}
public function updateJobInfo($message)
{
$this->JobInfo = $message;
$this->write();
}
/**
* Self check status
*/
public function updateStatus()
{
if ($this->CompletedPages == $this->TotalPages) {
$this->Status = 'Completed';
$this->updateJobInfo('Setting to completed');
}
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace SilverStripe\ExternalLinks\Reports;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\Core\Convert;
use SilverStripe\View\HTML;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Reports\Report;
use SilverStripe\View\Requirements;
/**
* Content side-report listing pages with external broken links
* @package externallinks
*/
class BrokenExternalLinksReport extends Report
{
/**
* Returns the report title
*
* @return string
*/
public function title()
{
return _t(__CLASS__ . '.EXTERNALBROKENLINKS', "External broken links report");
}
public function columns()
{
return array(
"Created" => "Checked",
'Link' => array(
'title' => 'External Link',
'formatting' => function ($value, $item) {
return sprintf(
'<a target="_blank" href="%s">%s</a>',
Convert::raw2att($item->Link),
Convert::raw2xml($item->Link)
);
}
),
'HTTPCodeDescription' => 'HTTP Error Code',
"Title" => array(
"title" => 'Page link is on',
'formatting' => function ($value, $item) {
$page = $item->Page();
return sprintf(
'<a href="%s">%s</a>',
Convert::raw2att($page->CMSEditLink()),
Convert::raw2xml($page->Title)
);
}
)
);
}
/**
* Alias of columns(), to support the export to csv action
* in {@link GridFieldExportButton} generateExportFileData method.
* @return array
*/
public function getColumns()
{
return $this->columns();
}
public function sourceRecords()
{
$track = BrokenExternalPageTrackStatus::get_latest();
if ($track) {
return $track->BrokenLinks();
}
return ArrayList::create();
}
public function getCMSFields()
{
Requirements::javascript('silverstripe/externallinks: javascript/BrokenExternalLinksReport.js');
$fields = parent::getCMSFields();
$reportResultSpan = '</ br></ br><h3 id="ReportHolder"></h3>';
$reportResult = LiteralField::create('ResultTitle', $reportResultSpan);
$fields->push($reportResult);
$button = HTML::createTag(
'button',
[
'id' => 'externalLinksReport',
'type' => 'button',
'class' => 'btn btn-primary'
],
_t(__CLASS__ . '.RUNREPORT', 'Create new report')
);
$runReportButton = LiteralField::create('runReport', $button);
$fields->push($runReportButton);
return $fields;
}
}

View File

@ -0,0 +1,230 @@
<?php
namespace SilverStripe\ExternalLinks\Tasks;
use SilverStripe\ExternalLinks\Model\BrokenExternalLink;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrack;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\Dev\BuildTask;
use SilverStripe\Core\Config\Config;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\Dev\Debug;
use DOMNode;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ExternalLinks\Tasks\LinkChecker;
use SilverStripe\CMS\Model\SiteTree;
class CheckExternalLinksTask extends BuildTask
{
private static $dependencies = [
'LinkChecker' => '%$' . LinkChecker::class
];
/**
* @var bool
*/
protected $silent = false;
/**
* @var LinkChecker
*/
protected $linkChecker;
protected $title = 'Checking broken External links in the SiteTree';
protected $description = 'A task that records external broken links in the SiteTree';
protected $enabled = true;
/**
* Log a message
*
* @param string $message
*/
protected function log($message)
{
if (!$this->silent) {
Debug::message($message);
}
}
public function run($request)
{
$this->runLinksCheck();
}
/**
* Turn on or off message output
*
* @param bool $silent
*/
public function setSilent($silent)
{
$this->silent = $silent;
}
/**
* @param LinkChecker $linkChecker
*/
public function setLinkChecker(LinkChecker $linkChecker)
{
$this->linkChecker = $linkChecker;
}
/**
* @return LinkChecker
*/
public function getLinkChecker()
{
return $this->linkChecker;
}
/**
* Check the status of a single link on a page
*
* @param BrokenExternalPageTrack $pageTrack
* @param DOMNode $link
*/
protected function checkPageLink(BrokenExternalPageTrack $pageTrack, DOMNode $link)
{
$class = $link->getAttribute('class');
$href = $link->getAttribute('href');
$markedBroken = preg_match('/\b(ss-broken)\b/', $class);
// Check link
$httpCode = $this->linkChecker->checkLink($href);
if ($httpCode === null) {
return; // Null link means uncheckable, such as an internal link
}
// If this code is broken then mark as such
if ($foundBroken = $this->isCodeBroken($httpCode)) {
// Create broken record
$brokenLink = new BrokenExternalLink();
$brokenLink->Link = $href;
$brokenLink->HTTPCode = $httpCode;
$brokenLink->TrackID = $pageTrack->ID;
$brokenLink->StatusID = $pageTrack->StatusID; // Slight denormalisation here for performance reasons
$brokenLink->write();
}
// Check if we need to update CSS class, otherwise return
if ($markedBroken == $foundBroken) {
return;
}
if ($foundBroken) {
$class .= ' ss-broken';
} else {
$class = preg_replace('/\s*\b(ss-broken)\b\s*/', ' ', $class);
}
$link->setAttribute('class', trim($class));
}
/**
* Determine if the given HTTP code is "broken"
*
* @param int $httpCode
* @return bool True if this is a broken code
*/
protected function isCodeBroken($httpCode)
{
// Null represents no request attempted
if ($httpCode === null) {
return false;
}
// do we have any whitelisted codes
$ignoreCodes = $this->config()->get('IgnoreCodes');
if (is_array($ignoreCodes) && in_array($httpCode, $ignoreCodes)) {
return false;
}
// Check if code is outside valid range
return $httpCode < 200 || $httpCode > 302;
}
/**
* Runs the links checker and returns the track used
*
* @param int $limit Limit to number of pages to run, or null to run all
* @return BrokenExternalPageTrackStatus
*/
public function runLinksCheck($limit = null)
{
// Check the current status
$status = BrokenExternalPageTrackStatus::get_or_create();
// Calculate pages to run
$pageTracks = $status->getIncompleteTracks();
if ($limit) {
$pageTracks = $pageTracks->limit($limit);
}
// Check each page
foreach ($pageTracks as $pageTrack) {
// Flag as complete
$pageTrack->Processed = 1;
$pageTrack->write();
// Check value of html area
$page = $pageTrack->Page();
$this->log("Checking {$page->Title}");
$htmlValue = Injector::inst()->create('HTMLValue', $page->Content);
if (!$htmlValue->isValid()) {
continue;
}
// Check each link
$links = $htmlValue->getElementsByTagName('a');
foreach ($links as $link) {
$this->checkPageLink($pageTrack, $link);
}
// Update content of page based on link fixes / breakages
$htmlValue->saveHTML();
$page->Content = $htmlValue->getContent();
$page->write();
// Once all links have been created for this page update HasBrokenLinks
$count = $pageTrack->BrokenLinks()->count();
$this->log("Found {$count} broken links");
if ($count) {
$siteTreeTable = DataObject::getSchema()->tableName(SiteTree::class);
// Bypass the ORM as syncLinkTracking does not allow you to update HasBrokenLink to true
DB::query(sprintf(
'UPDATE "%s" SET "HasBrokenLink" = 1 WHERE "ID" = \'%d\'',
$siteTreeTable,
intval($pageTrack->ID)
));
}
}
$status->updateJobInfo('Updating completed pages');
$status->updateStatus();
return $status;
}
private function updateCompletedPages($trackID = 0)
{
$noPages = BrokenExternalPageTrack::get()
->filter(array(
'TrackID' => $trackID,
'Processed' => 1
))
->count();
$track = BrokenExternalPageTrackStatus::get_latest();
$track->CompletedPages = $noPages;
$track->write();
return $noPages;
}
private function updateJobInfo($message)
{
$track = BrokenExternalPageTrackStatus::get_latest();
if ($track) {
$track->JobInfo = $message;
$track->write();
}
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace SilverStripe\ExternalLinks\Tasks;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Injector\Injector;
/**
* Check links using curl
*/
class CurlLinkChecker implements LinkChecker
{
/**
* Return cache
*
* @return Zend_Cache_Frontend
*/
protected function getCache()
{
return Injector::inst()->get(CacheInterface::class . '.CurlLinkChecker');
}
/**
* Determine the http status code for a given link
*
* @param string $href URL to check
* @return int HTTP status code, or null if not checkable (not a link)
*/
public function checkLink($href)
{
// Skip non-external links
if (!preg_match('/^https?[^:]*:\/\//', $href)) {
return null;
}
// Check if we have a cached result
$cacheKey = md5($href);
$result = $this->getCache()->get($cacheKey);
if ($result !== false) {
return $result;
}
// No cached result so just request
$handle = curl_init($href);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($handle, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($handle, CURLOPT_TIMEOUT, 10);
curl_exec($handle);
$httpCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
curl_close($handle);
// Cache result
$this->getCache()->set($httpCode, $cacheKey);
return $httpCode;
}
}

18
src/Tasks/LinkChecker.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace SilverStripe\ExternalLinks\Tasks;
/**
* Provides an interface for checking that a link is valid
*/
interface LinkChecker
{
/**
* Determine the http status code for a given link
*
* @param string $href URL to check
* @return int HTTP status code, or null if not checkable (not a link)
*/
public function checkLink($href);
}

View File

@ -1,148 +1,110 @@
<?php
class ExternalLinksTest extends SapphireTest {
namespace SilverStripe\ExternalLinks\Tests;
protected static $fixture_file = 'ExternalLinksTest.yml';
use SilverStripe\ExternalLinks\Reports\BrokenExternalLinksReport;
use SilverStripe\ExternalLinks\Model\BrokenExternalPageTrackStatus;
use SilverStripe\ExternalLinks\Tasks\CheckExternalLinksTask;
use SilverStripe\ExternalLinks\Tests\ExternalLinksTestPage;
use SilverStripe\i18n\i18n;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ExternalLinks\Tasks\LinkChecker;
use SilverStripe\ExternalLinks\Tests\Stubs\PretendLinkChecker;
use SilverStripe\Reports\Report;
use SilverStripe\Dev\SapphireTest;
protected $extraDataObjects = array(
'ExternalLinksTestPage'
);
class ExternalLinksTest extends SapphireTest
{
protected $illegalExtensions = array(
'SiteTree' => array('Translatable')
protected static $fixture_file = 'ExternalLinksTest.yml';
protected static $extra_dataobjects = array(
ExternalLinksTestPage::class
);
public function setUpOnce() {
if (class_exists('Phockito')) {
Phockito::include_hamcrest(false);
}
protected function setUp()
{
parent::setUp();
parent::setUpOnce();
}
// Stub link checker
$checker = new PretendLinkChecker;
Injector::inst()->registerService($checker, LinkChecker::class);
}
public function setUp() {
parent::setUp();
public function testLinks()
{
// Run link checker
$task = CheckExternalLinksTask::create();
$task->setSilent(true); // Be quiet during the test!
$task->runLinksCheck();
// Check dependencies
if (!class_exists('Phockito')) {
$this->skipTest = true;
return $this->markTestSkipped("These tests need the Phockito module installed to run");
}
// Get all links checked
$status = BrokenExternalPageTrackStatus::get_latest();
$this->assertEquals('Completed', $status->Status);
$this->assertEquals(5, $status->TotalPages);
$this->assertEquals(5, $status->CompletedPages);
// Mock link checker
$checker = Phockito::mock('LinkChecker');
Phockito::when($checker)
->checkLink('http://www.working.com')
->return(200);
// Check all pages have had the correct HTML adjusted
for ($i = 1; $i <= 5; $i++) {
$page = $this->objFromFixture(ExternalLinksTestPage::class, 'page'.$i);
$this->assertNotEmpty($page->Content);
$this->assertEquals(
$page->ExpectedContent,
$page->Content,
"Assert that the content of page{$i} has been updated"
);
}
Phockito::when($checker)
->checkLink('http://www.broken.com/url/thing') // 404 on working site
->return(404);
// Check that the correct report of broken links is generated
$links = $status
->BrokenLinks()
->sort('Link');
Phockito::when($checker)
->checkLink('http://www.broken.com') // 403 on working site
->return(403);
$this->assertEquals(4, $links->count());
$this->assertEquals(
array(
'http://www.broken.com',
'http://www.broken.com/url/thing',
'http://www.broken.com/url/thing',
'http://www.nodomain.com'
),
array_values($links->map('ID', 'Link')->toArray())
);
Phockito::when($checker)
->checkLink('http://www.nodomain.com') // no ping
->return(0);
// Check response codes are correct
$expected = array(
'http://www.broken.com' => 403,
'http://www.broken.com/url/thing' => 404,
'http://www.nodomain.com' => 0
);
$actual = $links->map('Link', 'HTTPCode')->toArray();
$this->assertEquals($expected, $actual);
Phockito::when($checker)
->checkLink('/internal/link')
->return(null);
// Check response descriptions are correct
i18n::set_locale('en_NZ');
$expected = array(
'http://www.broken.com' => '403 (Forbidden)',
'http://www.broken.com/url/thing' => '404 (Not Found)',
'http://www.nodomain.com' => '0 (Server Not Available)'
);
$actual = $links->map('Link', 'HTTPCodeDescription')->toArray();
$this->assertEquals($expected, $actual);
}
Phockito::when($checker)
->checkLink('[sitetree_link,id=9999]')
->return(null);
Phockito::when($checker)
->checkLink('home')
->return(null);
Phockito::when($checker)
->checkLink('broken-internal')
->return(null);
Phockito::when($checker)
->checkLink('[sitetree_link,id=1]')
->return(null);
Phockito::when($checker)
->checkLink(Hamcrest_Matchers::anything()) // anything else is 404
->return(404);
Injector::inst()->registerService($checker, 'LinkChecker');
}
public function testLinks() {
// Run link checker
$task = CheckExternalLinksTask::create();
$task->setSilent(true); // Be quiet during the test!
$task->runLinksCheck();
// Get all links checked
$status = BrokenExternalPageTrackStatus::get_latest();
$this->assertEquals('Completed', $status->Status);
$this->assertEquals(5, $status->TotalPages);
$this->assertEquals(5, $status->CompletedPages);
// Check all pages have had the correct HTML adjusted
for($i = 1; $i <= 5; $i++) {
$page = $this->objFromFixture('ExternalLinksTestPage', 'page'.$i);
$this->assertNotEmpty($page->Content);
$this->assertEquals(
$page->ExpectedContent,
$page->Content,
"Assert that the content of page{$i} has been updated"
);
}
// Check that the correct report of broken links is generated
$links = $status
->BrokenLinks()
->sort('Link');
$this->assertEquals(4, $links->count());
$this->assertEquals(
array(
'http://www.broken.com',
'http://www.broken.com/url/thing',
'http://www.broken.com/url/thing',
'http://www.nodomain.com'
),
array_values($links->map('ID', 'Link')->toArray())
);
// Check response codes are correct
$expected = array(
'http://www.broken.com' => 403,
'http://www.broken.com/url/thing' => 404,
'http://www.nodomain.com' => 0
);
$actual = $links->map('Link', 'HTTPCode')->toArray();
$this->assertEquals($expected, $actual);
// Check response descriptions are correct
i18n::set_locale('en_NZ');
$expected = array(
'http://www.broken.com' => '403 (Forbidden)',
'http://www.broken.com/url/thing' => '404 (Not Found)',
'http://www.nodomain.com' => '0 (Server Not Available)'
);
$actual = $links->map('Link', 'HTTPCodeDescription')->toArray();
$this->assertEquals($expected, $actual);
}
/**
* Test that broken links appears in the reports list
*/
public function testReportExists() {
$reports = SS_Report::get_reports();
$reportNames = array();
foreach($reports as $report) {
$reportNames[] = $report->class;
}
$this->assertContains('BrokenExternalLinksReport',$reportNames,
'BrokenExternalLinksReport is in reports list');
}
/**
* Test that broken links appears in the reports list
*/
public function testReportExists()
{
$reports = Report::get_reports();
$reportNames = array();
foreach ($reports as $report) {
$reportNames[] = get_class($report);
}
$this->assertContains(
BrokenExternalLinksReport::class,
$reportNames,
'BrokenExternalLinksReport is in reports list'
);
}
}

View File

@ -1,4 +1,4 @@
ExternalLinksTestPage:
SilverStripe\ExternalLinks\Tests\ExternalLinksTestPage:
# Tests mix of broken and working external links
page1:
Title: 'Page 1'

View File

@ -1,7 +1,14 @@
<?php
namespace SilverStripe\ExternalLinks\Tests;
use SilverStripe\Dev\TestOnly;
use Page;
class ExternalLinksTestPage extends Page implements TestOnly
{
private static $table_name = 'ExternalLinksTestPage';
private static $db = array(
'ExpectedContent' => 'HTMLText'
);

View File

@ -0,0 +1,28 @@
<?php
namespace SilverStripe\ExternalLinks\Tests\Stubs;
use SilverStripe\ExternalLinks\Tasks\LinkChecker;
class PretendLinkChecker implements LinkChecker
{
public function checkLink($href)
{
switch ($href) {
case 'http://www.working.com':
return 200;
case 'http://www.broken.com':
return 403;
case 'http://www.nodomain.com':
return 0;
case '/internal/link':
case '[sitetree_link,id=9999]':
case 'home':
case 'broken-internal':
case '[sitetree_link,id=1]':
return null;
case 'http://www.broken.com/url/thing':
default:
return 404;
}
}
}