diff --git a/StudipReleasesPlugin.php b/StudipReleasesPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..e15f9b5dd6d39708720fe27c8f345f0fd22cac99
--- /dev/null
+++ b/StudipReleasesPlugin.php
@@ -0,0 +1,24 @@
+<?php
+require_once __DIR__ . '/bootstrap.php';
+
+final class StudipReleasesPlugin extends TracToGitlab\Plugin implements SystemPlugin
+{
+    public function __construct()
+    {
+        parent::__construct();
+
+        $navigation = new Navigation(
+            _('Stud.IP Releases (Beta)'),
+            PluginEngine::getURL($this, [], 'releases')
+        );
+        $navigation->setImage(Icon::create('download', Icon::ROLE_NAVIGATION));
+        Navigation::addItem('/studip-releases', $navigation);
+    }
+
+    public function perform($unconsumed_path)
+    {
+        $this->addStylesheet('assets/releases.scss');
+
+        parent::perform($unconsumed_path);
+    }
+}
diff --git a/TracToGitlabPlugin.php b/TracToGitlabPlugin.php
index 5b17310f920e05f833e322272384a9613684836f..9be2f837fab0f0d972f37e136eca0a00c86eced8 100644
--- a/TracToGitlabPlugin.php
+++ b/TracToGitlabPlugin.php
@@ -1,10 +1,8 @@
 <?php
 require_once __DIR__ . '/bootstrap.php';
 
-final class TracToGitlabPlugin extends StudIPPlugin implements StandardPlugin, SystemPlugin
+final class TracToGitlabPlugin extends TracToGitlab\Plugin implements StandardPlugin, SystemPlugin
 {
-    private $container = null;
-
     public function __construct()
     {
         parent::__construct();
@@ -23,48 +21,6 @@ final class TracToGitlabPlugin extends StudIPPlugin implements StandardPlugin, S
         $this->buildNavigation();
     }
 
-    public function getDIContainer()
-    {
-        if ($this->container === null) {
-            require_once __DIR__ . '/vendor/autoload.php';
-
-            $this->container = new Pimple\Container();
-
-            $this->container['trac'] = function () {
-                return new TracToGitlab\TracLookup(Config::get()->TRAC2GITLAB_TRAC_URL);
-            };
-
-            $this->container['gitlab'] = function () {
-                $builder = new Gitlab\HttpClient\Builder(
-                    new GuzzleHttp\Client(),
-                    new Http\Factory\Guzzle\RequestFactory(),
-                    new Http\Factory\Guzzle\StreamFactory(),
-                    new Http\Factory\Guzzle\UriFactory()
-                );
-
-                $client = new Gitlab\Client($builder);
-                $client->setUrl(Config::get()->TRAC2GITLAB_GITLAB_URL);
-                $client->authenticate(Config::get()->TRAC2GITLAB_GITLAB_TOKEN, Gitlab\Client::AUTH_HTTP_TOKEN);
-                return $client;
-            };
-
-            $this->container['gitlabProjectId'] = function () {
-                return Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID;
-            };
-
-            $this->container['gitlabPager'] = function ($c) {
-                return new Gitlab\ResultPager($c['gitlab']);
-            };
-
-            $this->container['parsedown'] = function () {
-                $parsedown = new Parsedown();
-                $parsedown->setSafeMode(true);
-                return $parsedown;
-            };
-        }
-        return $this->container;
-    }
-
     public function getIconNavigation($course_id, $last_visit, $user_id)
     {
         return null;
diff --git a/assets/releases.scss b/assets/releases.scss
new file mode 100644
index 0000000000000000000000000000000000000000..d1f8c10b35e0f902b13f5527422c833967df1a6a
--- /dev/null
+++ b/assets/releases.scss
@@ -0,0 +1,7 @@
+.release-filesize {
+    white-space: nowrap;
+}
+
+tr:not(:last-child) td.table-divider {
+    border-bottom-color: var(--light-gray-color-80);
+}
diff --git a/composer.json b/composer.json
index 7e4a589603be88f5fc052b9dd86cadeac248b5c7..acae4f3f3cec0c29eea66a110be14e169d393517 100644
--- a/composer.json
+++ b/composer.json
@@ -13,6 +13,7 @@
         "http-interop/http-factory-guzzle": "^1.0",
         "erusev/parsedown": "^1.7",
         "ext-json": "*",
-        "pimple/pimple": "^3.4"
+        "pimple/pimple": "^3.4",
+        "ext-curl": "*"
     }
 }
