Commit abf7782c authored by Dominik Hebeler's avatar Dominik Hebeler

Removed Searcher Job

parent 68d0815c
......@@ -41,4 +41,4 @@ CMD /etc/init.d/cron start && \
/etc/init.d/redis-server start && \
chmod -R 0777 /html/storage && \
chmod -R 0777 /html/bootstrap/cache && \
php artisan worker:spawner
php artisan requests:fetcher
......@@ -2,8 +2,10 @@
namespace App\Console\Commands;
use Cache;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
use Log;
class WorkerSpawner extends Command
{
......@@ -12,7 +14,7 @@ class WorkerSpawner extends Command
*
* @var string
*/
protected $signature = 'worker:spawner';
protected $signature = 'requests:fetcher';
/**
* The console command description.
......@@ -22,7 +24,8 @@ class WorkerSpawner extends Command
protected $description = 'This command makes sure that enough worker processes are spawned';
protected $shouldRun = true;
protected $processes = [];
protected $multicurl = null;
protected $proxyhost, $proxyuser, $proxypassword;
/**
* Create a new command instance.
......@@ -32,6 +35,12 @@ class WorkerSpawner extends Command
public function __construct()
{
parent::__construct();
$this->multicurl = curl_multi_init();
$this->proxyhost = env("PROXY_HOST", "");
$this->proxyport = env("PROXY_PORT", "");
$this->proxyuser = env("PROXY_USER", "");
$this->proxypassword = env("PROXY_PASSWORD", "");
}
/**
......@@ -47,100 +56,104 @@ class WorkerSpawner extends Command
pcntl_signal(SIGHUP, [$this, "sig_handler"]);
try {
$counter = 0;
$blocking = false;
while ($this->shouldRun) {
$counter++;
$counter = $counter % 10;
$length = Redis::llen("queues:default");
if ($length > 0) {
while (true) {
usleep(50 * 1000);
if (Redis::llen("queues:default") !== $length) {
$length = Redis::llen("queues:default");
} else {
break;
}
$status = curl_multi_exec($this->multicurl, $active);
$currentJob = null;
if (!$blocking) {
$currentJob = Redis::lpop(\App\MetaGer::FETCHQUEUE_KEY);
} else {
$currentJob = Redis::blpop(\App\MetaGer::FETCHQUEUE_KEY, 10);
if (!empty($currentJob)) {
$currentJob = $currentJob[1];
}
$jobs = Redis::lrange("queues:default", 0, -1);
$length = sizeof($jobs) + 5;
$ids = $this->getJobIds($jobs);
for ($i = 0; $i <= $length; $i++) {
$this->processes[] = $this->spawnWorker();
}
if (!empty($currentJob)) {
$currentJob = json_decode($currentJob, true);
$ch = $this->getCurlHandle($currentJob);
curl_multi_add_handle($this->multicurl, $ch);
$blocking = false;
$active = true;
}
$answerRead = false;
while (($info = curl_multi_info_read($this->multicurl)) !== false) {
$answerRead = true;
$infos = curl_getinfo($info["handle"], CURLINFO_PRIVATE);
$infos = explode(";", $infos);
$resulthash = $infos[0];
$cacheDuration = intval($infos[1]);
$responseCode = curl_getinfo($info["handle"], CURLINFO_HTTP_CODE);
$body = "";
$error = curl_error($info["handle"]);
if (!empty($error)) {
Log::error($error);
}
while (sizeof($ids) > 0) {
$jobs = Redis::lrange("queues:default", 0, -1);
$newIds = $this->getJobIds($jobs);
foreach ($ids as $index => $id) {
foreach ($newIds as $newId) {
if ($id === $newId) {
continue 2;
}
}
unset($ids[$index]);
break;
}
if ($responseCode !== 200) {
Log::debug("Got responsecode " . $responseCode . " fetching \"" . curl_getinfo($info["handle"], CURLINFO_EFFECTIVE_URL) . "\n");
} else {
$body = \curl_multi_getcontent($info["handle"]);
}
} else {
usleep(100 * 1000); // Sleep for 100ms
Cache::put($resulthash, $body, now()->addMinutes($cacheDuration));
\curl_multi_remove_handle($this->multicurl, $info["handle"]);
}
if ($counter === 0) {
$newProcs = [];
foreach ($this->processes as $process) {
$infos = proc_get_status($process["process"]);
if (!$infos["running"]) {
fclose($process["pipes"][1]);
proc_close($process["process"]);
} else {
$newProcs[] = $process;
}
}
$this->processes = $newProcs;
if (!$active && !$answerRead) {
$blocking = true;
}
}
} finally {
foreach ($this->processes as $process) {
fclose($process["pipes"][1]);
proc_close($process["process"]);
}
curl_multi_close($this->multicurl);
}
}
private function getJobIds($jobs)
private function getCurlHandle($job)
{
$result = [];
foreach ($jobs as $job) {
$result[] = json_decode($job, true)["id"];
$ch = curl_init();
curl_setopt_array($ch, array(
CURLOPT_URL => $job["url"],
CURLOPT_PRIVATE => $job["resulthash"] . ";" . $job["cacheDuration"],
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_USERAGENT => "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1",
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_MAXCONNECTS => 500,
CURLOPT_LOW_SPEED_LIMIT => 500,
CURLOPT_LOW_SPEED_TIME => 5,
CURLOPT_TIMEOUT => 10,
));
if (!empty($this->proxyhost) && !empty($this->proxyport) && !empty($this->proxyuser) && !empty($this->proxypassword)) {
curl_setopt($ch, CURLOPT_PROXY, $this->proxyhost);
curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->proxyuser . ":" . $this->proxypassword);
curl_setopt($ch, CURLOPT_PROXYPORT, $this->proxyport);
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5);
}
if (!empty($job["username"]) && !empty($job["password"])) {
curl_setopt($ch, CURLOPT_USERPWD, $job["username"] . ":" . $job["password"]);
}
if (!empty($job["headers"])) {
$headers = [];
foreach ($job["headers"] as $key => $value) {
$headers[] = $key . ":" . $value;
}
# Headers are in the Form:
# <key>:<value>;<key>:<value>
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
return $result;
return $ch;
}
private function sig_handler($sig)
public function sig_handler($sig)
{
$this->shouldRun = false;
echo ("Terminating Process\n");
}
private function spawnWorker()
{
$descriptorspec = array(
0 => array("pipe", "r"), // STDIN ist eine Pipe, von der das Child liest
1 => array("pipe", "w"), // STDOUT ist eine Pipe, in die das Child schreibt
2 => array("file", "/tmp/worker-error.txt", "a"), // STDERR ist eine Datei,
// in die geschrieben wird
);
$cwd = getcwd();
$env = array();
$process = proc_open('php artisan queue:work --stop-when-empty --sleep=1', $descriptorspec, $pipes, $cwd, $env);
if (is_resource($process)) {
fclose($pipes[0]);
\stream_set_blocking($pipes[1], 0);
return [
"process" => $process,
"pipes" => $pipes,
"working" => false,
];
}
}
}
......@@ -14,6 +14,7 @@ class MetaGerSearch extends Controller
{
public function search(Request $request, MetaGer $metager)
{
$time = microtime(true);
$spamEntries = [];
if (file_exists(config_path('spam.txt'))) {
$spamEntries = file(config_path('spam.txt'));
......
This diff is collapsed.
......@@ -6,7 +6,6 @@ use App;
use Cache;
use Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Jenssegers\Agent\Agent;
use LaravelLocalization;
use Log;
......@@ -14,6 +13,8 @@ use Predis\Connection\ConnectionException;
class MetaGer
{
const FETCHQUEUE_KEY = "fetcher.queue";
# Einstellungen für die Suche
public $alteredQuery = "";
public $alterationOverrideQuery = "";
......@@ -780,13 +781,12 @@ class MetaGer
public function waitForMainResults()
{
$redis = Redis::connection(env('REDIS_RESULT_CONNECTION'));
$engines = $this->engines;
$enginesToWaitFor = [];
$mainEngines = $this->sumaFile->foki->{$this->fokus}->main;
foreach ($mainEngines as $mainEngine) {
foreach ($engines as $engine) {
if (!$engine->cached && $engine->name === $mainEngine) {
if ($engine->name === $mainEngine) {
$enginesToWaitFor[] = $engine;
}
}
......@@ -803,41 +803,38 @@ class MetaGer
}
while (sizeof($enginesToWaitFor) > 0 || ($forceTimeout !== null && (microtime(true) - $timeStart) < $forceTimeout)) {
$newEngine = $redis->blpop($this->redisResultWaitingKey, 1);
if ($newEngine === null || sizeof($newEngine) !== 2) {
continue;
} else {
$newEngine = $newEngine[1];
foreach ($enginesToWaitFor as $index => $engine) {
if ($engine->name === $newEngine) {
unset($enginesToWaitFor[$index]);
break;
}
Log::info(sizeof($enginesToWaitFor) . " " . sizeof($answered) . " " . $enginesToWaitFor[0]->hash);
foreach ($enginesToWaitFor as $index => $engine) {
if (Cache::has($engine->hash)) {
$answered[] = $engine;
unset($enginesToWaitFor[$index]);
break;
}
$answered[] = $newEngine;
}
if ((microtime(true) - $timeStart) >= 2) {
break;
} else {
usleep(50 * 1000);
}
}
# Now we can add an entry to Redis which defines the starting time and how many engines should answer this request
$pipeline = $redis->pipeline();
$pipeline->hset($this->getRedisEngineResult() . "status", "startTime", $timeStart);
$pipeline->hset($this->getRedisEngineResult() . "status", "engineCount", sizeof($engines));
$pipeline->hset($this->getRedisEngineResult() . "status", "engineDelivered", sizeof($answered));
# Add the cached engines as answered
foreach ($engines as $engine) {
if ($engine->cached) {
$pipeline->hincrby($this->getRedisEngineResult() . "status", "engineDelivered", 1);
$pipeline->hincrby($this->getRedisEngineResult() . "status", "engineAnswered", 1);
}
}
foreach ($answered as $engine) {
$pipeline->hset($this->getRedisEngineResult() . $engine, "delivered", "1");
}
$pipeline->execute();
/*
$pipeline = $redis->pipeline();
$pipeline->hset($this->getRedisEngineResult() . "status", "startTime", $timeStart);
$pipeline->hset($this->getRedisEngineResult() . "status", "engineCount", sizeof($engines));
$pipeline->hset($this->getRedisEngineResult() . "status", "engineDelivered", sizeof($answered));
# Add the cached engines as answered
foreach ($engines as $engine) {
if ($engine->cached) {
$pipeline->hincrby($this->getRedisEngineResult() . "status", "engineDelivered", 1);
$pipeline->hincrby($this->getRedisEngineResult() . "status", "engineAnswered", 1);
}
}
foreach ($answered as $engine) {
$pipeline->hset($this->getRedisEngineResult() . $engine, "delivered", "1");
}
$pipeline->execute();*/
}
public function retrieveResults()
......
......@@ -2,16 +2,12 @@
namespace App\Models;
use App\Jobs\Searcher;
use App\MetaGer;
use Cache;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Support\Facades\Redis;
abstract class Searchengine
{
use DispatchesJobs;
public $getString = ""; # Der String für die Get-Anfrage
public $engine; # Die ursprüngliche Engine XML
public $totalResults = 0; # How many Results the Searchengine has found
......@@ -40,17 +36,17 @@ abstract class Searchengine
public $counter = 0; # Wird eventuell für Artefakte benötigt
public $write_time = 0; # Wird eventuell für Artefakte benötigt
public $connection_time = 0; # Wird eventuell für Artefakte benötigt
public $cacheDuration = 60; # Wie lange soll das Ergebnis im Cache bleiben (Minuten)
public function __construct($name, \stdClass $engine, MetaGer $metager)
{
$this->engine = $engine;
$this->name = $name;
# Cache Standarddauer 60
$this->cacheDuration = 60;
if (isset($engine->{"cache-duration"}) && $engine->{"cache-duration"} !== -1) {
$this->cacheDuration = $engine->{"cache-duration"};
}
$this->cacheDuration = max($this->cacheDuration, 5);
$this->useragent = $metager->getUserAgent();
$this->ip = $metager->getIp();
......@@ -61,7 +57,12 @@ abstract class Searchengine
$this->password = $this->engine->{"http-auth-credentials"}->password;
}
$this->headers = $this->engine->{"request-header"};
if ($this->engine->{"request-header"}) {
$this->headers = [];
foreach ($this->headers as $key => $value) {
$this->headers[$key] = $value;
}
}
# Suchstring generieren
$q = $metager->getQ();
......@@ -87,7 +88,6 @@ abstract class Searchengine
$this->getString = $this->generateGetString($q);
$this->updateHash();
$this->resultHash = $metager->getSearchUid();
$this->canCache = $metager->canCache();
}
......@@ -95,7 +95,7 @@ abstract class Searchengine
# Standardimplementierung der getNext Funktion, damit diese immer verwendet werden kann
public function getNext(MetaGer $metager, $result)
{ }
{}
# Prüft, ob die Suche bereits gecached ist, ansonsted wird sie als Job dispatched
public function startSearch(\App\MetaGer $metager)
......@@ -104,11 +104,6 @@ abstract class Searchengine
$this->cached = true;
$this->retrieveResults($metager);
} else {
$redis = Redis::connection(env('REDIS_RESULT_CONNECTION'));
// We will push the confirmation of the submission to the Result Hash
$redis->hset($metager->getRedisEngineResult() . $this->name, "status", "waiting");
$redis->expire($metager->getRedisEngineResult() . $this->name, env('REDIS_RESULT_CACHE_DURATION'));
// We need to submit a action that one of our workers can understand
// The missions are submitted to a redis queue in the following string format
// <ResultHash>;<URL to fetch>
......@@ -126,62 +121,27 @@ abstract class Searchengine
$url .= ":" . $this->engine->port;
}
$url .= $this->getString;
$url = base64_encode($url);
$mission = $this->resultHash . ";" . $url . ";" . $metager->getTime();
$mission = [
"resulthash" => $this->hash,
"url" => $url,
"username" => $this->username,
"password" => $this->password,
"headers" => $this->headers,
"cacheDuration" => $this->cacheDuration,
];
$mission = json_encode($mission);
// Submit this mission to the corresponding Redis Queue
// Since each Searcher is dedicated to one specific search engine
// each Searcher has it's own queue lying under the redis key <name>.queue
Redis::rpush($this->name . ".queue", $mission);
Redis::rpush(\App\MetaGer::FETCHQUEUE_KEY, $mission);
// The request is not cached and will be submitted to the searchengine
// We need to check if the number of requests to this engine are limited
if (!empty($this->engine->{"monthly-requests"})) {
Redis::incr("monthlyRequests:" . $this->name);
}
/**
* We have Searcher processes running for MetaGer
* Each Searcher is dedicated to one specific Searchengine and fetches it's results.
* We can have multiple Searchers for each engine, if needed.
* At this point we need to decide, whether we need to start a new Searcher process or
* if we have enough of them running.
* The information for that is provided through the redis system. Each running searcher
* gives information how long it has waited to be given the last fetcher job.
* The longer this time value is, the less frequent the search engine is used and the less
* searcher of that type we need.
* But if it's too low, i.e. 100ms, then the searcher is near to it's full workload and needs assistence.
**/
$needSearcher = false;
$searcherData = Redis::hgetall($this->name . ".stats");
// We now have an array of statistical data from the searchers
// Each searcher has one entry in it.
// So if it's empty, then we have currently no searcher running and
// of course need to spawn a new one.
if (sizeof($searcherData) === 0) {
$needSearcher = true;
} else {
// There we go:
// There's at least one Fetcher running for this search engine.
// Now we have to check if the current count is enough to fetch all the
// searches or if it needs help.
// Let's hardcode a minimum of 100ms between every search job.
// First calculate the median of all Times
$median = 0;
foreach ($searcherData as $pid => $data) {
$data = explode(";", $data);
$median += floatval($data[1]);
}
$median /= sizeof($searcherData);
if ($median < .1) {
$needSearcher = true;
}
}
if ($needSearcher && Redis::get($this->name) !== "locked") {
Redis::set($this->name, "locked");
$this->dispatch(new Searcher($this->name, $this->username, $this->password, $this->headers));
}
}
}
......@@ -210,18 +170,21 @@ abstract class Searchengine
return true;
}
$body = "";
$redis = Redis::connection(env('REDIS_RESULT_CONNECTION'));
$body = null;
if ($this->canCache && $this->cacheDuration > 0 && Cache::has($this->hash)) {
if (Cache::has($this->hash)) {
$body = Cache::get($this->hash);
}
/*
if ($this->canCache && $this->cacheDuration > 0 && Cache::has($this->hash)) {
$body = Cache::get($this->hash);
} elseif ($redis->hexists($metager->getRedisEngineResult() . $this->name, "response")) {
$body = $redis->hget($metager->getRedisEngineResult() . $this->name, "response");
if ($this->canCache && $this->cacheDuration > 0) {
Cache::put($this->hash, $body, $this->cacheDuration);
}
$body = $redis->hget($metager->getRedisEngineResult() . $this->name, "response");
if ($this->canCache && $this->cacheDuration > 0) {
Cache::put($this->hash, $body, $this->cacheDuration);
}
if ($body !== "" && $body !== "connected" && $body !== "waiting") {
}*/
if ($body !== null) {
$this->loadResults($body);
$this->getNext($metager, $body);
$this->loaded = true;
......
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>MetaGer - Mehr als eine Suchmaschine</title>
<meta name="description" content="Sicher suchen und finden unter Wahrung der Privatsphäre. Das digitale Wissen der Welt muss ohne Bevormundung durch Staaten oder Konzerne frei zugänglich sein und bleiben." />
<meta name="keywords" content="Internetsuche, privatsphäre, privacy, Suchmaschine, Datenschutz, Anonproxy, anonym suchen, Bildersuche, Suchmaschine, anonym, MetaGer, metager, metager.de" />
<meta name="page-topic" content="Dienstleistung" />
<meta name="robots" content="index,follow" />
<meta name="revisit-after" content="7 days" />
<meta name="audience" content="all" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link href="/favicon.ico" rel="icon" type="image/x-icon" />
<link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
<link rel="search" type="application/opensearchdescription+xml" title="MetaGer: Sicher suchen &amp; finden, Privatsphäre schützen" href="https://metager.de/plugins/opensearch.xml">
<link type="text/css" rel="stylesheet" href="/css/bootstrap.css?id=b803963ec1e03b9de08e" />
<link type="text/css" rel="stylesheet" href="/css/themes/metager.css?id=35b998573f409cb5260f" />
<link type="text/css" rel="stylesheet" href="/css/utility.css?id=119a7732fcac8ee992c0" />
<link href="/fonts/liberationsans/stylesheet.css" rel="stylesheet">
<link type="text/css" rel="stylesheet" href="/css/fontawesome.css?id=b9dfacd52a93d4406a21" />
<link type="text/css" rel="stylesheet" href="/css/fontawesome-solid.css?id=ef93547e15423c41f724" />
<script src="/js/lib.js?id=8794dbf6d3b10d784319"></script>
<script src="/js/utility.js?id=7fab2dc6a328a13d19a0"></script>
</head>
<body>
<header>
</header>
<div class="wrapper startpage">
<main id="main-content">
<h1 id="startpage-logo">
<a href="https://metager.de/">
<img src="/img/metager.svg" alt="MetaGer" />
</a>
</h1>
<fieldset>
<form id="searchForm" method=GET action="https://metager.de/meta/meta.ger3 " accept-charset="UTF-8">
<div class="searchbar startpage-searchbar">
<div class="search-input-submit">
<div id="search-key">
<a id="key-link" class="unauthorized" href="https://metager.de/meta/key?redirUrl=https%3A%2F%2Fmetager.de" data-tooltip="Mitglieder-Key eingeben" tabindex="0">
<i class="fa fa-key" aria-hidden="true"></i>
</a>
</div>
<div class="search-input">
<input type="search" name="eingabe" value="" required="" autofocus autocomplete="off" class="form-control" placeholder="MetaGer: Sicher suchen &amp; finden, Privatsphäre schützen" tabindex="0">
<button id="search-delete-btn" name="delete-search-input" type="button" tabindex="-1">
&#xd7;
</button>
</div>
<div class="search-submit" id="submit-inputgroup">
<button type="submit" tabindex="-1" name="submit-query" title="MetaGer-Suche" aria-label="MetaGer-Suche">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="search-hidden">
<input type="hidden" name="focus" value=web>
</div>
<div class="search-custom-hidden"></div>
</div>
</form>
</fieldset>
<div id="plugin-btn-div">
<a id="plugin-btn" href="https://metager.de/plugin" title="MetaGer zu Ihrem Browser hinzufügen"><i class="fa fa-plug" aria-hidden="true"></i> MetaGer-Plugin hinzufügen</a>
</div>
<div id="about-us">
<div class="m-row">
<a href="https://metager.de/about">
<img alt="lock" src="/img/metager-schloss.svg">
<span>Garantierte Privatsphäre</span>
<div class="teaser">Mit uns behalten Sie die volle Kontrolle über Ihre Daten. Wir speichern nicht und der Quellcode ist frei.</div>
</a>
<a href="https://suma-ev.de" target="_blank">
<img alt="rainbow" src="/img/rainbow.svg">
<span>Vielfältig & Frei</span>
<div class="teaser">MetaGer schützt gegen Zensur, indem es Ergebnisse vieler Suchmaschinen kombiniert.</div>
</a>
</div>
<div class="m-row">
<a href="https://www.hetzner.de/unternehmen/umweltschutz/" target="_blank">
<i class="fas fa-leaf" id="green-leaf"></i>
<span>100% Ökostrom</span>
<div class="teaser">Alle unsere Dienste sind mit Strom aus regenerativen Energiequellen betrieben. Nachhaltig und sauber.</div>
</a>
<a href="https://metager.de/spende">
<i class="fas fa-heart" id="gradient"></i>
<span>Gemeinnütziger Verein</span>
<div class="teaser">Unterstützen Sie MetaGer, indem Sie spenden oder Mitglied im gemeinnützigen Trägerverein werden.</div>
</a>
</div>
</div>
<a id="scroll-helper" href="#about-us">
<i class="fas fa-angle-double-down"></i>
</a>
</main>
</div>
<input id="sidebarToggle" class="hidden" type="checkbox">
<div class="sidebar">
<a class="sidebar-logo" href="https://metager.de/">
<span>
<img src="/img/metager.svg" alt="MetaGer"></img>
</span>
</a>
<ul class="sidebar-list">
<li>
<a href="https://metager.de/" id="navigationSuche">
<i class="fa fa-search" aria-hidden="true"></i>
<span>Suche</span>
</a>
</li>
<hr>
<li>
<a href="https://metager.de/datenschutz" id="navigationPrivacy" >
<i class="fa fa-user-secret" aria-hidden="true"></i>
<span>Datenschutz</span>
</a>
</li>
<li>
<a href="https://metager.de/hilfe" >
<i class="fa fa-info" aria-hidden="true"></i>
<span>Hilfe</span>
</a>
</li>
<hr>
<li>
<a href="https://metager.de/spende" >
<i class="fa fa-donate" aria-hidden="true"></i>
<span>Spenden</span>
</a>
</li>
<li>
<a href="https://metager.de/beitritt" >
<i class="fa fa-users" aria-hidden="true"></i>
<span>Mitglied werden</span>
</a>
</li>
<hr>
<li>
<a href="https://metager.de/app" >
<i class="fa fa-mobile-alt" aria-hidden="true"></i>
<span>MetaGer App</span>
</a>
</li>
<li>
<a class="inlink" href="https://maps.metager.de" target="_blank" >
<i class="fa fa-map" aria-hidden="true"></i>
<span>Maps.MetaGer.de</span>
</a>
</li>
<hr>
<li class="metager-dropdown">
<input id="contactToggle" class="sidebarCheckbox" type="checkbox">
<label for="contactToggle" class="metager-dropdown-toggle navigation-element" aria-haspopup="true" id="navigationKontakt" tabindex=0>
<i class="fa fa-comments" aria-hidden="true"></i>
<span>Kontakt</span>
<span class="caret"></span>
</label>
<ul class="metager-dropdown-content">
<li>
<a href="https://metager.de/kontakt" >Kontakt</a>
</li>
<li>
<a href="https://metager.de/team" >Team</a>
</li>
<li>
<a href="https://metager.de/about" >Über uns</a>
</li>
<li>
<a href="https://metager.de/impressum" >Impressum</a>
</li>
</ul>
</li>
<li class="metager-dropdown">
<input id="servicesToggle" class="sidebarCheckbox" type="checkbox">
<label for="servicesToggle" class="metager-dropdown-toggle navigation-element" aria-haspopup="true" tabindex=0>
<i class="fa fa-wrench" aria-hidden="true"></i>
<span>Dienste</span>
<span class="caret"></span>
</label>
<ul class="metager-dropdown-content">
<li>
<a href="https://metager.de/plugin" >MetaGer Plugin</a>
</li>
<li>
<a href="https://metager.de/widget" >Widget</a>
</li>
<li>
<a href="https://metager.de/zitat-suche" >Zitatsuche</a>
</li>
<li>
<a href="https://metager.de/asso" >Assoziator</a>
</li>
<li>
<a href="https://metager.de/tips" >Tips</a>
</li>
<li>
<a class="outlink" href="https://gitlab.metager3.de/open-source/MetaGer" >MetaGer Quellcode</a>
</li>
<li>
<a class="outlink" href="https://metager.de/tor" >TOR-Hidden-Service</a>
</li>
<li>
<a class="outlink" href="https://shop.spreadshirt.de/metager/" rel="noopener" target="_blank">MetaGer-Fanshop</a>
</li>
<li>
<a class="outlink" href="https://www.wecanhelp.de/430159004" >MetaGer-Fördershops</a>
</li>
</ul>
</li>