mirror of
https://github.com/torrentpier/torrentpier
synced 2025-08-14 18:48:21 -07:00
Интеграция с Ocelot
Добавляем интеграцию с Ocelot. Включение в конфиге, настройки там же. Исправление дампа базы на замену. Исправление ошибки с добавлением ретрекера гостю.
This commit is contained in:
parent
5c51e6a824
commit
99400052d3
6 changed files with 247 additions and 63 deletions
|
@ -31,7 +31,10 @@ CREATE TABLE IF NOT EXISTS `bb_bt_tracker` (
|
|||
`up_add` bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
`down_add` bigint(20) unsigned NOT NULL DEFAULT '0',
|
||||
`update_time` int(11) NOT NULL DEFAULT '0',
|
||||
`complete_percent` bigint(20) NOT NULL DEFAULT '0'
|
||||
`complete_percent` bigint(20) NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (`peer_hash`),
|
||||
KEY `topic_id` (`topic_id`),
|
||||
KEY `user_id` (`user_id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
@ -46,5 +49,6 @@ CREATE TABLE IF NOT EXISTS `bb_bt_tracker_snap` (
|
|||
`leechers` mediumint(8) unsigned NOT NULL DEFAULT '0',
|
||||
`speed_up` int(10) unsigned NOT NULL DEFAULT '0',
|
||||
`speed_down` int(10) unsigned NOT NULL DEFAULT '0',
|
||||
`complete` int(11) NOT NULL
|
||||
`complete` int(11) NOT NULL,
|
||||
PRIMARY KEY (`topic_id`)
|
||||
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Script versions
|
||||
* Domain name
|
||||
* Version info
|
||||
* Database
|
||||
|
@ -12,7 +13,6 @@
|
|||
- Datastore
|
||||
* Server
|
||||
- Cloudflare
|
||||
- Script versions
|
||||
- GZip
|
||||
* Tracker
|
||||
* Ocelot
|
||||
|
@ -74,7 +74,7 @@ $domain_name = (!empty($_SERVER['SERVER_NAME'])) ? $_SERVER['SERVER_NAME'] : $do
|
|||
|
||||
// Version info
|
||||
$bb_cfg['tp_version'] = '2.1.3';
|
||||
$bb_cfg['tp_release_date'] = '**-10-2014';
|
||||
$bb_cfg['tp_release_date'] = '24-10-2014';
|
||||
$bb_cfg['tp_release_state'] = 'ALPHA';
|
||||
|
||||
// Database
|
||||
|
@ -109,7 +109,7 @@ $bb_cfg['db_alias'] = array(
|
|||
// Cache
|
||||
$bb_cfg['cache']['pconnect'] = true;
|
||||
$bb_cfg['cache']['db_dir'] = realpath(BB_ROOT) .'/internal_data/cache/filecache/';
|
||||
$bb_cfg['cache']['prefix'] = ''; // Префикс кеша 'tp_2'
|
||||
$bb_cfg['cache']['prefix'] = 'tp_'; // Префикс кеша ('tp_')
|
||||
$bb_cfg['cache']['memcache'] = array(
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 11211,
|
||||
|
@ -162,9 +162,10 @@ $bb_cfg['allow_internal_ip'] = false; // Allow internal IP (10.xx..
|
|||
$bb_cfg['ocelot'] = array(
|
||||
'enabled' => false,
|
||||
'host' => $domain_name,
|
||||
'url' => "http://$domain_name:34000/",
|
||||
'secret' => 'some_10_chars', // 10 chars
|
||||
'stats' => 'some_10_chars', // 10 chars
|
||||
'port' => 34000,
|
||||
'url' => "http://$domain_name:34000/", // with '/'
|
||||
'secret' => 'some_10_chars', // 10 chars
|
||||
'stats' => 'some_10_chars', // 10 chars
|
||||
);
|
||||
|
||||
// FAQ url help link
|
||||
|
@ -374,7 +375,7 @@ $bb_cfg['pm_notify_enabled'] = true;
|
|||
$bb_cfg['group_send_email'] = true;
|
||||
$bb_cfg['email_change_disabled'] = false; // disable changing email by user
|
||||
|
||||
$bb_cfg['tech_admin_email'] = "admin@$domain_name"; // email for sending error reports
|
||||
$bb_cfg['tech_admin_email'] = "admin@$domain_name"; // email for sending error reports
|
||||
$bb_cfg['abuse_email'] = "abuse@$domain_name";
|
||||
$bb_cfg['adv_email'] = "adv@$domain_name";
|
||||
|
||||
|
|
|
@ -2,29 +2,47 @@
|
|||
|
||||
if (!defined('BB_ROOT')) die(basename(__FILE__));
|
||||
|
||||
// Get complete counts
|
||||
DB()->query("
|
||||
CREATE TEMPORARY TABLE tmp_complete_count
|
||||
SELECT
|
||||
topic_id, COUNT(*) AS compl_cnt
|
||||
FROM ". BB_BT_TORSTAT ."
|
||||
WHERE completed = 0
|
||||
GROUP BY topic_id
|
||||
");
|
||||
global $bb_cfg;
|
||||
|
||||
// Update USER "completed" counters
|
||||
DB()->query("UPDATE ". BB_BT_TORSTAT ." SET completed = 1");
|
||||
if ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
// Update TORRENT "completed" counters
|
||||
DB()->query("
|
||||
UPDATE
|
||||
". BB_BT_TORRENTS ." tor,
|
||||
". BB_BT_TRACKER_SNAP. " snap
|
||||
SET
|
||||
tor.complete_count = snap.complete
|
||||
WHERE
|
||||
tor.topic_id = snap.topic_id
|
||||
");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get complete counts
|
||||
DB()->query("
|
||||
CREATE TEMPORARY TABLE tmp_complete_count
|
||||
SELECT
|
||||
topic_id, COUNT(*) AS compl_cnt
|
||||
FROM ". BB_BT_TORSTAT ."
|
||||
WHERE completed = 0
|
||||
GROUP BY topic_id
|
||||
");
|
||||
|
||||
// Update TORRENT "completed" counters
|
||||
DB()->query("
|
||||
UPDATE
|
||||
". BB_BT_TORRENTS ." tor,
|
||||
tmp_complete_count tmp
|
||||
SET
|
||||
tor.complete_count = tor.complete_count + tmp.compl_cnt
|
||||
WHERE
|
||||
tor.topic_id = tmp.topic_id
|
||||
");
|
||||
// Update USER "completed" counters
|
||||
DB()->query("UPDATE ". BB_BT_TORSTAT ." SET completed = 1");
|
||||
|
||||
// Drop tmp table
|
||||
DB()->query("DROP TEMPORARY TABLE tmp_complete_count");
|
||||
// Update TORRENT "completed" counters
|
||||
DB()->query("
|
||||
UPDATE
|
||||
". BB_BT_TORRENTS ." tor,
|
||||
tmp_complete_count tmp
|
||||
SET
|
||||
tor.complete_count = tor.complete_count + tmp.compl_cnt
|
||||
WHERE
|
||||
tor.topic_id = tmp.topic_id
|
||||
");
|
||||
|
||||
// Drop tmp table
|
||||
DB()->query("DROP TEMPORARY TABLE tmp_complete_count");
|
||||
}
|
|
@ -2,17 +2,21 @@
|
|||
|
||||
if (!defined('BB_ROOT')) die(basename(__FILE__));
|
||||
|
||||
global $bb_cfg;
|
||||
|
||||
DB()->expect_slow_query(600);
|
||||
|
||||
//
|
||||
// Make tracker snapshot
|
||||
//
|
||||
define('NEW_BB_BT_TRACKER_SNAP', 'new_tracker_snap');
|
||||
define('OLD_BB_BT_TRACKER_SNAP', 'old_tracker_snap');
|
||||
if (!$bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
define('NEW_BB_BT_TRACKER_SNAP', 'new_tracker_snap');
|
||||
define('OLD_BB_BT_TRACKER_SNAP', 'old_tracker_snap');
|
||||
|
||||
DB()->query("DROP TABLE IF EXISTS ". NEW_BB_BT_TRACKER_SNAP .", ". OLD_BB_BT_TRACKER_SNAP);
|
||||
|
||||
DB()->query("CREATE TABLE ". NEW_BB_BT_TRACKER_SNAP ." LIKE ". BB_BT_TRACKER_SNAP);
|
||||
DB()->query("DROP TABLE IF EXISTS " . NEW_BB_BT_TRACKER_SNAP . ", " . OLD_BB_BT_TRACKER_SNAP);
|
||||
DB()->query("CREATE TABLE " . NEW_BB_BT_TRACKER_SNAP . " LIKE " . BB_BT_TRACKER_SNAP);
|
||||
}
|
||||
|
||||
$per_cycle = 50000;
|
||||
$row = DB()->fetch_row("SELECT MIN(topic_id) AS start_id, MAX(topic_id) AS finish_id FROM ". BB_BT_TRACKER);
|
||||
|
@ -25,44 +29,78 @@ while (true)
|
|||
$end_id = $start_id + $per_cycle - 1;
|
||||
|
||||
$val = array();
|
||||
$sql = "
|
||||
SELECT
|
||||
topic_id, SUM(seeder) AS seeders, (COUNT(*) - SUM(seeder)) AS leechers,
|
||||
SUM(speed_up) AS speed_up, SUM(speed_down) AS speed_down
|
||||
FROM ". BB_BT_TRACKER ."
|
||||
WHERE topic_id BETWEEN $start_id AND $end_id
|
||||
GROUP BY topic_id
|
||||
";
|
||||
|
||||
if (!$bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
topic_id, SUM(seeder) AS seeders, (COUNT(*) - SUM(seeder)) AS leechers,
|
||||
SUM(speed_up) AS speed_up, SUM(speed_down) AS speed_down
|
||||
FROM " . BB_BT_TRACKER . "
|
||||
WHERE topic_id BETWEEN $start_id AND $end_id
|
||||
GROUP BY topic_id
|
||||
";
|
||||
}
|
||||
else
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
topic_id, SUM(speed_up) AS speed_up, SUM(speed_down) AS speed_down
|
||||
FROM " . BB_BT_TRACKER . "
|
||||
WHERE topic_id BETWEEN $start_id AND $end_id
|
||||
GROUP BY topic_id
|
||||
";
|
||||
}
|
||||
|
||||
foreach (DB()->fetch_rowset($sql) as $row)
|
||||
{
|
||||
$val[] = join(',', $row);
|
||||
}
|
||||
|
||||
if ($val)
|
||||
{
|
||||
DB()->query("
|
||||
REPLACE INTO ". NEW_BB_BT_TRACKER_SNAP ."
|
||||
(topic_id, seeders, leechers, speed_up, speed_down)
|
||||
VALUES(". join('),(', $val) .")
|
||||
");
|
||||
if (!$bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
DB()->query("
|
||||
REPLACE INTO " . NEW_BB_BT_TRACKER_SNAP . "
|
||||
(topic_id, seeders, leechers, speed_up, speed_down)
|
||||
VALUES(" . join('),(', $val) . ")
|
||||
");
|
||||
}
|
||||
else
|
||||
{
|
||||
DB()->query("
|
||||
INSERT INTO " . BB_BT_TRACKER_SNAP . "
|
||||
(topic_id, speed_up, speed_down)
|
||||
VALUES(". join('),(', $val) .")
|
||||
ON DUPLICATE KEY UPDATE speed_up = VALUES(speed_up), speed_down = VALUES(speed_down)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
if ($end_id > $finish_id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!($start_id % ($per_cycle*10)))
|
||||
{
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
$start_id += $per_cycle;
|
||||
}
|
||||
|
||||
DB()->query("
|
||||
RENAME TABLE
|
||||
". BB_BT_TRACKER_SNAP ." TO ". OLD_BB_BT_TRACKER_SNAP .",
|
||||
". NEW_BB_BT_TRACKER_SNAP ." TO ". BB_BT_TRACKER_SNAP ."
|
||||
");
|
||||
if (!$bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
DB()->query("
|
||||
RENAME TABLE
|
||||
". BB_BT_TRACKER_SNAP ." TO ". OLD_BB_BT_TRACKER_SNAP .",
|
||||
". NEW_BB_BT_TRACKER_SNAP ." TO ". BB_BT_TRACKER_SNAP ."
|
||||
");
|
||||
|
||||
DB()->query("DROP TABLE IF EXISTS ". NEW_BB_BT_TRACKER_SNAP .", ". OLD_BB_BT_TRACKER_SNAP);
|
||||
DB()->query("DROP TABLE IF EXISTS ". NEW_BB_BT_TRACKER_SNAP .", ". OLD_BB_BT_TRACKER_SNAP);
|
||||
}
|
||||
|
||||
//
|
||||
// Make dl-list snapshot
|
||||
|
|
|
@ -61,7 +61,7 @@ function tracker_unregister ($attach_id, $mode = '')
|
|||
global $lang, $bb_cfg;
|
||||
|
||||
$attach_id = (int) $attach_id;
|
||||
$post_id = $topic_id = $forum_id = null;
|
||||
$post_id = $topic_id = $forum_id = $info_hash = null;
|
||||
|
||||
// Get torrent info
|
||||
if ($torrent = get_torrent_info($attach_id))
|
||||
|
@ -117,6 +117,16 @@ function tracker_unregister ($attach_id, $mode = '')
|
|||
bb_die('Could not delete peers');
|
||||
}
|
||||
|
||||
// Ocelot
|
||||
if ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
if ($row = DB()->fetch_row("SELECT info_hash FROM ". BB_BT_TORRENTS ." WHERE attach_id = $attach_id LIMIT 1"))
|
||||
{
|
||||
$info_hash = $row['info_hash'];
|
||||
}
|
||||
ocelot_update_tracker('delete_torrent', array('info_hash' => rawurlencode($info_hash), 'id' => $topic_id));
|
||||
}
|
||||
|
||||
// Delete torrent
|
||||
$sql = "DELETE FROM ". BB_BT_TORRENTS ." WHERE attach_id = $attach_id";
|
||||
|
||||
|
@ -206,9 +216,21 @@ function change_tor_type ($attach_id, $tor_status_gold)
|
|||
|
||||
if (!IS_AM) bb_die($lang['ONLY_FOR_MOD']);
|
||||
|
||||
$topic_id = $torrent['topic_id'];
|
||||
$topic_id = $torrent['topic_id'];
|
||||
$tor_status_gold = intval($tor_status_gold);
|
||||
$info_hash = null;
|
||||
|
||||
DB()->query("UPDATE ". BB_BT_TORRENTS ." SET tor_type = $tor_status_gold WHERE topic_id = $topic_id LIMIT 1");
|
||||
|
||||
// Ocelot
|
||||
if ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
if ($row = DB()->fetch_row("SELECT info_hash FROM ". BB_BT_TORRENTS ." WHERE topic_id = $topic_id LIMIT 1"))
|
||||
{
|
||||
$info_hash = $row['info_hash'];
|
||||
}
|
||||
ocelot_update_tracker('update_torrent', array('info_hash' => rawurlencode($info_hash), 'freetorrent' => $tor_status_gold));
|
||||
}
|
||||
}
|
||||
|
||||
function tracker_register ($attach_id, $mode = '', $tor_status = TOR_NOT_APPROVED, $reg_time = TIMENOW)
|
||||
|
@ -227,6 +249,7 @@ function tracker_register ($attach_id, $mode = '', $tor_status = TOR_NOT_APPROVE
|
|||
$topic_id = $torrent['topic_id'];
|
||||
$forum_id = $torrent['forum_id'];
|
||||
$poster_id = $torrent['poster_id'];
|
||||
$info_hash = null;
|
||||
|
||||
if ($torrent['extension'] !== TORRENT_EXT) return torrent_error_exit($lang['NOT_TORRENT']);
|
||||
if (!$torrent['allow_reg_tracker']) return torrent_error_exit($lang['REG_NOT_ALLOWED_IN_THIS_FORUM']);
|
||||
|
@ -275,6 +298,12 @@ function tracker_register ($attach_id, $mode = '', $tor_status = TOR_NOT_APPROVE
|
|||
$info_hash_sql = rtrim(DB()->escape($info_hash), ' ');
|
||||
$info_hash_md5 = md5($info_hash);
|
||||
|
||||
// Ocelot
|
||||
if ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
ocelot_update_tracker('add_torrent', array('info_hash' => rawurlencode($info_hash), 'id' => $topic_id, 'freetorrent' => 0));
|
||||
}
|
||||
|
||||
if ($row = DB()->fetch_row("SELECT topic_id FROM ". BB_BT_TORRENTS ." WHERE info_hash = '$info_hash_sql' LIMIT 1"))
|
||||
{
|
||||
$msg = sprintf($lang['BT_REG_FAIL_SAME_HASH'], TOPIC_URL . $row['topic_id']);
|
||||
|
@ -411,6 +440,10 @@ function send_torrent_with_passkey ($filename)
|
|||
{
|
||||
bb_simple_die('Could not generate passkey');
|
||||
}
|
||||
elseif ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
ocelot_update_tracker('add_user', array('id' => $user_id ,'passkey' => $passkey_val));
|
||||
}
|
||||
}
|
||||
|
||||
// Ratio limits
|
||||
|
@ -443,7 +476,7 @@ function send_torrent_with_passkey ($filename)
|
|||
bb_die('This is not a bencoded file');
|
||||
}
|
||||
|
||||
$announce = strval($ann_url . "?$passkey_key=$passkey_val");
|
||||
$announce = $bb_cfg['ocelot']['enabled'] ? strval($bb_cfg['ocelot']['url'] .$passkey_val. "/announce") : strval($ann_url . "?$passkey_key=$passkey_val");
|
||||
|
||||
// Replace original announce url with tracker default
|
||||
if ($bb_cfg['bt_replace_ann_url'] || !isset($tor['announce']))
|
||||
|
@ -464,7 +497,7 @@ function send_torrent_with_passkey ($filename)
|
|||
// Add retracker
|
||||
if (isset($tr_cfg['retracker']) && $tr_cfg['retracker'])
|
||||
{
|
||||
if (bf($userdata['user_opt'], 'user_opt', 'user_retracker'))
|
||||
if (bf($userdata['user_opt'], 'user_opt', 'user_retracker') || IS_GUEST)
|
||||
{
|
||||
if (!isset($tor['announce-list']))
|
||||
{
|
||||
|
@ -513,7 +546,7 @@ function send_torrent_with_passkey ($filename)
|
|||
|
||||
function generate_passkey ($user_id, $force_generate = false)
|
||||
{
|
||||
global $lang, $sql;
|
||||
global $bb_cfg, $lang, $sql;
|
||||
|
||||
$user_id = (int) $user_id;
|
||||
|
||||
|
@ -538,6 +571,12 @@ function generate_passkey ($user_id, $force_generate = false)
|
|||
for ($i=0; $i < 20; $i++)
|
||||
{
|
||||
$passkey_val = make_rand_str(BT_AUTH_KEY_LENGTH);
|
||||
$old_passkey = null;
|
||||
|
||||
if ($row = DB()->fetch_row("SELECT auth_key FROM ". BB_BT_USERS ." WHERE user_id = $user_id LIMIT 1"))
|
||||
{
|
||||
$old_passkey = $row['auth_key'];
|
||||
}
|
||||
|
||||
// Insert new row
|
||||
DB()->query("INSERT IGNORE INTO ". BB_BT_USERS ." (user_id, auth_key) VALUES ($user_id, '$passkey_val')");
|
||||
|
@ -551,6 +590,11 @@ function generate_passkey ($user_id, $force_generate = false)
|
|||
|
||||
if (DB()->affected_rows() == 1)
|
||||
{
|
||||
// Ocelot
|
||||
if ($bb_cfg['ocelot']['enabled'])
|
||||
{
|
||||
ocelot_update_tracker('change_passkey', array('oldpasskey' => $old_passkey,'newpasskey' => $passkey_val));
|
||||
}
|
||||
return $passkey_val;
|
||||
}
|
||||
}
|
||||
|
@ -606,6 +650,86 @@ function torrent_error_exit ($message)
|
|||
bb_die($msg . $message);
|
||||
}
|
||||
|
||||
function ocelot_update_tracker ($action, $updates)
|
||||
{
|
||||
global $bb_cfg;
|
||||
|
||||
$get = $bb_cfg['ocelot']['secret'] . "/update?action=$action";
|
||||
|
||||
foreach ($updates as $key => $value)
|
||||
{
|
||||
$get .= "&$key=$value";
|
||||
}
|
||||
|
||||
$max_attempts = 3;
|
||||
$err = false;
|
||||
|
||||
if (ocelot_send_request($get, $max_attempts, $err) === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ocelot_send_request ($get, $max_attempts = 1, &$err = false)
|
||||
{
|
||||
global $bb_cfg;
|
||||
|
||||
$header = "GET /$get HTTP/1.1\r\nConnection: Close\r\n\r\n";
|
||||
$attempts = $sleep = $success = $response = 0;
|
||||
$start_time = microtime(true);
|
||||
|
||||
while (!$success && $attempts++ < $max_attempts)
|
||||
{
|
||||
if ($sleep)
|
||||
{
|
||||
sleep($sleep);
|
||||
}
|
||||
|
||||
// Send request
|
||||
$file = fsockopen($bb_cfg['ocelot']['host'], $bb_cfg['ocelot']['port'], $error_num, $error_string);
|
||||
if ($file)
|
||||
{
|
||||
if (fwrite($file, $header) === false)
|
||||
{
|
||||
$err = "Failed to fwrite()";
|
||||
$sleep = 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$err = "Failed to fsockopen() - $error_num - $error_string";
|
||||
$sleep = 6;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for response
|
||||
while (!feof($file))
|
||||
{
|
||||
$response .= fread($file, 1024);
|
||||
}
|
||||
$data_start = strpos($response, "\r\n\r\n") + 4;
|
||||
$data_end = strrpos($response, "\n");
|
||||
if ($data_end > $data_start)
|
||||
{
|
||||
$data = substr($response, $data_start, $data_end - $data_start);
|
||||
}
|
||||
else
|
||||
{
|
||||
$data = "";
|
||||
}
|
||||
$status = substr($response, $data_end + 1);
|
||||
if ($status == "success")
|
||||
{
|
||||
$success = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
// bdecode: based on OpenTracker
|
||||
function bdecode_file ($filename)
|
||||
{
|
||||
|
|
|
@ -83,8 +83,7 @@ function smtpmail($mail_to, $subject, $message, $headers = '')
|
|||
bb_die('Email message was blank');
|
||||
}
|
||||
|
||||
// Ok we have error checked as much as we can to this point let's get on
|
||||
// it already.
|
||||
// Ok we have error checked as much as we can to this point let's get on it already
|
||||
$ssl = ($bb_cfg['smtp_ssl']) ? 'ssl://' : '';
|
||||
if( !$socket = @fsockopen($ssl . $bb_cfg['smtp_host'], $bb_cfg['smtp_port'], $errno, $errstr, 20) )
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue