Index: templates/downloads/repopackage.html
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/templates/downloads/repopackage.html b/templates/downloads/repopackage.html
new file mode 100644
--- /dev/null	(date 1622121957258)
+++ b/templates/downloads/repopackage.html	(date 1622121957258)
@@ -0,0 +1,58 @@
+{%extends "base/page.html"%}
+{%block title%}{{ pkg.repo.distro.kind.name|capfirst }} Package Info{%endblock%}
+{%block contents%}
+<h1>{{ pkg.repo.distro.kind.name|capfirst }} Package Info <i class="fa fa-archive"></i></h1>
+
+<table class="table table-hover" width="100%">
+  <tbody>
+    <tr>
+      <th scope="row" width="20%">Name</th>
+      <td>{{ pkg.name }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Version</th>
+      <td>{{ pkg.version }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Build</th>
+      <td>{{ pkg.build }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Architecture</th>
+      <td>{{ pkg.arch.name }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Repository Type</th>
+      <td>{{ pkg.repo.distro.kind.name }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Distribution</th>
+      <td>{{ pkg.repo.distro.name }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Repository</th>
+      <td>{{ pkg.repo.name }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Description</th>
+      <td>{{ pkg.description|linebreaks }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Licence</th>
+      <td>{{ pkg.licence }}</td>
+    </tr>
+    <tr>
+      <th scope="row">Maintainer</th>
+      <td>{{ pkg.maintainer }}</td>
+    </tr>
+    <tr>
+      <th scope="row">URL</th>
+      <td><a href="{{  pkg.url }}">{{  pkg.url }}</a></td>
+    </tr>
+    <tr>
+      <th scope="row">File</th>
+      <td><a href="https://ftp.postgresql.org/pub/repos/{{ pkg.repo.distro.kind.name }}/{{ pkg.file }}">https://ftp.postgresql.org/pub/repos/{{ pkg.repo.distro.kind.name }}/{{ pkg.file }}</a></td>
+    </tr>
+  </tbody>
+</table>
+{%endblock%}
Index: templates/pages/download/linux/redhat.html
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/templates/pages/download/linux/redhat.html b/templates/pages/download/linux/redhat.html
--- a/templates/pages/download/linux/redhat.html	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/templates/pages/download/linux/redhat.html	(date 1622118373517)
@@ -52,6 +52,11 @@
 on Fedora, all supported versions of PostgreSQL are not available on this platform. We do not recommend
 using Fedora for server deployments.
 </p>
+
+<p>
+You can <a href="/repo/yum">browse the contents of the yum repositories</a>.
+</p>
+
 <p>
 To use the PostgreSQL Yum Repository, follow these steps:
 </p>
Index: templates/pages/download/linux/suse.html
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/templates/pages/download/linux/suse.html b/templates/pages/download/linux/suse.html
--- a/templates/pages/download/linux/suse.html	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/templates/pages/download/linux/suse.html	(date 1622118419725)
@@ -8,17 +8,22 @@
 SUSE Linux "snapshots" a specific version of PostgreSQL
 that is then supported throughout the lifetime of that SUSE version.
 </p>
-<a name="zypper"></a><h2>PostgreSQL RPM Repository</h2>
+<a name="zypper"></a><h2>PostgreSQL Zypper Repository</h2>
 <p>
 This repository will integrate with your normal systems and patch management,
 and provide automatic updates for PostgreSQL 9.5+ throughout
 the support <a href="/support/versioning/">lifetime</a> of PostgreSQL.
 </p>
 <p>
-The PostgreSQL RPM repository currently supports SUSE Enterprise Linux 15 SP1 and 12 SP5.
+The PostgreSQL Zypper repository currently supports SUSE Enterprise Linux 15 SP1 and 12 SP5.
 </p>
+
 <p>
-To use the SUSE RPM repository, please follow these <a href="https://zypp.postgresql.org/howtozypp">instructions</a>.
+You can <a href="/repo/zypp">browse the contents of the zypper repositories</a>.
+</p>
+
+<p>
+To use the PostgreSQL Zypper repository, please follow these <a href="https://zypp.postgresql.org/howtozypp">instructions</a>.
 </p>
 
 <h2>Included in distribution</h2>
Index: templates/pages/download/linux/debian.html
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/templates/pages/download/linux/debian.html b/templates/pages/download/linux/debian.html
--- a/templates/pages/download/linux/debian.html	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/templates/pages/download/linux/debian.html	(date 1622118373511)
@@ -41,6 +41,11 @@
   <li>i386 (Buster and older)</li>
   <li>ppc64el</li>
 </ul>
+
+<p>
+You can <a href="/repo/apt">browse the contents of the apt repositories</a>.
+</p>
+
 <p>
 To use the apt repository, follow these steps:
 </p>
Index: pgweb/urls.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pgweb/urls.py b/pgweb/urls.py
--- a/pgweb/urls.py	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/pgweb/urls.py	(date 1622121280241)
@@ -47,6 +47,8 @@
     url(r'^about/privacypolicy/$', RedirectView.as_view(url='/about/policies/privacy/', permanent=True)),
 
     url(r'^ftp/(.*/)?$', pgweb.downloads.views.ftpbrowser),
+    url(r'^repo/package/$', pgweb.downloads.views.repopackage),
+    url(r'^repo/(?P<kind>\w+)/$', pgweb.downloads.views.repobrowser),
     url(r'^download/mirrors-ftp/+(.*)$', pgweb.downloads.views.mirrorselect),
     url(r'^download/product-categories/$', pgweb.downloads.views.categorylist),
     url(r'^download/products/(\d+)(-.*)?/$', pgweb.downloads.views.productlist),
Index: pgweb/downloads/views.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pgweb/downloads/views.py b/pgweb/downloads/views.py
--- a/pgweb/downloads/views.py	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/pgweb/downloads/views.py	(date 1622121802078)
@@ -1,6 +1,7 @@
 from django.shortcuts import render, get_object_or_404
 from django.http import HttpResponse, Http404, HttpResponseRedirect
 from django.core.exceptions import PermissionDenied
+from django.core.paginator import Paginator
 from django.views.decorators.csrf import csrf_exempt
 from django.conf import settings
 
@@ -8,13 +9,13 @@
 import pickle as pickle
 import json
 
-from pgweb.util.decorators import nocache
+from pgweb.util.decorators import nocache, queryparams
 from pgweb.util.contexts import render_pgweb
 from pgweb.util.helpers import PgXmlHelper, HttpServerError
 from pgweb.util.misc import varnish_purge, version_sort
 
 from pgweb.core.models import Version
-from .models import Category, Product, StackBuilderApp
+from .models import Category, Product, StackBuilderApp, RepoPackage, RepoArchitecture, RepoDistribution, RepoRepository
 
 
 #######
@@ -257,3 +258,48 @@
     x.endElement('applications')
     x.endDocument()
     return resp
+
+
+#######
+# Repo browser
+#######
+@csrf_exempt
+@queryparams('pkg', 'distro', 'repo', 'arch', 'page')
+def repobrowser(request, kind):
+    pkgs = RepoPackage.objects.filter(repo__distro__kind__name=kind)
+
+    if request.GET.get('pkg', '') != '':
+        pkgs = pkgs.filter(name__icontains=request.GET['pkg'])
+
+    if request.GET.get('distro', '') != '':
+        pkgs = pkgs.filter(repo__distro__name=request.GET['distro'])
+
+    if request.GET.get('repo', '') != '':
+        pkgs = pkgs.filter(repo__name=request.GET['repo'])
+
+    if request.GET.get('arch', '') != '':
+        pkgs = pkgs.filter(arch__name=request.GET['arch'])
+
+    paginator = Paginator(pkgs, 100)
+
+    page = request.GET.get('page')
+    pkgs = paginator.get_page(page)
+
+    repos = RepoRepository.objects.filter(distro__kind__name=kind)
+
+    return render_pgweb(request, 'download', 'downloads/repobrowser.html', {
+        'pkgs': pkgs,
+        'kind': kind,
+        'c_pkg': request.GET.get('pkg', ''),
+        'c_repo': request.GET.get('repo', ''),
+        'repos': repos,
+    })
+
+
+@queryparams('file')
+def repopackage(request):
+    pkg = get_object_or_404(RepoPackage, file=request.GET.get('file', ''))
+
+    return render_pgweb(request, 'download', 'downloads/repopackage.html', {
+        'pkg': pkg
+    })
Index: pgweb/downloads/models.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pgweb/downloads/models.py b/pgweb/downloads/models.py
--- a/pgweb/downloads/models.py	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/pgweb/downloads/models.py	(date 1622120689957)
@@ -112,3 +112,77 @@
     class Meta:
         unique_together = ('textid', 'version', 'platform', )
         ordering = ('textid', 'name', 'platform', )
+
+
+class RepoKind(models.Model):
+    name = models.CharField(max_length=10, null=False, blank=False,
+                            help_text='Kind of repository, e.g. "apt" or "yum".')
+
+    def __str__(self):
+        return self.name
+
+
+class RepoDistribution(models.Model):
+    kind = models.ForeignKey(RepoKind, null=False,
+                             on_delete=models.CASCADE,
+                             help_text='The kind of repository this distribution is part of.')
+    name = models.CharField(max_length=50, null=False, blank=False,
+                            help_text='Name of the distribution, e.g. "rhel-8" or "buster".')
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        ordering = ('name', 'kind__name')
+
+
+class RepoRepository(models.Model):
+    distro = models.ForeignKey(RepoDistribution, null=False,
+                               on_delete=models.CASCADE,
+                               help_text='The distribution this repository works with.')
+    name = models.CharField(max_length=50, null=False, blank=False,
+                            help_text='Name of the repository, e.g. "non-free" or buster-pgdg".')
+
+    def __str__(self):
+        return '%s - %s' % (self.distro, self.name)
+
+    class Meta:
+        ordering = ('distro__name', 'name')
+
+
+class RepoArchitecture(models.Model):
+    name = models.CharField(max_length=20, null=False, blank=False,
+                            help_text='Name of the architecture, e.g. "x86_64" or "arm64".')
+
+    def __str__(self):
+        return self.name
+
+
+class RepoPackage(models.Model):
+    repo = models.ForeignKey(RepoRepository, null=False,
+                             on_delete=models.CASCADE,
+                               help_text='The repository in which the package is available.')
+    arch = models.ForeignKey(RepoArchitecture, null=False,
+                             on_delete=models.CASCADE,
+                             help_text='The architecture on which the package will run.')
+    name = models.CharField(max_length=50, null=False, blank=False,
+                            help_text='Name of the package.')
+    version = models.CharField(max_length=50, null=False, blank=False,
+                               help_text='Version of the package, e.g. "9.6.13" or "4.5".')
+    build = models.CharField(max_length=50, null=False, blank=False,
+                             help_text='Build of the package, e.g. "1.rhel8" or "4.pgdg+1".')
+    description = models.TextField(null=False, blank=True,
+                                   help_text='Description of the package.')
+    licence = models.CharField(max_length=50, null=False, blank=True,
+                               help_text='Name/description of the licence.')
+    maintainer = models.CharField(max_length=200, null=False, blank=True)
+    url = models.CharField(max_length=500, null=False, blank=True,
+                           help_text='URL of the upstream project.')
+    file = models.CharField(max_length=500, null=False, blank=True, unique=True,
+                            help_text='Path to the package file.')
+
+    def __str__(self):
+        return '%s %s-%s (%s - %s)' % (self.name, self.version, self.build, self.repo, self.arch)
+
+    class Meta:
+        ordering = ('name', '-version', '-build', 'repo__name', 'arch__name')
Index: pgweb/downloads/migrations/0003_auto_20210527_1320.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pgweb/downloads/migrations/0003_auto_20210527_1320.py b/pgweb/downloads/migrations/0003_auto_20210527_1320.py
new file mode 100644
--- /dev/null	(date 1622121608724)
+++ b/pgweb/downloads/migrations/0003_auto_20210527_1320.py	(date 1622121608724)
@@ -0,0 +1,73 @@
+# Generated by Django 2.2.23 on 2021-05-27 13:20
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('downloads', '0002_remove_mirrors'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RepoArchitecture',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Name of the architecture, e.g. "x86_64" or "arm64".', max_length=20)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='RepoDistribution',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Name of the distribution, e.g. "rhel-8" or "buster".', max_length=50)),
+            ],
+            options={
+                'ordering': ('name', 'kind__name'),
+            },
+        ),
+        migrations.CreateModel(
+            name='RepoKind',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Kind of repository, e.g. "apt" or "yum".', max_length=10)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='RepoRepository',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Name of the repository, e.g. "non-free" or buster-pgdg".', max_length=50)),
+                ('distro', models.ForeignKey(help_text='The distribution this repository works with.', on_delete=django.db.models.deletion.CASCADE, to='downloads.RepoDistribution')),
+            ],
+            options={
+                'ordering': ('distro__name', 'name'),
+            },
+        ),
+        migrations.CreateModel(
+            name='RepoPackage',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Name of the package.', max_length=50)),
+                ('version', models.CharField(help_text='Version of the package, e.g. "9.6.13" or "4.5".', max_length=50)),
+                ('build', models.CharField(help_text='Build of the package, e.g. "1.rhel8" or "4.pgdg+1".', max_length=50)),
+                ('description', models.TextField(blank=True, help_text='Description of the package.')),
+                ('licence', models.CharField(blank=True, help_text='Name/description of the licence.', max_length=50)),
+                ('maintainer', models.CharField(blank=True, max_length=200)),
+                ('url', models.CharField(blank=True, help_text='URL of the upstream project.', max_length=500)),
+                ('file', models.CharField(blank=True, help_text='Path to the package file.', max_length=500, unique=True)),
+                ('arch', models.ForeignKey(help_text='The architecture on which the package will run.', on_delete=django.db.models.deletion.CASCADE, to='downloads.RepoArchitecture')),
+                ('repo', models.ForeignKey(help_text='The repository in which the package is available.', on_delete=django.db.models.deletion.CASCADE, to='downloads.RepoRepository')),
+            ],
+            options={
+                'ordering': ('name', '-version', '-build', 'repo__name', 'arch__name'),
+            },
+        ),
+        migrations.AddField(
+            model_name='repodistribution',
+            name='kind',
+            field=models.ForeignKey(help_text='The kind of repository this distribution is part of.', on_delete=django.db.models.deletion.CASCADE, to='downloads.RepoKind'),
+        ),
+    ]
Index: templates/downloads/repobrowser.html
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/templates/downloads/repobrowser.html b/templates/downloads/repobrowser.html
new file mode 100644
--- /dev/null	(date 1622121862374)
+++ b/templates/downloads/repobrowser.html	(date 1622121862374)
@@ -0,0 +1,67 @@
+{%extends "base/page.html"%}
+{%block title%}{{ kind|capfirst }} Repo Browser{%endblock%}
+{%block contents%}
+<h1>{{ kind|capfirst }} Repo Browser <i class="fa fa-archive"></i></h1>
+
+    <form class="form-inline" method="get">
+        <div class="form-row align-items-center">
+            <div class="col">
+                <input class="form-control" type="text" name="pkg" size="30" placeholder="Package name" value="{{ c_pkg }}">
+            </div>
+            <div class="col">
+                <select class="form-control" name="repo">
+                    <option value=""{% if repo.name == "" %} selected{% endif %}>Select repo (optional)</option>
+                    {% for repo in repos %}
+                    <option value="{{ repo.name }}"{% if repo.name == c_repo %} selected{% endif %}>{{ repo }}</option>
+                    {% endfor %}
+                </select>
+            </div>
+
+            <div class="col-auto">
+                <input class="btn btn-primary" type="submit" value="Submit">
+            </div>
+        </div>
+    </form>
+
+    <p>&nbsp;</p>
+
+    {% if pkgs.number > 0 %}
+    <p>Page {{ pkgs.number }} of {{ pkgs.paginator.num_pages }} (results {{ pkgs.start_index }} to
+        {{ pkgs.end_index }}):</p>
+    {% endif %}
+
+    <table class="table table-hover" width="100%">
+        <thead>
+            <tr>
+                <th scope="col">Package</th>
+                <th scope="col">Version</th>
+                <th scope="col">Build</th>
+                <th scope="col">Architecture</th>
+                <th scope="col">Distribution</th>
+                <th scope="col">Repository</th>
+            </tr>
+        </thead>
+        <tbody>
+            {%  for pkg in pkgs %}
+            <tr>
+                <td><a href="/repo/package/?file={{ pkg.file|urlencode }}">{{ pkg.name }}</a></td>
+                <td>{{ pkg.version }}</td>
+                <td>{{ pkg.build }}</td>
+                <td>{{ pkg.arch.name }}</td>
+                <td>{{ pkg.repo.distro.name }}</td>
+                <td>{{ pkg.repo.name }}</td>
+            </tr>
+            {% endfor %}
+        </tbody>
+    </table>
+
+    <div class="pagination">
+        <span class="step-links">
+            {% if pkgs.has_previous %}<a href="?page=1&pkg={{ c_pkg }}&arch={{ c_arch }}&distro={{ c_distro }}&repo={{ c_repo }}">{% endif %}&laquo; first{% if pkgs.has_previous %}</a>{% endif %} &middot;
+            {% if pkgs.has_previous %}<a href="?page={{ pkgs.previous_page_number }}&pkg={{ c_pkg }}&arch={{ c_arch }}&distro={{ c_distro }}&repo={{ c_repo }}">{% endif %}previous{% if pkgs.has_previous %}</a>{% endif %} &middot;
+
+            {% if pkgs.has_next %}<a href="?page={{ pkgs.next_page_number }}&pkg={{ c_pkg }}&arch={{ c_arch }}&distro={{ c_distro }}&repo={{ c_repo }}">{% endif %}next{% if pkgs.has_next %}</a>{% endif %} &middot;
+            {% if pkgs.has_next %}<a href="?page={{ pkgs.paginator.num_pages }}&pkg={{ c_pkg }}&arch={{ c_arch }}&distro={{ c_distro }}&repo={{ c_repo }}   ">{% endif %}last &raquo;{% if pkgs.has_next %}</a>{% endif %}
+        </span>
+    </div>
+{%endblock%}
Index: pgweb/downloads/admin.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pgweb/downloads/admin.py b/pgweb/downloads/admin.py
--- a/pgweb/downloads/admin.py	(revision 7cf980965a1d3985e533ebcafe76e17db89c37c9)
+++ b/pgweb/downloads/admin.py	(date 1621589581285)
@@ -5,7 +5,8 @@
 import re
 
 from pgweb.util.admin import PgwebAdmin
-from .models import StackBuilderApp, Category, Product, LicenceType
+from .models import StackBuilderApp, Category, Product, LicenceType, \
+    RepoArchitecture, RepoDistribution, RepoKind, RepoRepository, RepoPackage
 
 
 class ProductAdmin(PgwebAdmin):
@@ -58,7 +59,55 @@
     form = StackBuilderAppAdminForm
 
 
+class RepoDistributionAdmin(PgwebAdmin):
+    list_display = ('name', 'kind_name')
+    list_filter = ('kind',)
+
+    def kind_name(self, obj):
+        return obj.kind.name
+    kind_name.short_description = 'Repo Kind'
+
+
+class RepoRepositoryAdmin(PgwebAdmin):
+    list_display = ('name', 'distro_name', 'kind_name')
+    list_filter = ('distro__kind', 'distro')
+
+    def distro_name(self, obj):
+        return obj.distro.name
+    distro_name.short_description = 'Distribution'
+
+    def kind_name(self, obj):
+        return obj.distro.kind.name
+    kind_name.short_description = 'Repo Kind'
+
+
+class RepoPackageAdmin(PgwebAdmin):
+    list_display = ('name', 'repo_name', 'distro_name', 'kind_name')
+    list_filter = ('repo__distro__kind', 'repo__distro', 'repo')
+
+    def full_version(self, obj):
+        return "%s-%s" % (obj.version + obj.build)
+    full_version.short_description = 'Version Number'
+
+    def repo_name(self, obj):
+        return obj.repo.name
+    repo_name.short_description = 'Repository'
+
+    def distro_name(self, obj):
+        return obj.repo.distro.name
+    distro_name.short_description = 'Distribution'
+
+    def kind_name(self, obj):
+        return obj.repo.distro.kind.name
+    kind_name.short_description = 'Repo Kind'
+
+
 admin.site.register(Category)
 admin.site.register(LicenceType)
 admin.site.register(Product, ProductAdmin)
 admin.site.register(StackBuilderApp, StackBuilderAppAdmin)
+admin.site.register(RepoKind)
+admin.site.register(RepoArchitecture)
+admin.site.register(RepoDistribution, RepoDistributionAdmin)
+admin.site.register(RepoRepository, RepoRepositoryAdmin)
+admin.site.register(RepoPackage, RepoPackageAdmin)
