1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
{ depot, pkgs, lib, config, authKeys, ... }:

let
  cfg = config.profpatsch.sourceForge;
  source-forge = depot.users.Profpatsch.source-forge;

  dbPath = "${cfg.stateDir}/db/source-forge.db";
  hooksPath = "${cfg.stateDir}/hooks";

  # Installed ONCE, generically, as the source-forge user's post-receive hook
  # for EVERY bare repo under stateDir — present or future. This works because
  # git falls back to $HOME/.gitconfig for any repo with no local
  # core.hooksPath override, and $HOME for the "source-forge" user (below) IS
  # stateDir. There is deliberately no per-project Nix option: a project is
  # declared entirely at runtime with `source-forge project add` (which
  # creates its bare repo AND its database row together, so the two can never
  # drift), and the hook derives both the project name (from the repo's own
  # directory name) and the published branch (from that project's row in the
  # database) at push time — see hook.go and project.go for the full
  # rationale, in particular why an undeclared repo's pushes are rejected
  # rather than silently ignored.
  #
  # --base-url is the same value serve gets below, so the URL the hook echoes
  # back over the wire on push ("remote: source-forge: published at …") is by
  # construction the one the site is actually served under.
  postReceiveHook = pkgs.writeShellScript "source-forge-post-receive" ''
    export PATH=${pkgs.gitMinimal}/bin:$PATH
    exec ${source-forge}/bin/source-forge git-post-receive-hook \
      --db ${dbPath} --base-url https://${cfg.host}
  '';

  # Makes core.hooksPath apply to every bare repo under stateDir without a
  # per-repo symlink (see postReceiveHook above). git looks this config file
  # up via $HOME, which sshd sets from users.users.source-forge.home below.
  gitconfig = pkgs.writeText "source-forge-gitconfig" ''
    [core]
    	hooksPath = ${hooksPath}
  '';

in {
  options.profpatsch.sourceForge = {
    enable = lib.mkEnableOption "source-forge on-demand source browser";

    host = lib.mkOption {
      type = lib.types.str;
      default = "sources.profpatsch.de";
      description = "Public hostname served over HTTPS.";
    };

    port = lib.mkOption {
      type = lib.types.port;
      default = 8772;
      description = "Local port the serve process listens on.";
    };

    stateDir = lib.mkOption {
      type = lib.types.str;
      default = "/var/lib/source-forge";
      description = ''
        State directory. Holds the database, and — directly under it, one per
        declared project — the bare repos (<name>.git). Projects are declared
        at runtime with `source-forge project add`, not through this module;
        see source-forge.1.
      '';
    };
  };

  config = lib.mkIf cfg.enable {

    # Dedicated system user that owns the DB, runs the service, AND is the SSH
    # push account. Using one fixed user avoids any cross-uid DB sharing issue
    # between the push hook and the long-running service. The login shell is
    # git-shell so the account can only push/pull, never open a real shell.
    # Its $HOME (= stateDir) is also what makes the generic gitconfig below
    # apply to every repo the account can push to.
    users.users.source-forge = {
      isSystemUser = true;
      group = "source-forge";
      home = cfg.stateDir;
      createHome = true;
      shell = "${pkgs.gitMinimal}/bin/git-shell";
      openssh.authorizedKeys.keys = authKeys;
    };
    users.groups.source-forge = { };

    systemd.tmpfiles.rules = [
      "d ${cfg.stateDir}     0750 source-forge source-forge -"
      "d ${cfg.stateDir}/db  0750 source-forge source-forge -"
      "d ${hooksPath}        0750 source-forge source-forge -"
      # L+ (re)creates the symlink on every activation, so a redeploy always
      # repoints it at the current store path. Unlike the old repo-init
      # service, there is nothing here to run per project, and nothing that
      # needs to be ordered before source-forge.service: both files are just
      # static content the hook (run later, out-of-band, by git on push)
      # reads.
      "L+ ${cfg.stateDir}/.gitconfig - - - - ${gitconfig}"
      "L+ ${hooksPath}/post-receive  - - - - ${postReceiveHook}"
    ];

    systemd.services.source-forge = {
      description = "source-forge on-demand source browser";
      wantedBy = [ "multi-user.target" ];
      after = [ "network.target" ];
      # mandoc renders a project's manpage (e.g. foo.1) as the directory README
      # when no README.md is present; the serve process execs it from PATH.
      path = [ pkgs.mandoc ];
      serviceConfig = {
        ExecStart = lib.escapeShellArgs [
          "${source-forge}/bin/source-forge"
          "serve"
          "--db" dbPath
          "--addr" "127.0.0.1:${toString cfg.port}"
          "--base-url" "https://${cfg.host}"
        ];
        User = "source-forge";
        Group = "source-forge";
        StateDirectory = "source-forge";
        Restart = "on-failure";
        RestartSec = 5;
      };
    };

    services.nginx.virtualHosts.${cfg.host} = {
      forceSSL = true;
      enableACME = true;
      locations."/" = {
        proxyPass = "http://127.0.0.1:${toString cfg.port}";
        extraConfig = ''
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto $scheme;
          # Full-history bundle downloads can be a few MiB.
          client_max_body_size 20M;
        '';
      };
    };

  };
}