diff --git a/composer.lock b/composer.lock
index b4182ad808a802d368deedaffe37ca8ccefb1a72..0efd7ad6f03a6dbfbfe4722c005c9ad4aa415ca1 100644
--- a/composer.lock
+++ b/composer.lock
@@ -459,32 +459,32 @@
         },
         {
             "name": "m4tthumphrey/php-gitlab-api",
-            "version": "11.4.0",
+            "version": "11.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/GitLabPHP/Client.git",
-                "reference": "4aa7d2af5614bca3f3473dae59fe930dbe73b496"
+                "reference": "d3364fd373f1ec8588dbd65afb527ea4cdcad8b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/GitLabPHP/Client/zipball/4aa7d2af5614bca3f3473dae59fe930dbe73b496",
-                "reference": "4aa7d2af5614bca3f3473dae59fe930dbe73b496",
+                "url": "https://api.github.com/repos/GitLabPHP/Client/zipball/d3364fd373f1ec8588dbd65afb527ea4cdcad8b4",
+                "reference": "d3364fd373f1ec8588dbd65afb527ea4cdcad8b4",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "ext-xml": "*",
                 "php": "^7.2.5 || ^8.0",
-                "php-http/cache-plugin": "^1.7.1",
+                "php-http/cache-plugin": "^1.7.2",
                 "php-http/client-common": "^2.3",
                 "php-http/discovery": "^1.12",
                 "php-http/httplug": "^2.2",
                 "php-http/multipart-stream-builder": "^1.1.2",
-                "psr/cache": "^1.0",
+                "psr/cache": "^1.0 || ^2.0",
                 "psr/http-client-implementation": "^1.0",
                 "psr/http-factory-implementation": "^1.0",
                 "psr/http-message": "^1.0",
-                "symfony/options-resolver": "^3.4 || ^4.0 || ^5.0",
+                "symfony/options-resolver": "^3.4 || ^4.0 || ^5.0 || ^6.0",
                 "symfony/polyfill-php80": "^1.17"
             },
             "require-dev": {
@@ -505,19 +505,23 @@
             "authors": [
                 {
                     "name": "Fabien Bourigault",
-                    "email": "bourigaultfabien@gmail.com"
+                    "email": "bourigaultfabien@gmail.com",
+                    "homepage": "https://github.com/fbourigault"
                 },
                 {
                     "name": "Graham Campbell",
-                    "email": "graham@alt-three.com"
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
                 },
                 {
                     "name": "Matt Humphrey",
-                    "email": "matth@windsor-telecom.co.uk"
+                    "email": "matth@windsor-telecom.co.uk",
+                    "homepage": "https://github.com/m4tthumphrey"
                 },
                 {
                     "name": "Miguel Piedrafita",
-                    "email": "github@miguelpiedrafita.com"
+                    "email": "github@miguelpiedrafita.com",
+                    "homepage": "https://github.com/m1guelpf"
                 }
             ],
             "description": "GitLab API v4 client for PHP",
@@ -527,7 +531,7 @@
             ],
             "support": {
                 "issues": "https://github.com/GitLabPHP/Client/issues",
-                "source": "https://github.com/GitLabPHP/Client/tree/11.4.0"
+                "source": "https://github.com/GitLabPHP/Client/tree/11.5.1"
             },
             "funding": [
                 {
@@ -535,7 +539,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-03-27T23:40:20+00:00"
+            "time": "2022-01-23T18:43:46+00:00"
         },
         {
             "name": "php-http/cache-plugin",
@@ -1711,5 +1715,5 @@
         "ext-json": "*"
     },
     "platform-dev": [],
-    "plugin-api-version": "2.1.0"
+    "plugin-api-version": "2.3.0"
 }
