Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.68% covered (warning)
73.68%
28 / 38
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Database
73.68% covered (warning)
73.68%
28 / 38
71.43% covered (warning)
71.43%
5 / 7
16.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBase62Hash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 saveAndShortenHash
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
6.00
 getGenericHash
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
2.21
 getShortHash
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 shorten
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Local database-driven URL shortener.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2019.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  UrlShortener
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFind\UrlShortener;
31
32use Exception;
33use VuFind\Db\Service\ShortlinksServiceInterface;
34
35/**
36 * Local database-driven URL shortener.
37 *
38 * @category VuFind
39 * @package  UrlShortener
40 * @author   Demian Katz <demian.katz@villanova.edu>
41 * @author   Cornelius Amzar <cornelius.amzar@bsz-bw.de>
42 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
43 * @link     https://vufind.org/wiki/development Wiki
44 */
45class Database implements UrlShortenerInterface
46{
47    /**
48     * When using a hash algorithm other than base62, the preferred number of
49     * characters to use from the hash in the URL (more may be used for
50     * disambiguation when necessary).
51     *
52     * @var int
53     */
54    protected $preferredHashLength = 9;
55
56    /**
57     * The maximum allowed hash length (tied to the width of the database hash
58     * column); if we can't generate a unique hash under this length, something
59     * has gone very wrong.
60     *
61     * @var int
62     */
63    protected $maxHashLength = 32;
64
65    /**
66     * Constructor
67     *
68     * @param string                     $baseUrl       Base URL of current VuFind site
69     * @param ShortlinksServiceInterface $service       Shortlinks database service
70     * @param string                     $salt          HMacKey from config
71     * @param string                     $hashAlgorithm Hash algorithm to use
72     */
73    public function __construct(
74        protected string $baseUrl,
75        protected ShortlinksServiceInterface $service,
76        protected string $salt,
77        protected string $hashAlgorithm = 'md5'
78    ) {
79    }
80
81    /**
82     * Generate a short hash using the base62 algorithm (and write a row to the
83     * database).
84     *
85     * @param string $path Path to store in database
86     *
87     * @return string
88     */
89    protected function getBase62Hash(string $path): string
90    {
91        $row = $this->service->createAndPersistEntityForPath($path);
92        $b62 = new \VuFind\Crypt\Base62();
93        $hash = $b62->encode($row->getId());
94        $row->setHash($hash);
95        $this->service->persistEntity($row);
96        return $hash;
97    }
98
99    /**
100     * Support method for getGenericHash(): do the work of picking a short version
101     * of the hash and writing to the database as needed.
102     *
103     * @param string $path   Path to store in database
104     * @param string $hash   Hash of $path (generated in getGenericHash)
105     * @param int    $length Minimum number of characters from hash to use for
106     * lookups (may be increased to enforce uniqueness)
107     *
108     * @throws Exception
109     * @return string
110     */
111    protected function saveAndShortenHash($path, $hash, $length)
112    {
113        // Validate hash length:
114        if ($length > $this->maxHashLength) {
115            throw new \Exception(
116                'Could not generate unique hash under ' . $this->maxHashLength
117                . ' characters in length.'
118            );
119        }
120        $shorthash = str_pad(substr($hash, 0, $length), $length, '_');
121        $match = $this->service->getShortLinkByHash($shorthash);
122
123        // Brand new hash? Create row and return:
124        if (!$match) {
125            $newEntity = $this->service->createEntity()->setPath($path)->setHash($shorthash);
126            $this->service->persistEntity($newEntity);
127            return $shorthash;
128        }
129
130        // If we got this far, the hash already exists; let's check if it matches
131        // the path...
132        if ($match->getHash() === $path) {
133            return $shorthash;
134        }
135
136        // If we got here, we have encountered an unexpected hash collision. Let's
137        // disambiguate by making it one character longer:
138        return $this->saveAndShortenHash($path, $hash, $length + 1);
139    }
140
141    /**
142     * Generate a short hash using the configured algorithm (and write a row to the
143     * database if the link is new).
144     *
145     * @param string $path Path to store in database
146     *
147     * @return string
148     */
149    protected function getGenericHash(string $path): string
150    {
151        $hash = hash($this->hashAlgorithm, $path . $this->salt);
152        // Generate short hash within a transaction to avoid odd timing-related
153        // problems:
154        $this->service->beginTransaction();
155        try {
156            $shortHash = $this->saveAndShortenHash($path, $hash, $this->preferredHashLength);
157        } catch (Exception $e) {
158            $this->service->rollBackTransaction();
159            throw $e;
160        }
161        $this->service->commitTransaction();
162        return $shortHash;
163    }
164
165    /**
166     * Given a URL, create a database entry (if necessary) and return the hash
167     * value for inclusion in the short URL.
168     *
169     * @param string $url URL
170     *
171     * @return string
172     */
173    protected function getShortHash(string $url): string
174    {
175        $path = str_replace($this->baseUrl, '', $url);
176
177        // We need to handle things differently depending on whether we're
178        // using the legacy base62 algorithm, or a different hash mechanism.
179        $shorthash = $this->hashAlgorithm === 'base62'
180            ? $this->getBase62Hash($path) : $this->getGenericHash($path);
181
182        return $shorthash;
183    }
184
185    /**
186     * Generate & store shortened URL in Database.
187     *
188     * @param string $url URL
189     *
190     * @return string
191     */
192    public function shorten($url)
193    {
194        return $this->baseUrl . '/short/' . $this->getShortHash($url);
195    }
196
197    /**
198     * Resolve URL from Database via id.
199     *
200     * @param string $input hash
201     *
202     * @return string
203     * @throws Exception
204     */
205    public function resolve($input)
206    {
207        $match = $this->service->getShortLinkByHash($input);
208        if (!$match) {
209            throw new Exception('Shortlink could not be resolved: ' . $input);
210        }
211
212        return $this->baseUrl . $match->getPath();
213    }
214}