app/codehosting: forgejo deployment

Change-Id: Icfe6e0b17932a3248e1bdb807f431c59c48430de
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1685
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/app/codehosting/README.md b/app/codehosting/README.md
new file mode 100644
index 0000000..197e4e1
--- /dev/null
+++ b/app/codehosting/README.md
@@ -0,0 +1,26 @@
+# Hackerspace Code Hosting deployment
+
+"Code Hosting service" below means Forgejo.
+
+Due to certain specific requirements our deployment is a little customized.
+
+While we prefer users to use SSO/OpenID Connect for authentication, we also
+want code hosting service to be aware of all active users to correctly
+synchronize account access and SSH keys. When running with both LDAP and OpenID
+Connect integration enabled users are automatically created in a local database
+based on LDAP source, however OpenID Connect identity is not automatically bound
+to LDAP users. This causes code hosting service to still show a password-based
+authentication form in order to join the two identities.
+
+Workaround for this in our case is a SQL trigger function that automatically
+creates an OpenID Connect -> LDAP identity binding injected directly into code
+hosting service's PostgreSQL database. This trigger can be reviewed in
+`create-oidc-binding.sql` file here. For this to work correctly
+auto-registration needs to be disabled for OpenID Connect integration, in case
+some new user attempts to log in before code hosting service runs external
+users synchronization job.
+
+LDAP users synchronization job has been adjusted to run every 10 minutes. (in
+contrast to default 24h, see `app.ini.template`)
+
+Explore page has users listing disabled. Email and name display is disabled.
diff --git a/app/codehosting/app.ini.template b/app/codehosting/app.ini.template
new file mode 100644
index 0000000..b3b606f
--- /dev/null
+++ b/app/codehosting/app.ini.template
@@ -0,0 +1,98 @@
+APP_NAME = $APP_NAME
+RUN_MODE = $RUN_MODE
+
+[repository]
+ROOT = /data/git/repositories
+
+[repository.local]
+LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
+
+[repository.upload]
+TEMP_PATH = /data/gitea/uploads
+
+[server]
+APP_DATA_PATH = /data/gitea
+DOMAIN           = $DOMAIN
+SSH_DOMAIN       = $SSH_DOMAIN
+HTTP_PORT        = $HTTP_PORT
+ROOT_URL         = $ROOT_URL
+START_SSH_SERVER = true
+DISABLE_SSH      = $DISABLE_SSH
+SSH_PORT         = $SSH_PORT
+SSH_LISTEN_PORT  = $SSH_LISTEN_PORT
+LFS_START_SERVER = $LFS_START_SERVER
+OFFLINE_MODE     = $OFFLINE_MODE
+LANDING_PAGE     = explore
+
+[lfs]
+PATH = /data/git/lfs
+
+[database]
+PATH = /data/gitea/gitea.db
+DB_TYPE = $DB_TYPE
+HOST    = $DB_HOST
+NAME    = $DB_NAME
+USER    = $DB_USER
+PASSWD  = $DB_PASSWD
+
+[storage]
+STORAGE_TYPE = minio
+
+MINIO_ENDPOINT = $MINIO_ENDPOINT
+MINIO_ACCESS_KEY_ID = $MINIO_ACCESS_KEY_ID
+MINIO_SECRET_ACCESS_KEY = $MINIO_SECRET_ACCESS_KEY
+MINIO_BUCKET = $MINIO_BUCKET
+
+[indexer]
+ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
+
+[session]
+PROVIDER_CONFIG = /data/gitea/sessions
+
+[picture]
+AVATAR_UPLOAD_PATH = /data/gitea/avatars
+REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
+
+[attachment]
+PATH = /data/gitea/attachments
+
+[log]
+ROOT_PATH = /data/gitea/log
+
+[security]
+INSTALL_LOCK = $INSTALL_LOCK
+SECRET_KEY   = $SECRET_KEY
+
+[service]
+DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+REQUIRE_SIGNIN_VIEW  = $REQUIRE_SIGNIN_VIEW
+ALLOW_ONLY_EXTERNAL_REGISTRATION = $ALLOW_ONLY_EXTERNAL_REGISTRATION
+
+[service.explore]
+DISABLE_USERS_PAGE = true
+
+[ui]
+SHOW_USER_EMAIL = false
+
+[oauth2_client]
+REGISTER_EMAIL_CONFIRM = false
+ENABLE_AUTO_REGISTRATION = false
+USERNAME = userid
+ACCOUNT_LINKING = auto
+
+[cron.sync_external_users]
+SCHEDULE = @every 10m
+RUN_AT_START = true
+
+[i18n]
+LANGS = en-US,pl-PL
+NAMES = English,Polski
+
+[mailer]
+ENABLED        = true
+FROM           = $MAILER_FROM
+PROTOCOL       = smtps
+SMTP_ADDR      = $MAILER_HOST
+SMTP_PORT      = $MAILER_PORT
+USER           = $MAILER_USER
+PASSWD         = $MAILER_PASSWORD
diff --git a/app/codehosting/bootstrap-auth.sh b/app/codehosting/bootstrap-auth.sh
new file mode 100644
index 0000000..29781b8
--- /dev/null
+++ b/app/codehosting/bootstrap-auth.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# This script runs in an initContainer (once, using /data/.gitea_bootstrap_done
+# as a witness file) and is responsible for setting up and configuring:
+#  * initial admin user
+#  * hswaw OpenID Connect provider
+#  * hswaw LDAP user database
+
+set -e -o pipefail
+
+if [[ -f '/data/.gitea_bootstrap_done' ]]; then
+	echo '/data/.gitea_bootstrap_done exists, not doing anything'
+	exit 0
+fi
+
+/app/gitea/gitea admin user create --username bofh --password ${ADMIN_PASSWORD} --email bofh@hackerspace.pl --admin --must-change-password=false
+/app/gitea/gitea admin auth add-oauth --name hswaw-oidc --provider openidConnect --key ${SSO_CLIENT_ID} --secret ${SSO_CLIENT_SECRET} --auto-discover-url https://sso.hackerspace.pl/.well-known/openid-configuration
+/app/gitea/gitea admin auth add-ldap --name hswaw-ldap --active --security-protocol ldaps --host ldap.hackerspace.pl --port 636 --bind-dn ${LDAP_BIND_DN} --bind-password ${LDAP_BIND_PASSWORD} --user-search-base "ou=People,dc=hackerspace,dc=pl" --user-filter "(&(objectclass=hsMember)(uid=%[1]s)(|(memberOf=cn=fatty,ou=Group,dc=hackerspace,dc=pl)(memberOf=cn=starving,ou=Group,dc=hackerspace,dc=pl)(memberOf=cn=potato,ou=Group,dc=hackerspace,dc=pl)))" --admin-filter "(memberOf=cn=staff,ou=Group,dc=hackerspace,dc=pl)" --username-attribute uid --email-attribute mail --public-ssh-key-attribute sshPublicKey --synchronize-users
+touch /data/.gitea_bootstrap_done
diff --git a/app/codehosting/create-oidc-binding.sql b/app/codehosting/create-oidc-binding.sql
new file mode 100644
index 0000000..ef2250e
--- /dev/null
+++ b/app/codehosting/create-oidc-binding.sql
@@ -0,0 +1,23 @@
+-- This trigger automatically ensures an openid connect identity is bound to an
+-- equivalent LDAP account created by Gitea/Forgejo.
+
+BEGIN;
+
+CREATE OR REPLACE FUNCTION upsert_oidc_external_users ()
+   RETURNS TRIGGER
+AS $$
+BEGIN
+    -- 1 is OpenID Connect login source ID
+    -- 2 is LDAP login source ID
+    INSERT INTO external_login_user (external_id, user_id, login_source_id) SELECT name, id, 1 FROM "user" WHERE login_source = 2 ON CONFLICT DO NOTHING;
+    RETURN NULL;
+END;
+$$ LANGUAGE PLPGSQL;
+
+DROP TRIGGER IF EXISTS upsert_oidc_external_users ON "user";
+CREATE TRIGGER upsert_oidc_external_users AFTER INSERT OR UPDATE ON "user" EXECUTE PROCEDURE upsert_oidc_external_users();
+
+-- Force trigger run
+UPDATE "user" SET name = name WHERE FALSE;
+
+COMMIT;
diff --git a/app/codehosting/entrypoint.sh b/app/codehosting/entrypoint.sh
new file mode 100644
index 0000000..3002c63
--- /dev/null
+++ b/app/codehosting/entrypoint.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+
+for FOLDER in /data/gitea/log /data/git /data/ssh; do
+    mkdir -p ${FOLDER}
+done
+
+if [ ! -d /data/git/.ssh ]; then
+    mkdir -p /data/git/.ssh
+fi
+
+if [ ! -f /data/git/.ssh/environment ]; then
+    echo "GITEA_CUSTOM=$GITEA_CUSTOM" >| /data/git/.ssh/environment
+
+elif ! grep -q "^GITEA_CUSTOM=$GITEA_CUSTOM$" /data/git/.ssh/environment; then
+    sed -i /^GITEA_CUSTOM=/d /data/git/.ssh/environment
+    echo "GITEA_CUSTOM=$GITEA_CUSTOM" >> /data/git/.ssh/environment
+fi
+
+if [ ! -f ${GITEA_CUSTOM}/conf/app.ini ]; then
+    mkdir -p ${GITEA_CUSTOM}/conf
+
+    # Set INSTALL_LOCK to true only if SECRET_KEY is not empty and
+    # INSTALL_LOCK is empty
+    if [ -n "$SECRET_KEY" ] && [ -z "$INSTALL_LOCK" ]; then
+        INSTALL_LOCK=true
+    fi
+
+    # Substitude the environment variables in the template
+    env -i \
+    APP_NAME="${APP_NAME:-"Gitea: Git with a cup of tea"}" \
+    RUN_MODE="${RUN_MODE:-"dev"}" \
+    DOMAIN="${DOMAIN:-"localhost"}" \
+    SSH_DOMAIN="${SSH_DOMAIN:-"localhost"}" \
+    HTTP_PORT="${HTTP_PORT:-"3000"}" \
+    ROOT_URL="${ROOT_URL:-""}" \
+    DISABLE_SSH="${DISABLE_SSH:-"false"}" \
+    SSH_PORT="${SSH_PORT:-"22"}" \
+    SSH_LISTEN_PORT="${SSH_LISTEN_PORT:-"${SSH_PORT}"}" \
+    LFS_START_SERVER="${LFS_START_SERVER:-"false"}" \
+    DB_TYPE="${DB_TYPE:-"sqlite3"}" \
+    DB_HOST="${DB_HOST:-"localhost:3306"}" \
+    DB_NAME="${DB_NAME:-"gitea"}" \
+    DB_USER="${DB_USER:-"root"}" \
+    DB_PASSWD="${DB_PASSWD:-""}" \
+    INSTALL_LOCK="${INSTALL_LOCK:-"false"}" \
+    DISABLE_REGISTRATION="${DISABLE_REGISTRATION:-"false"}" \
+    REQUIRE_SIGNIN_VIEW="${REQUIRE_SIGNIN_VIEW:-"false"}" \
+    SECRET_KEY="${SECRET_KEY:-""}" \
+    ALLOW_ONLY_EXTERNAL_REGISTRATION="${ALLOW_ONLY_EXTERNAL_REGISTRATION:-"false"}" \
+    OFFLINE_MODE="${OFFLINE_MODE:-"true"}" \
+    MINIO_ENDPOINT="${MINIO_ENDPOINT:-""}" \
+    MINIO_ACCESS_KEY_ID="${MINIO_ACCESS_KEY_ID:-""}" \
+    MINIO_SECRET_ACCESS_KEY="${MINIO_SECRET_ACCESS_KEY:-""}" \
+    MINIO_BUCKET="${MINIO_BUCKET:-""}" \
+    MAILER_FROM="${MAILER_FROM:-""}" \
+    MAILER_HOST="${MAILER_HOST:-""}" \
+    MAILER_PORT="${MAILER_PORT:-""}" \
+    MAILER_USER="${MAILER_USER:-""}" \
+    MAILER_PASSWORD="${MAILER_PASSWORD:-""}" \
+    envsubst < /etc/templates/app.ini > ${GITEA_CUSTOM}/conf/app.ini
+    cat ${GITEA_CUSTOM}/conf/app.ini
+fi
+
+if [ $# -gt 0 ]; then
+    exec "$@"
+else
+    exec /app/gitea/gitea web
+fi
+
diff --git a/app/codehosting/forgejo.libsonnet b/app/codehosting/forgejo.libsonnet
new file mode 100644
index 0000000..9abbcf1
--- /dev/null
+++ b/app/codehosting/forgejo.libsonnet
@@ -0,0 +1,253 @@
+/*
+
+    Deploy a Forgejo instance with PostgreSQL database and additional PV for git data.
+    Pre-provision the secrets with:
+
+    kubectl -n $KUBE_NAMESPACE create secret generic forgejo \
+      --from-literal=postgres_password=$(pwgen -s 24 1) \
+      --from-literal=secret_key=$(pwgen -s 128 1) \
+      --from-literal=admin_password=$(pwgen -s 128 1) \
+      --from-literal=oauth2_client_id=$SSO_CLIENT_ID \
+      --from-literal=oauth2_client_secret=$SSO_CLIENT_SECRET \
+      --from-literal=ldap_bind_dn=$LDAP_BIND_DN \
+      --from-literal=ldap_bind_password=$LDAP_BIND_PASSWORD \
+      --from-literal=smtp_password=$SMTP_PASSWORD
+
+    Import objectstore secret:
+
+    ceph_ns=ceph-waw3; ceph_pool=waw-hdd-redundant-3
+    kubectl -n $ceph_ns get secrets rook-ceph-object-user-${ceph_pool}-object-codehosting -o json | jq 'del(.metadata.namespace,.metadata.resourceVersion,.metadata.uid) | .metadata.creationTimestamp=null' | kubectl apply -f - -n $KUBE_NAMESPACE
+
+    Import oidc auth trigger:
+
+    kubectl -n $KUBE_NAMESPACE exec deploy/postgres -i -- psql -U forgejo forgejo < create-oidc-binding.sql
+
+*/
+
+local kube = import "../../kube/kube.libsonnet";
+local postgres = import "../../kube/postgres.libsonnet";
+
+{
+    local forgejo = self,
+    local cfg = forgejo.cfg,
+    cfg:: {
+        namespace: error "namespace must be set",
+        prefix: "",
+
+        image: "codeberg.org/forgejo/forgejo:1.20.5-0",
+        storageClassName: "waw-hdd-redundant-3",
+        storageSize: { git: "200Gi" },
+
+        admin_username: error "admin_username must be set",
+        admin_email: error "admin_email must be set",
+
+        # Forgejo configuration, roughly representing the structure of app.ini
+        instanceName: error "instanceName (e.g. 'Warsaw Hackerspace Forgejo') must be set",
+        runMode: "prod",
+        server: {
+            domain: error "domain (e.g. git.hackerspace.pl) must be set",
+            sshDomain: cfg.server.domain,
+            rootURL: "https://" + cfg.server.domain + "/",
+            offlineMode: "true",
+        },
+        security: {
+            installLock: "true",
+        },
+        service: {
+            disableRegistration: "false",
+            allowOnlyExternalRegistration: "true",
+        },
+
+        s3: {
+            endpoint: "rook-ceph-rgw-waw-hdd-redundant-3-object.ceph-waw3.svc:80", #{ secretKeyRef: {name: "rook-ceph-object-user-waw-hdd-redundant-3-object-codehosting", key: "Endpoint" } },
+            accessKey: { secretKeyRef: {name: "rook-ceph-object-user-waw-hdd-redundant-3-object-codehosting", key: "AccessKey" } },
+            secretKey: { secretKeyRef: {name: "rook-ceph-object-user-waw-hdd-redundant-3-object-codehosting", key: "SecretKey" } },
+            bucket: "codehosting",
+        },
+
+        mailer: {
+            from: "forgejo@hackerspace.pl",
+            host: "mail.hackerspace.pl",
+            port: 465,
+            user: "forgejo",
+            password: { secretKeyRef: { name: "forgejo", key: "smtp_password" } },
+        },
+    },
+
+    name(suffix):: cfg.prefix + suffix,
+    ns: kube.Namespace(cfg.namespace),
+
+    postgres: postgres {
+        cfg+: {
+            namespace: cfg.namespace,
+            appName: "forgejo",
+            database: "forgejo",
+            username: "forgejo",
+            password: { secretKeyRef: { name: "forgejo", key: "postgres_password" } },
+            storageClassName: cfg.storageClassName,
+        },
+    },
+
+    configMap: forgejo.ns.Contain(kube.ConfigMap(forgejo.name("forgejo"))) {
+        data: {
+            "app.ini.template": importstr 'app.ini.template',
+            "entrypoint.sh": importstr 'entrypoint.sh',
+            "bootstrap-auth.sh": importstr 'bootstrap-auth.sh',
+        },
+    },
+
+    dataVolume: forgejo.ns.Contain(kube.PersistentVolumeClaim(forgejo.name("forgejo"))) {
+        spec+: {
+            storageClassName: cfg.storageClassName,
+            accessModes: [ "ReadWriteOnce" ],
+            resources: {
+                requests: {
+                    storage: cfg.storageSize.git,
+                },
+            },
+        },
+    },
+
+    forgejoCustom: forgejo.ns.Contain(kube.ConfigMap(forgejo.name("forgejo-custom"))) {
+        data: {
+            "signin_inner.tmpl": importstr 'signin_inner.tmpl',
+        },
+    },
+
+    statefulSet: forgejo.ns.Contain(kube.StatefulSet(forgejo.name("forgejo"))) {
+        spec+: {
+            replicas: 1,
+            template+: {
+                spec+: {
+                    securityContext: {
+                        runAsUser: 1000,
+                        runAsGroup: 1000,
+                        fsGroup: 1000,
+                    },
+                    volumes_: {
+                        configmap: kube.ConfigMapVolume(forgejo.configMap),
+                        custom: kube.ConfigMapVolume(forgejo.forgejoCustom),
+                        data: kube.PersistentVolumeClaimVolume(forgejo.dataVolume),
+                        empty: kube.EmptyDirVolume(),
+                    },
+                    containers_: {
+                        server: kube.Container(forgejo.name("forgejo")) {
+                            image: cfg.image,
+                            command: [ "bash", "/usr/bin/entrypoint" ],
+                            ports_: {
+                                server: { containerPort: 3000 },
+                                ssh: { containerPort: 22 },
+                            },
+                            readinessProbe: {
+                                tcpSocket: {
+                                    port: "server",
+                                },
+                                initialDelaySeconds: 5,
+                                periodSeconds: 5,
+                                successThreshold: 1,
+                                failureThreshold: 3
+                            },
+                            env_: {
+                                APP_NAME: cfg.instanceName,
+                                RUN_MODE: cfg.runMode,
+                                INSTALL_LOCK: cfg.security.installLock,
+                                SECRET_KEY: { secretKeyRef: { name: "forgejo", key: "secret_key" } },
+                                DB_TYPE: "postgres",
+                                DB_HOST: "postgres:5432",
+                                DB_USER: forgejo.postgres.cfg.username,
+                                DB_PASSWD: forgejo.postgres.cfg.password,
+                                DB_NAME: forgejo.postgres.cfg.appName,
+                                DOMAIN: cfg.server.domain,
+                                SSH_DOMAIN: cfg.server.sshDomain,
+                                SSH_LISTEN_PORT: "2222",
+                                ROOT_URL: forgejo.cfg.server.rootURL,
+                                DISABLE_REGISTRATION: cfg.service.disableRegistration,
+                                ALLOW_ONLY_EXTERNAL_REGISTRATION: cfg.service.allowOnlyExternalRegistration,
+                                OFFLINE_MODE: cfg.server.offlineMode,
+                                USER_UID: "1000",
+                                USER_GID: "1000",
+                                GITEA_CUSTOM: "/custom",
+                                MINIO_ENDPOINT: cfg.s3.endpoint,
+                                MINIO_BUCKET: cfg.s3.bucket,
+                                MINIO_ACCESS_KEY_ID: cfg.s3.accessKey,
+                                MINIO_SECRET_ACCESS_KEY: cfg.s3.secretKey,
+                                MAILER_FROM: cfg.mailer.from,
+                                MAILER_HOST: cfg.mailer.host,
+                                MAILER_PORT: cfg.mailer.port,
+                                MAILER_USER: cfg.mailer.user,
+                                MAILER_PASSWORD: cfg.mailer.password,
+                            },
+                            volumeMounts: [
+                                { name: "configmap", subPath: "entrypoint.sh", mountPath: "/usr/bin/entrypoint" },
+                                { name: "configmap", subPath: "app.ini.template", mountPath: "/etc/templates/app.ini" },
+                                { name: "data", mountPath: "/data" },
+                                { name: "empty", mountPath: "/custom" },
+                                { name: "custom", subPath: "signin_inner.tmpl", mountPath: "/custom/templates/user/auth/signin_inner.tmpl" },
+                            ],
+                        },
+                    },
+                    initContainers: [
+                        kube.Container(forgejo.name("forgejo-dbmigrate")) {
+                            image: forgejo.statefulSet.spec.template.spec.containers_.server.image,
+                            command: [ "bash", "/usr/bin/entrypoint", "/app/gitea/gitea", "migrate" ],
+                            env_: forgejo.statefulSet.spec.template.spec.containers_.server.env_,
+                            volumeMounts: forgejo.statefulSet.spec.template.spec.containers_.server.volumeMounts,
+                        },
+                        kube.Container(forgejo.name("forgejo-bootstrap-auth")) {
+                            image: forgejo.statefulSet.spec.template.spec.containers_.server.image,
+                            command: [
+                              "bash", "/bootstrap-auth.sh"
+                            ],
+                            env_: forgejo.statefulSet.spec.template.spec.containers_.server.env_ + {
+                              ADMIN_PASSWORD: { secretKeyRef: { name: "forgejo", key: "admin_password" } },
+                              SSO_CLIENT_ID: { secretKeyRef: { name: "forgejo", key: "oauth2_client_id" } },
+                              SSO_CLIENT_SECRET: { secretKeyRef: { name: "forgejo", key: "oauth2_client_secret" } },
+                              LDAP_BIND_DN: { secretKeyRef: { name: "forgejo", key: "ldap_bind_dn" } },
+                              LDAP_BIND_PASSWORD: { secretKeyRef: { name: "forgejo", key: "ldap_bind_password" } },
+                            },
+                            volumeMounts: forgejo.statefulSet.spec.template.spec.containers_.server.volumeMounts + [
+                                { name: "configmap", subPath: "bootstrap-auth.sh", mountPath: "/bootstrap-auth.sh" },
+                            ]
+                        },
+                    ],
+                },
+            },
+        },
+    },
+
+    svc: forgejo.ns.Contain(kube.Service(forgejo.name("forgejo"))) {
+        target_pod:: forgejo.statefulSet.spec.template,
+        spec+: {
+            ports: [
+                { name: "server", port: 80, targetPort: 3000, protocol: "TCP" },
+                { name: "ssh", port: 22, targetPort: 2222, protocol: "TCP" },
+            ],
+        },
+    },
+
+    ingress: forgejo.ns.Contain(kube.Ingress(forgejo.name("forgejo"))) {
+        metadata+: {
+            annotations+: {
+                "kubernetes.io/tls-acme": "true",
+                "cert-manager.io/cluster-issuer": "letsencrypt-prod",
+                "nginx.ingress.kubernetes.io/proxy-body-size": "0",
+            },
+        },
+        spec+: {
+            tls: [
+                { hosts: [cfg.server.domain], secretName: forgejo.name("acme") },
+            ],
+            rules: [
+                {
+                    host: cfg.server.domain,
+                    http: {
+                        paths: [
+                            { path: "/", backend: forgejo.svc.name_port },
+                        ],
+                    },
+                }
+            ],
+        },
+    },
+
+}
diff --git a/app/codehosting/prod.jsonnet b/app/codehosting/prod.jsonnet
new file mode 100644
index 0000000..891463b
--- /dev/null
+++ b/app/codehosting/prod.jsonnet
@@ -0,0 +1,20 @@
+local kube = import "../../kube/kube.libsonnet";
+local forgejo = import "forgejo.libsonnet";
+{
+    #namespace: kube.Namespace("forgejo-prod"),
+
+    forgejo: forgejo {
+        cfg+: {
+            namespace: "codehosting-prod",
+            prefix: "",
+
+            admin_username: "bofh",
+            admin_email: "bofh@hackerspace.pl",
+
+            instanceName: "Warsaw Hackerspace Codehosting",
+            server+: {
+                domain: "git.hackerspace.pl",
+            },
+        },
+    },
+}
diff --git a/app/codehosting/signin_inner.tmpl b/app/codehosting/signin_inner.tmpl
new file mode 100644
index 0000000..21cb0b5
--- /dev/null
+++ b/app/codehosting/signin_inner.tmpl
@@ -0,0 +1,77 @@
+<!-- Mirrored from https://codeberg.org/forgejo/forgejo/raw/tag/v1.20.5-0/templates/user/auth/signin_inner.tmpl with gt-hidden adjustment to hide login form -->
+
+{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn)}}
+{{template "base/alert" .}}
+{{end}}
+<h4 class="ui top attached header center">
+	{{if .LinkAccountMode}}
+		{{.locale.Tr "auth.oauth_signin_title"}}
+	{{else}}
+		{{.locale.Tr "auth.login_userpass"}}
+	{{end}}
+</h4>
+<div class="ui attached segment">
+	<form class="ui form" action="{{.SignInLink}}" method="post">
+	{{.CsrfTokenHtml}}
+    <div class="gt-hidden">
+	<div class="required inline field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
+		<label for="user_name">{{.locale.Tr "home.uname_holder"}}</label>
+		<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required>
+	</div>
+	{{if or (not .DisablePassword) .LinkAccountMode}}
+	<div class="required inline field {{if and (.Err_Password) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
+		<label for="password">{{.locale.Tr "password"}}</label>
+		<input id="password" name="password" type="password" value="{{.password}}" autocomplete="current-password" required>
+	</div>
+	{{end}}
+	{{if not .LinkAccountMode}}
+	<div class="inline field">
+		<label></label>
+		<div class="ui checkbox">
+			<label>{{.locale.Tr "auth.remember_me"}}</label>
+			<input name="remember" type="checkbox">
+		</div>
+	</div>
+	{{end}}
+
+	{{template "user/auth/captcha" .}}
+
+	<div class="inline field">
+		<label></label>
+		<button class="ui green button">
+			{{if .LinkAccountMode}}
+				{{.locale.Tr "auth.oauth_signin_submit"}}
+			{{else}}
+				{{.locale.Tr "sign_in"}}
+			{{end}}
+		</button>
+		<a href="{{AppSubUrl}}/user/forgot_password">{{.locale.Tr "auth.forgot_password"}}</a>
+	</div>
+
+	{{if .ShowRegistrationButton}}
+		<div class="inline field">
+			<label></label>
+			<a href="{{AppSubUrl}}/user/sign_up">{{.locale.Tr "auth.sign_up_now" | Str2html}}</a>
+		</div>
+	{{end}}
+    </div>
+	{{if and .OrderedOAuth2Names .OAuth2Providers}}
+	<div class="ui horizontal divider gt-hidden">
+		{{.locale.Tr "sign_in_or"}}
+	</div>
+	<div id="oauth2-login-navigator" class="gt-py-2">
+		<div class="gt-df gt-fc gt-jc">
+			<div id="oauth2-login-navigator-inner" class="gt-df gt-fc gt-fw gt-ac gt-gap-3">
+				{{range $key := .OrderedOAuth2Names}}
+					{{$provider := index $.OAuth2Providers $key}}
+					<a class="{{$provider.Name}} ui button gt-df gt-ac gt-jc gt-py-3 oauth-login-link" href="{{AppSubUrl}}/user/oauth2/{{$key}}">
+						{{$provider.IconHTML}}
+						{{$.locale.Tr "sign_in_with_provider" $provider.DisplayName}}
+					</a>
+				{{end}}
+			</div>
+		</div>
+	</div>
+	{{end}}
+	</form>
+</div>