Skip to content

Commit a396af0

Browse files
committed
initial release
1 parent b4b773b commit a396af0

8 files changed

Lines changed: 239 additions & 1 deletion

File tree

.dockerignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.git
2+
.github
3+
.env
4+
.env.example
5+
.dockerignore
6+
docker-compose.yml
7+
README.md

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DOMAIN=gitpkg.example.com

.github/workflows/docker.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Docker
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
8+
env:
9+
IMAGE: ghcr.io/${{ github.repository }}
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: docker/login-action@v3
22+
with:
23+
registry: ghcr.io
24+
username: ${{ github.actor }}
25+
password: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- uses: docker/metadata-action@v5
28+
id: meta
29+
with:
30+
images: ${{ env.IMAGE }}
31+
tags: |
32+
type=sha
33+
type=semver,pattern={{version}}
34+
type=raw,value=latest,enable={{is_default_branch}}
35+
36+
- uses: docker/build-push-action@v6
37+
with:
38+
context: .
39+
push: true
40+
tags: ${{ steps.meta.outputs.tags }}
41+
labels: ${{ steps.meta.outputs.labels }}

Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
FROM python:3.12-alpine
2+
3+
RUN adduser -D -h /app -s /sbin/nologin app
4+
5+
WORKDIR /app
6+
7+
COPY requirements.txt .
8+
RUN pip install --no-cache-dir --upgrade pip \
9+
&& pip install --no-cache-dir -r requirements.txt
10+
11+
COPY app.py .
12+
13+
USER app
14+
EXPOSE 8000
15+
16+
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "--timeout", "60", "app:app"]

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,24 @@
1-
# gitpkg-selfhost
1+
# GitPkg (self-hosted)
2+
3+
Install npm packages from GitHub monorepo subdirectories. A self-hosted alternative to [gitpkg.now.sh](https://github.com/EqualMa/gitpkg).
4+
5+
## Usage
6+
7+
```bash
8+
npm install https://your-server/user/repo/packages/pkg?commit-ish
9+
```
10+
11+
Full GitHub URL format is also supported:
12+
13+
```bash
14+
npm install https://your-server/https://github.com/user/repo/tree/commit-ish/packages/pkg
15+
```
16+
17+
If no commit is specified, defaults to `main`.
18+
19+
## Deploy
20+
21+
```bash
22+
cp .env.example .env # set your domain
23+
docker compose up -d
24+
```

app.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import hashlib
2+
import io
3+
import re
4+
import tarfile
5+
from typing import Any
6+
7+
import requests
8+
from flask import Flask, Response, abort, request
9+
10+
app = Flask(__name__)
11+
12+
MAX_OUTPUT_BYTES = 50 * 1024 * 1024 # 50 MB limit for repacked tarball
13+
_SAFE_PARAM = re.compile(r"^[A-Za-z0-9._-]+$")
14+
15+
_session = requests.Session()
16+
_session.headers["User-Agent"] = "gitpkg-selfhost/1.0"
17+
18+
19+
@app.route("/health")
20+
def health():
21+
return "ok"
22+
23+
24+
@app.route("/<user>/<repo>/<path:subdir>")
25+
@app.route("/https://github.com/<user>/<repo>/tree/<commit>/<path:subdir>")
26+
def pkg(user: str, repo: str, subdir: str, commit: str | None = None):
27+
if commit is None:
28+
qs = request.query_string.decode()
29+
commit = request.args.get("commit") or (qs if qs and "=" not in qs else "") or "main"
30+
31+
# Validate inputs
32+
for param in (user, repo, commit):
33+
if not _SAFE_PARAM.match(param):
34+
abort(400, "Invalid characters in URL")
35+
36+
subdir = subdir.rstrip("/") + "/"
37+
38+
# Fetch full-repo tarball from GitHub
39+
codeload_url = f"https://codeload.github.com/{user}/{repo}/tar.gz/{commit}"
40+
41+
# HEAD — skip download, no useful headers to return without repack
42+
if request.method == "HEAD":
43+
return Response(mimetype="application/gzip")
44+
45+
upstream = _session.get(codeload_url, stream=True, timeout=(5, 60))
46+
if upstream.status_code != 200:
47+
upstream.close()
48+
abort(upstream.status_code, f"GitHub returned {upstream.status_code}")
49+
50+
upstream.raw.decode_content = True
51+
52+
# Stream the tarball, filter to subdir, repack with package/ prefix
53+
try:
54+
tgz_bytes = _repack(upstream.raw, subdir)
55+
except ValueError:
56+
abort(413, "Subdirectory too large to serve")
57+
finally:
58+
upstream.close()
59+
if tgz_bytes is None:
60+
abort(404, f"Subdirectory '{subdir}' not found in {user}/{repo}@{commit}")
61+
62+
etag = hashlib.sha256(tgz_bytes).hexdigest()[:16]
63+
if request.headers.get("If-None-Match") == etag:
64+
return Response(status=304)
65+
66+
safe_subdir = re.sub(r"[^\w.-]", "-", subdir)
67+
filename = f"{user}-{repo}-{safe_subdir}{commit[:12]}.tgz"
68+
return Response(
69+
tgz_bytes,
70+
mimetype="application/gzip",
71+
headers={
72+
"Content-Disposition": f'attachment; filename="{filename}"',
73+
"ETag": etag,
74+
"Cache-Control": "public, immutable, max-age=31536000",
75+
},
76+
)
77+
78+
79+
def _repack(stream: Any, subdir: str) -> bytes | None:
80+
"""Extract subdir from streamed tarball and repack as npm-compatible tgz."""
81+
out_buf = io.BytesIO()
82+
83+
found = False
84+
full_prefix = ""
85+
86+
with tarfile.open(fileobj=stream, mode="r|gz") as src:
87+
with tarfile.open(fileobj=out_buf, mode="w:gz") as dst:
88+
for member in src:
89+
# First entry is the repo root dir, e.g. "wagmi-8fe5291/"
90+
if not full_prefix:
91+
full_prefix = member.name.split("/")[0] + "/" + subdir
92+
continue
93+
94+
# Check if entry is inside the target subdir
95+
if not member.name.startswith(full_prefix):
96+
continue
97+
98+
# Only allow regular files and directories
99+
if not (member.isfile() or member.isdir()):
100+
continue
101+
102+
found = True
103+
104+
# Copy member info with remapped name
105+
relative = member.name[len(full_prefix):]
106+
info = tarfile.TarInfo(name="package/" + relative if relative else "package")
107+
info.size = member.size if member.isfile() else 0
108+
info.mode = member.mode
109+
info.type = member.type
110+
info.mtime = member.mtime
111+
112+
fileobj = src.extractfile(member) if member.isfile() else None
113+
dst.addfile(info, fileobj)
114+
115+
if out_buf.tell() > MAX_OUTPUT_BYTES:
116+
raise ValueError("Output tarball too large")
117+
118+
if not found:
119+
return None
120+
121+
return out_buf.getvalue()
122+
123+
124+
if __name__ == "__main__":
125+
app.run(host="0.0.0.0", port=8000)

docker-compose.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
services:
2+
gitpkg:
3+
image: ghcr.io/feshchenkod/gitpkg-selfhost:latest
4+
restart: unless-stopped
5+
healthcheck:
6+
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
7+
interval: 30s
8+
timeout: 5s
9+
retries: 3
10+
11+
caddy:
12+
image: caddy:2
13+
restart: unless-stopped
14+
ports:
15+
- "80:80"
16+
- "443:443"
17+
volumes:
18+
- caddy_data:/data
19+
command: caddy reverse-proxy --from ${DOMAIN} --to gitpkg:8000
20+
21+
volumes:
22+
caddy_data:

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Flask==3.1.*
2+
requests==2.32.*
3+
gunicorn>=25,<26

0 commit comments

Comments
 (0)