diff --git a/controllers/releases.php b/controllers/releases.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a9ba6f48d50b26b406a16a03dd0c59eeca95cf7
--- /dev/null
+++ b/controllers/releases.php
@@ -0,0 +1,231 @@
+<?php
+final class ReleasesController extends \TracToGitlab\Controller
+{
+    const CACHE_KEY = 'studip-releases';
+
+    private static $curl_handle = null;
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        Navigation::activateItem('/studip-releases');
+        PageLayout::setTitle(_('Stud.IP Releases'));
+
+        $widget = new LinksWidget();
+        $widget->setTitle(_('Weiterführende Links'));
+        $widget->addLink(
+            _('Releases auf gitlab.studip.de'),
+            'https://gitlab.studip.de/studip/studip/-/releases',
+            Icon::create('link2'),
+            ['target' => '_blank']
+        );
+        $widget->addLink(
+            _('Ältere Releases bei SourceForge'),
+            'https://sourceforge.net/projects/studip/files/Stud.IP/',
+            Icon::create('link2'),
+            ['target' => '_blank']
+        );
+        Sidebar::get()->addWidget($widget);
+    }
+
+    public function after_filter($action, $args)
+    {
+        if (self::$curl_handle !== null) {
+            curl_close(self::$curl_handle);
+        }
+
+        parent::after_filter($action, $args);
+    }
+
+    public function index_action()
+    {
+        $version = Request::get('version');
+
+        $this->releases = $this->getReleases();
+
+        $this->addVersionFilter($this->releases, $version);
+        $this->addResetAction();
+
+        if ($version && isset($this->releases[$version])) {
+            $this->releases = [
+                $version => $this->releases[$version],
+            ];
+        }
+    }
+
+    public function reset_action()
+    {
+        if ($this->isAdmin()) {
+            StudipCacheFactory::getCache()->expire(self::CACHE_KEY);
+        }
+
+        $this->relocate($this->indexURL());
+    }
+
+    private function getReleases(): array
+    {
+        $cache = StudipCacheFactory::getCache();
+
+        $releases = $cache->read(self::CACHE_KEY);
+        if ($releases === false) {
+            $temp = array_map(
+                function (array $release): array {
+                    $links = array_filter(
+                        $release['assets']['links'] ?? [],
+                        function (array $asset): bool {
+                            return $asset['link_type'] === 'package';
+                        }
+                    );
+                    $links = array_map(
+                        function (array $link): array {
+                            return [
+                                'name' => $link['name'],
+                                'url'  => $link['url'],
+                                'size' => $this->getFileSize($link['url']),
+                            ];
+                        },
+                        $links
+                    );
+                    usort($links, function ($a, $b) {
+                        return strcmp($a['name'], $b['name']);
+                    });
+
+                    $sources = array_map(
+                        function (array $asset): array {
+                            $asset['size'] = $this->getFileSize($asset['url']);
+                            return $asset;
+                        },
+                        $release['assets']['sources'] ?? []
+                    );
+
+                    $changelog = sprintf(
+                        'https://gitlab.studip.de/studip/studip/-/blob/%s/ChangeLog',
+                        $release['tag_name']
+                    );
+
+                    return [
+                        'name'        => $release['name'],
+                        'description' => $release['description'],
+                        'url'         => $release['_links']['self'],
+                        'changelog'   => $changelog,
+                        'released'    => strtotime($release['released_at']),
+                        'sources'     => $release['assets']['sources'],
+                        'links'       => $links
+                    ];
+                },
+                $this->gitlab->repositories()->releases($this->gitlabProjectId)
+            );
+
+            $releases = [];
+            foreach ($temp as $release) {
+                $version = $release['name'];
+                if (preg_match('/^v?(\d+(\.\d+)+)$/', $release['name'], $match)) {
+                    $version = $match[1];
+                }
+
+                $major_version = implode('.', array_slice(explode('.', $version), 0, 2));
+
+                if (!isset($releases[$major_version])) {
+                    $releases[$major_version] = [];
+                }
+
+                $releases[$major_version][$version] = $release;
+            }
+
+            uksort($releases, function ($a, $b) {
+                return version_compare($b, $a);
+            });
+            foreach ($releases as $version => $subversions) {
+                uksort($subversions, function ($a, $b) {
+                    return version_compare($b, $a);
+                });
+
+                $releases[$version] = $subversions;
+            }
+
+            $cache->write(self::CACHE_KEY, $releases);
+        }
+
+        return $releases;
+    }
+
+    private function getFileSize(string $url): int
+    {
+        // Init curl if necessary
+        if (self::$curl_handle === null) {
+            self::$curl_handle = curl_init(null);
+
+            curl_setopt(self::$curl_handle, CURLOPT_HEADER, true);
+            curl_setopt(self::$curl_handle, CURLOPT_NOBODY, true);
+            curl_setopt(self::$curl_handle, CURLOPT_RETURNTRANSFER, true);
+            curl_setopt(self::$curl_handle, CURLOPT_FOLLOWLOCATION, true);
+        }
+
+        // Set url and send request
+        curl_setopt(self::$curl_handle, CURLOPT_URL, $url);
+        $response = curl_exec(self::$curl_handle);
+
+        // Get status
+        $line = strtok($response, "\r\n");
+        $status = trim($line);
+
+        // Parse headers
+        $headers = [];
+        while (($line = strtok("\r\n")) !== false) {
+            $chunks = explode(':', $line, 2);
+            if ($chunks !== false) {
+                $headers[$chunks[0]] = $chunks[1];
+            }
+        }
+
+        // TODO: Check status
+
+        // Get filesize by Content-Length header
+        return (int) ($headers['Content-Length'] ?? -1);
+    }
+
+    private function addVersionFilter(array $releases, ?string $version): void
+    {
+        if (count($releases) > 1) {
+            $widget = new SelectWidget(
+                _('Nur Releases einer Version anzeigen'),
+                $this->indexURL(),
+                'version'
+            );
+            $widget->addElement(new SelectElement(
+                null,
+                _('Alle Versionen anzeigen'),
+                !isset($releases[$version])
+            ));
+            foreach (array_keys($releases) as $v) {
+                $widget->addElement(new SelectElement(
+                    $v,
+                    $v,
+                    $v === $version
+                ));
+            }
+            Sidebar::get()->addWidget($widget);
+        }
+
+    }
+
+    private function addResetAction(): void
+    {
+        if (!$this->isAdmin()) {
+            return;
+        }
+
+        Sidebar::get()->addWidget(new ActionsWidget())->addLink(
+            _('Cache leeren'),
+            $this->resetURL(),
+            Icon::create('refresh')
+        );
+    }
+
+    private function isAdmin(): bool
+    {
+        $user = User::findCurrent();
+        return $user && $user->perms === 'root';
+    }
+}
diff --git a/lib/Plugin.php b/lib/Plugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..5bcdda90b77283d9d0c0125d3cdb01dca9f554de
--- /dev/null
+++ b/lib/Plugin.php
@@ -0,0 +1,55 @@
+<?php
+namespace TracToGitlab;
+
+use Gitlab;
+use GuzzleHttp;
+use Http;
+use Parsedown;
+use Pimple;
+
+abstract class Plugin extends \StudIPPlugin
+{
+    private $container = null;
+
+    public function getDIContainer()
+    {
+        if ($this->container === null) {
+            require_once $this->getPluginPath() . '/vendor/autoload.php';
+
+            $this->container = new Pimple\Container();
+
+            $this->container['trac'] = function () {
+                return new TracLookup(Config::get()->TRAC2GITLAB_TRAC_URL);
+            };
+
+            $this->container['gitlab'] = function () {
+                $builder = new Gitlab\HttpClient\Builder(
+                    new GuzzleHttp\Client(),
+                    new Http\Factory\Guzzle\RequestFactory(),
+                    new Http\Factory\Guzzle\StreamFactory(),
+                    new Http\Factory\Guzzle\UriFactory()
+                );
+
+                $client = new Gitlab\Client($builder);
+                $client->setUrl(\Config::get()->TRAC2GITLAB_GITLAB_URL);
+                $client->authenticate(\Config::get()->TRAC2GITLAB_GITLAB_TOKEN, Gitlab\Client::AUTH_HTTP_TOKEN);
+                return $client;
+            };
+
+            $this->container['gitlabProjectId'] = function () {
+                return \Config::get()->TRAC2GITLAB_GITLAB_PROJECT_ID;
+            };
+
+            $this->container['gitlabPager'] = function ($c) {
+                return new Gitlab\ResultPager($c['gitlab']);
+            };
+
+            $this->container['parsedown'] = function () {
+                $parsedown = new Parsedown();
+                $parsedown->setSafeMode(true);
+                return $parsedown;
+            };
+        }
+        return $this->container;
+    }
+}
diff --git a/plugin.manifest b/plugin.manifest
index 559e946ee7c16a443da80fdbb60af85a07642388..8f3baa364c295003f33d83e1194c538830add421 100644
--- a/plugin.manifest
+++ b/plugin.manifest
@@ -1,5 +1,6 @@
 pluginname=Trac to gitlab converter
 pluginclassname=TracToGitlabPlugin
+pluginclassname=StudipReleasesPlugin
 origin=UOL
-version=1.4
+version=1.4.1
 studipMinVersion=5.0
diff --git a/views/releases/index.php b/views/releases/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..d060e2ae039b9505e7cbecb9f2136f13e1e6ec84
--- /dev/null
+++ b/views/releases/index.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * @var array<string, array> $releases
+ */
+?>
+<table class="default">
+    <colgroup>
+        <col>
+        <col class="hidden-tiny-down">
+        <col>
+        <col>
+        <col>
+    </colgroup>
+<? foreach ($releases as $major_version => $versions): ?>
+    <tbody>
+        <tr>
+            <th colspan="5"><?= htmlReady($major_version) ?></th>
+        </tr>
+    <? foreach ($versions as $version => $release): ?>
+        <tr>
+            <td>
+                <a href="<?= URLHelper::getLink($release['url']) ?>" target="_blank" rel="noreferrer noopener">
+                    <?= htmlReady($release['name']) ?>
+                </a>
+            </td>
+            <td class="hidden-tiny-down"><?= strftime('%x', $release['released']) ?></td>
+      <? foreach ($release['links'] as $link): ?>
+            <td>
+                <a href="<?= URLHelper::getLink($link['url']) ?>" target="_blank" rel="noreferrer noopener">
+                    <?= htmlReady($link['name']) ?>
+                </a>
+
+                <span class="release-filesize hidden-tiny-down">
+                    (<?= relsize($link['size'], false) ?>)
+                </span>
+            </td>
+      <? endforeach; ?>
+            <td class="hidden-small-down">
+                <a href="<?= URLHelper::getLink($release['changelog']) ?>" target="_blank" rel="noopener noreferrer">
+                    ChangeLog
+                </a>
+            </td>
+        </tr>
+        <tr class="hidden-medium-up">
+            <td colspan="5" class="table-divider">
+                <a href="<?= URLHelper::getLink($release['changelog']) ?>" target="_blank" rel="noopener noreferrer">
+                    ChangeLog
+                </a>
+            </td>
+        </tr>
+    <? endforeach; ?>
+    </tbody>
+<? endforeach; ?>
+</table>