Skip to content

Instantly share code, notes, and snippets.

@inuitviking
Created June 21, 2024 13:02
Show Gist options
  • Save inuitviking/bf3f0836e25a7043ef55f08f0be3a408 to your computer and use it in GitHub Desktop.
Save inuitviking/bf3f0836e25a7043ef55f08f0be3a408 to your computer and use it in GitHub Desktop.
A League\CommonMark extension for matching very particular link containing images
<?php
declare(strict_types=1);
namespace Imms\plugins\imms\gitlab\src\Extensions\DownloadLastTag;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
final class DownloadLastTagExtension implements ExtensionInterface{
/**
* Simply registers the extension and the included parser
*
* @param EnvironmentBuilderInterface $environment
* @return void
*/
public function register(EnvironmentBuilderInterface $environment): void {
$environment->addInlineParser(new DownloadLastTagParser, priority: 10);
}
}
<?php
declare(strict_types=1);
namespace Imms\plugins\imms\gitlab\src\Extensions\DownloadLastTag;
use CzProject\GitPhp\GitException;
use CzProject\GitPhp\Runners\CliRunner;
use Gitlab\Client;
use Imms\Classes\Bootstrapper;
use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
class DownloadLastTagParser implements InlineParserInterface {
private static string $urlRegex = '(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&\/=]*';
/**
* Gets the match definition of an image link that looks something like this:
*
* - `![latest tag](https://gitlab.com/inuitviking/imms)`
* - `![latest release](https://gitlab.com/inuitviking/imms/-/badges/release.svg?order_by=release_at&value_width=110)`
*
* @return InlineParserMatch
*/
public function getMatchDefinition(): InlineParserMatch {
$regex = InlineParserMatch::regex('\[!\[(latest|last) (tag|release)\]\(' . self::$urlRegex . '\)\]\(' . self::$urlRegex . '\)');
echo $regex->getRegex().'<br>';
return $regex;
}
/**
* Parses the matched text.
*
* If everything below matches, replace the text with the latest version found in the remote git repository
*
* @param InlineParserContext $inlineContext
* @return bool
*/
public function parse(InlineParserContext $inlineContext): bool {
$cursor = $inlineContext->getCursor();
$fullMatch = strtolower($inlineContext->getFullMatch());
return $this->elementToLastTag($cursor, $inlineContext, $fullMatch);
}
/**
* Gets the last release/tag from the match.
* Returns `false` if the version wasn't found.
*
* @param string $match
* @return string|false
*/
private function getLatestRelease (string $match): string|false {
// Remove all the unnecessary fluff from the match
$replacements = [
'![latest tag](',
'![latest release](',
'![last tag](',
'![last release](',
')'
];
$match = str_replace($replacements, '', $match);
$match = preg_replace('/\/-.*/', '', $match);
// Make use of the CliRunner within GitPhp
$runner = new CliRunner();
$cwd = Bootstrapper::rootDirectory();
// If the remote address isn't reachable, we'll try with tokens!
$match = $this->nonReachableRemoteMatch($match);
if (!str_ends_with($match, '.git')) {
$match = $match . '.git';
}
$gitArgs = ['-c', 'versionsort.suffix=-', 'ls-remote', '--tags',$match];
try {
// Run the command and get the output as an array (and then sanitize it)
$output = $runner->run($cwd, $gitArgs)->getOutput();
$output = $this->sanitizeOutput($output);
// Return the last output
return end($output);
} catch (GitException) {
echo "Failed to fetch latest tag";
}
return false;
}
/**
* Checks whether the provided url has an HTTP code greater than or equal to 200 and lower than 300.
*
* @param string $match
* @return bool
*/
private function checkRemoteReachable (string $match): bool {
$remoteRepo = curl_init($match);
@curl_setopt($remoteRepo, CURLOPT_HEADER , true);
@curl_setopt($remoteRepo, CURLOPT_NOBODY , true);
@curl_setopt($remoteRepo, CURLOPT_RETURNTRANSFER , true);
@curl_setopt($remoteRepo, CURLOPT_TIMEOUT, 20);
curl_exec($remoteRepo);
$httpCode = curl_getinfo($remoteRepo, CURLINFO_HTTP_CODE);
curl_close($remoteRepo);
if ($httpCode >= 200 && $httpCode < 300) {
return true;
}
return false;
}
/**
* If the remote is not reachable, we'll change the $match to use tokens.
* If it is reachable, we'll not change anything, and simply return the $match in its original state.
*
* @param string $match
* @return string
*/
private function nonReachableRemoteMatch (string $match): string {
if (!$this->checkRemoteReachable($match)) {
$config = Bootstrapper::getIni();
$tokenUser = $config['git']['token_name'];
$tokenPass = $config['git']['token_pass'];
$authString = "$tokenUser:$tokenPass@";
$url = parse_url($match);
$matchReplacements = [
$url['scheme'],
'://'
];
$match = str_replace($matchReplacements, '', $match);
return $url['scheme'] . '://' . $authString . $match;
}
return $match;
}
/**
* Is meant to fix the output array of the CliRunner.
*
* Strips ref strings from tag numbers, removes unwanted tags, and lastly sorts it
*
* @param array $output
* @return array
*/
private function sanitizeOutput (array $output): array {
foreach ($output as &$item) {
$item = strrchr($item, '/');
$item = str_replace('/', '', $item);
}
$output = array_filter($output, function ($var) { return stripos($var, '^{}') === false; });
sort($output, SORT_NATURAL);
return $output;
}
/**
* Converts the matching element to the tag we want it to be.
*
* It returns true or false depending on whether it succeeds.
*
* @param Cursor $cursor
* @param InlineParserContext $inlineContext
* @param string $fullMatch
* @return bool|void
*/
private function elementToLastTag (Cursor $cursor, InlineParserContext $inlineContext, string $fullMatch) {
// Save the cursor state in case we need to rewind and bail
$previousState = $cursor->saveState();
// The symbol must not have any other characters immediately prior
$previousChar = $cursor->peek(-1);
if ($previousChar !== null && $previousChar !== ' ' && $previousChar !== '[') {
// peek() doesn't modify the cursor, so no need to restore state first
return false;
}
// Advance past the symbol to keep parsing simpler
$cursor->advance();
// Parse the match value
$identifier = $cursor->match("^\[!\[(latest|last) (tag|release)]\((?:http[s]?://.)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&/=]*\)^i");
if ($identifier === null) {
// Regex failed to match; this isn't a valid link
$cursor->restoreState($previousState);
return false;
}
$identifier = strtolower(substr($identifier, 0, -1));
if (str_contains($identifier, 'latest tag') || str_contains($identifier, 'latest release')
|| str_contains($identifier, 'last tag') || str_contains($identifier, 'last release')) {
$container = $inlineContext->getContainer();
$container->appendChild(new Code($this->getLatestRelease($fullMatch)));
$token = Bootstrapper::getIni()['gitlab']['token'];
$groupID = Bootstrapper::getIni()['gitlab']['group_id'];
$project = explode('(', $fullMatch)[1];
$project = explode(')', $project)[0];
$project = str_replace('/-/badges/release.svg', '', $project);
$projectArr = explode('/', $project);
$project = end($projectArr);
$client = new Client();
$client->authenticate($token, Client::AUTH_HTTP_TOKEN);
$projectId = $client->groups()->search($groupID, ['scope' => 'projects', 'search' => $project])[0]['id'];
$packages = $this->getPackages($projectId, $token);
$link = $this->getPackageLink(end($packages), $projectId, $token);
$container->prependChild(new Text(" - "));
$container->prependChild(new Link($link, "(Download)", "Download"));
return true;
}
}
/**
* Gets all packages from projectID
*
* @param int|string $projectId
* @param string $token
* @return mixed
*/
private function getPackages (int|string $projectId, string $token): mixed {
$handle = curl_init();
curl_setopt($handle, CURLOPT_URL, "https://gitlab.com/api/v4/projects/$projectId/packages");
curl_setopt($handle, CURLOPT_HTTPHEADER, array('PRIVATE-TOKEN: ' . $token));
curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
$result = json_decode(curl_exec($handle), true);
curl_close($handle);
return $result;
}
/**
* Generates a generic download link to the package - only works for the generic package registry.
*
* Source: https://docs.gitlab.com/ee/api/packages.html
*
* @param array $packageMetadata
* @param int|string $projectId
* @param string $token
* @return string
*/
private function getPackageLink (array $packageMetadata, int|string $projectId, string $token): string {
$tokenName = Bootstrapper::getIni()['git']['token_name'];
$tokenPass = Bootstrapper::getIni()['git']['token_pass'];
$handle = curl_init();
curl_setopt($handle, CURLOPT_URL, "https://gitlab.com/api/v4/projects/$projectId/packages/" . $packageMetadata['id'] . "/package_files");
curl_setopt($handle, CURLOPT_HTTPHEADER, array('PRIVATE-TOKEN: ' . $token));
curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
$packageFiles = json_decode(curl_exec($handle), true);
$package = end($packageFiles);
$link = "https://$tokenName:$tokenPass@gitlab.com/api/v4/projects/$projectId/packages/generic/" . $packageMetadata['name'] . '/' . $package['pipelines'][0]['ref'] . '/' . $package['file_name'];
curl_close($handle);
return $link;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment