scripticus-server 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/PKG-INFO +44 -17
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/README.md +40 -15
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/pyproject.toml +4 -2
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/src/scripticus_server/app.py +4 -16
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/src/scripticus_server/db.py +25 -1
- scripticus_server-0.3.0/src/scripticus_server/gitea.py +115 -0
- scripticus_server-0.3.0/src/scripticus_server/publish.py +316 -0
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/src/scripticus_server/__init__.py +0 -0
- {scripticus_server-0.2.0 → scripticus_server-0.3.0}/src/scripticus_server/main.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scripticus-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A package manager and registry for scripts — index service
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Dist: fastapi>=0.115
|
|
7
7
|
Requires-Dist: uvicorn>=0.30
|
|
8
8
|
Requires-Dist: sqlalchemy>=2.0
|
|
9
|
-
Requires-Dist:
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
11
|
+
Requires-Dist: scripticus-schema>=0.1.2,<0.2.0
|
|
10
12
|
Requires-Python: >=3.11
|
|
11
13
|
Description-Content-Type: text/markdown
|
|
12
14
|
|
|
@@ -56,17 +58,40 @@ balancers and container orchestrators.
|
|
|
56
58
|
|
|
57
59
|
The index database defaults to a local SQLite file
|
|
58
60
|
(`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
|
|
59
|
-
to point elsewhere. Tables are created automatically on first use.
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
to point elsewhere. Tables are created automatically on first use.
|
|
62
|
+
|
|
63
|
+
### Publishing
|
|
64
|
+
|
|
65
|
+
`POST /packages` publishes a package: a multipart upload of one archive
|
|
66
|
+
(as produced by `scripticus pack`) with your Gitea token in the
|
|
67
|
+
`Authorization` header:
|
|
68
|
+
|
|
69
|
+
```console
|
|
70
|
+
$ curl -X POST http://localhost:8000/packages \
|
|
71
|
+
-H "Authorization: token <your-gitea-token>" \
|
|
72
|
+
-F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The server trusts nothing about the upload: it re-validates the manifest
|
|
76
|
+
and package tree, computes the content hash, checks with Gitea (live)
|
|
77
|
+
that your token may publish to the manifest's namespace — your own
|
|
78
|
+
username, or an organisation you belong to — stores the blob in Gitea's
|
|
79
|
+
generic package registry, and only then commits the index record.
|
|
80
|
+
Versions are immutable; the one addition an existing version accepts is
|
|
81
|
+
an artifact in a new archive format carrying the identical content hash.
|
|
82
|
+
Declared package dependencies must be fully namespaced and already
|
|
83
|
+
present in the index, and a publish that would create a dependency cycle
|
|
84
|
+
is rejected. The `library` namespace is reserved. The Gitea instance is
|
|
85
|
+
configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
|
|
62
86
|
|
|
63
87
|
### Docker
|
|
64
88
|
|
|
65
89
|
Server releases publish a Docker image to
|
|
66
90
|
[`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
|
|
67
|
-
(tagged with the release version and `latest`). The repository
|
|
68
|
-
`docker-compose.yml`
|
|
69
|
-
|
|
91
|
+
(tagged with the release version and `latest`). The repository's
|
|
92
|
+
`docker-compose.yml` is the full registry bundle — the index service plus
|
|
93
|
+
the Gitea instance that provides storage, authentication, and namespace
|
|
94
|
+
ownership — and needs no checkout:
|
|
70
95
|
|
|
71
96
|
```console
|
|
72
97
|
$ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
|
|
@@ -75,15 +100,17 @@ $ curl http://localhost:8000/health
|
|
|
75
100
|
{"status":"ok"}
|
|
76
101
|
```
|
|
77
102
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
First-run Gitea setup: accounts and organisations are managed in Gitea
|
|
104
|
+
(http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
|
|
105
|
+
organisation, claimed first-come-first-served, with publish rights
|
|
106
|
+
following Gitea's own membership and ACLs. So, once the bundle is up:
|
|
107
|
+
|
|
108
|
+
1. Register your user in the Gitea web UI (the first registered user is
|
|
109
|
+
the instance admin), and create an organisation for any shared
|
|
110
|
+
namespace you want.
|
|
111
|
+
2. Generate a token under *Settings → Applications → Manage Access
|
|
112
|
+
Tokens* with package write and user read scopes.
|
|
113
|
+
3. Publish with that token (see above).
|
|
87
114
|
|
|
88
115
|
## Licence
|
|
89
116
|
|
|
@@ -44,17 +44,40 @@ balancers and container orchestrators.
|
|
|
44
44
|
|
|
45
45
|
The index database defaults to a local SQLite file
|
|
46
46
|
(`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
|
|
47
|
-
to point elsewhere. Tables are created automatically on first use.
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
to point elsewhere. Tables are created automatically on first use.
|
|
48
|
+
|
|
49
|
+
### Publishing
|
|
50
|
+
|
|
51
|
+
`POST /packages` publishes a package: a multipart upload of one archive
|
|
52
|
+
(as produced by `scripticus pack`) with your Gitea token in the
|
|
53
|
+
`Authorization` header:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
$ curl -X POST http://localhost:8000/packages \
|
|
57
|
+
-H "Authorization: token <your-gitea-token>" \
|
|
58
|
+
-F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The server trusts nothing about the upload: it re-validates the manifest
|
|
62
|
+
and package tree, computes the content hash, checks with Gitea (live)
|
|
63
|
+
that your token may publish to the manifest's namespace — your own
|
|
64
|
+
username, or an organisation you belong to — stores the blob in Gitea's
|
|
65
|
+
generic package registry, and only then commits the index record.
|
|
66
|
+
Versions are immutable; the one addition an existing version accepts is
|
|
67
|
+
an artifact in a new archive format carrying the identical content hash.
|
|
68
|
+
Declared package dependencies must be fully namespaced and already
|
|
69
|
+
present in the index, and a publish that would create a dependency cycle
|
|
70
|
+
is rejected. The `library` namespace is reserved. The Gitea instance is
|
|
71
|
+
configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
|
|
50
72
|
|
|
51
73
|
### Docker
|
|
52
74
|
|
|
53
75
|
Server releases publish a Docker image to
|
|
54
76
|
[`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
|
|
55
|
-
(tagged with the release version and `latest`). The repository
|
|
56
|
-
`docker-compose.yml`
|
|
57
|
-
|
|
77
|
+
(tagged with the release version and `latest`). The repository's
|
|
78
|
+
`docker-compose.yml` is the full registry bundle — the index service plus
|
|
79
|
+
the Gitea instance that provides storage, authentication, and namespace
|
|
80
|
+
ownership — and needs no checkout:
|
|
58
81
|
|
|
59
82
|
```console
|
|
60
83
|
$ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
|
|
@@ -63,15 +86,17 @@ $ curl http://localhost:8000/health
|
|
|
63
86
|
{"status":"ok"}
|
|
64
87
|
```
|
|
65
88
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
First-run Gitea setup: accounts and organisations are managed in Gitea
|
|
90
|
+
(http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
|
|
91
|
+
organisation, claimed first-come-first-served, with publish rights
|
|
92
|
+
following Gitea's own membership and ACLs. So, once the bundle is up:
|
|
93
|
+
|
|
94
|
+
1. Register your user in the Gitea web UI (the first registered user is
|
|
95
|
+
the instance admin), and create an organisation for any shared
|
|
96
|
+
namespace you want.
|
|
97
|
+
2. Generate a token under *Settings → Applications → Manage Access
|
|
98
|
+
Tokens* with package write and user read scopes.
|
|
99
|
+
3. Publish with that token (see above).
|
|
75
100
|
|
|
76
101
|
## Licence
|
|
77
102
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "scripticus-server"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "A package manager and registry for scripts — index service"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -9,7 +9,9 @@ dependencies = [
|
|
|
9
9
|
"fastapi>=0.115",
|
|
10
10
|
"uvicorn>=0.30",
|
|
11
11
|
"sqlalchemy>=2.0",
|
|
12
|
-
"
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"python-multipart>=0.0.9",
|
|
14
|
+
"scripticus-schema>=0.1.2,<0.2.0",
|
|
13
15
|
]
|
|
14
16
|
|
|
15
17
|
[tool.uv.sources]
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
from collections.abc import Iterator
|
|
2
1
|
from typing import Literal
|
|
3
2
|
|
|
4
3
|
from fastapi import Depends, FastAPI, HTTPException
|
|
5
4
|
from pydantic import BaseModel
|
|
6
5
|
from sqlalchemy import select
|
|
7
|
-
from sqlalchemy.orm import Session
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
8
7
|
|
|
9
8
|
from scripticus_schema.index_api import (
|
|
10
9
|
PackageSummary,
|
|
@@ -14,6 +13,8 @@ from scripticus_schema.index_api import (
|
|
|
14
13
|
)
|
|
15
14
|
from scripticus_schema.semver import semver_key
|
|
16
15
|
from scripticus_server import __version__, db
|
|
16
|
+
from scripticus_server.db import get_session
|
|
17
|
+
from scripticus_server.publish import router as publish_router
|
|
17
18
|
|
|
18
19
|
app = FastAPI(
|
|
19
20
|
title="Scripticus index service",
|
|
@@ -23,20 +24,7 @@ app = FastAPI(
|
|
|
23
24
|
),
|
|
24
25
|
version=__version__,
|
|
25
26
|
)
|
|
26
|
-
|
|
27
|
-
_session_factory: sessionmaker | None = None
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def get_session() -> Iterator[Session]:
|
|
31
|
-
# Lazy so that importing the app never touches the database; tables are
|
|
32
|
-
# created on first use (D31: create_all until the schema stabilises).
|
|
33
|
-
global _session_factory
|
|
34
|
-
if _session_factory is None:
|
|
35
|
-
engine = db.make_engine()
|
|
36
|
-
db.init_db(engine)
|
|
37
|
-
_session_factory = sessionmaker(bind=engine)
|
|
38
|
-
with _session_factory() as session:
|
|
39
|
-
yield session
|
|
27
|
+
app.include_router(publish_router)
|
|
40
28
|
|
|
41
29
|
|
|
42
30
|
# Local to the server on purpose: a liveness shape is not part of the
|
|
@@ -10,10 +10,18 @@ manifest blob, never independently editable (D21).
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import os
|
|
13
|
+
from collections.abc import Iterator
|
|
13
14
|
|
|
14
15
|
from sqlalchemy import ForeignKey, Text, UniqueConstraint, create_engine
|
|
15
16
|
from sqlalchemy.engine import Engine
|
|
16
|
-
from sqlalchemy.orm import
|
|
17
|
+
from sqlalchemy.orm import (
|
|
18
|
+
DeclarativeBase,
|
|
19
|
+
Mapped,
|
|
20
|
+
Session,
|
|
21
|
+
mapped_column,
|
|
22
|
+
relationship,
|
|
23
|
+
sessionmaker,
|
|
24
|
+
)
|
|
17
25
|
|
|
18
26
|
DEFAULT_DB_URL = "sqlite:///scripticus-index.db"
|
|
19
27
|
|
|
@@ -30,6 +38,22 @@ def init_db(engine: Engine) -> None:
|
|
|
30
38
|
Base.metadata.create_all(engine)
|
|
31
39
|
|
|
32
40
|
|
|
41
|
+
_session_factory: sessionmaker | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_session() -> Iterator[Session]:
|
|
45
|
+
# FastAPI dependency. Lazy so that importing the app never touches the
|
|
46
|
+
# database; tables are created on first use (D31: create_all until the
|
|
47
|
+
# schema stabilises).
|
|
48
|
+
global _session_factory
|
|
49
|
+
if _session_factory is None:
|
|
50
|
+
engine = make_engine()
|
|
51
|
+
init_db(engine)
|
|
52
|
+
_session_factory = sessionmaker(bind=engine)
|
|
53
|
+
with _session_factory() as session:
|
|
54
|
+
yield session
|
|
55
|
+
|
|
56
|
+
|
|
33
57
|
class Base(DeclarativeBase):
|
|
34
58
|
pass
|
|
35
59
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The server's boundary to Gitea (D2): permission checks and blob storage.
|
|
2
|
+
|
|
3
|
+
Auth is pass-through (D32): every publish carries the caller's own Gitea
|
|
4
|
+
token, and this client acts with exactly that token's rights — the index
|
|
5
|
+
service holds no credentials and caches nothing ACL-shaped (D24). The
|
|
6
|
+
narrow surface here exists so tests can fake Gitea; nothing outside this
|
|
7
|
+
module speaks HTTP to it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import Header, HTTPException
|
|
14
|
+
|
|
15
|
+
DEFAULT_GITEA_URL = "http://localhost:3000"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def gitea_url() -> str:
|
|
19
|
+
return os.environ.get("SCRIPTICUS_GITEA_URL", DEFAULT_GITEA_URL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GiteaAuthError(Exception):
|
|
23
|
+
"""The token is missing, invalid, or expired."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GiteaError(Exception):
|
|
27
|
+
"""Gitea is unreachable or answered something unexpected."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GiteaClient:
|
|
31
|
+
def __init__(self, base_url: str, token: str):
|
|
32
|
+
self._client = httpx.Client(
|
|
33
|
+
base_url=base_url,
|
|
34
|
+
headers={"Authorization": f"token {token}"},
|
|
35
|
+
timeout=30,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _get(self, path: str) -> httpx.Response:
|
|
39
|
+
try:
|
|
40
|
+
return self._client.get(path)
|
|
41
|
+
except httpx.HTTPError as exc:
|
|
42
|
+
raise GiteaError(f"cannot reach Gitea: {exc}") from exc
|
|
43
|
+
|
|
44
|
+
def authenticated_user(self) -> str:
|
|
45
|
+
"""The login of the token's owner; raises GiteaAuthError on a bad token."""
|
|
46
|
+
response = self._get("/api/v1/user")
|
|
47
|
+
if response.status_code in (401, 403):
|
|
48
|
+
raise GiteaAuthError("Gitea rejected the token")
|
|
49
|
+
if response.status_code != 200:
|
|
50
|
+
raise GiteaError(f"unexpected {response.status_code} from Gitea /user")
|
|
51
|
+
return response.json()["login"]
|
|
52
|
+
|
|
53
|
+
def can_publish(self, namespace: str, user: str) -> bool:
|
|
54
|
+
"""Live ACL check (D24): the namespace is the user themselves, or an
|
|
55
|
+
organisation the user is a member of. Unknown namespaces are simply
|
|
56
|
+
not publishable — Gitea owns namespace existence.
|
|
57
|
+
"""
|
|
58
|
+
if namespace == user:
|
|
59
|
+
return True
|
|
60
|
+
response = self._get(f"/api/v1/orgs/{namespace}/members/{user}")
|
|
61
|
+
if response.status_code == 204:
|
|
62
|
+
return True
|
|
63
|
+
if response.status_code < 500:
|
|
64
|
+
# Non-membership, nonexistent org, and permission refusals all
|
|
65
|
+
# come back as assorted 3xx/4xx depending on Gitea version; every
|
|
66
|
+
# one of them means "cannot publish here".
|
|
67
|
+
return False
|
|
68
|
+
raise GiteaError(f"unexpected {response.status_code} from Gitea org check")
|
|
69
|
+
|
|
70
|
+
def _blob_path(self, namespace: str, name: str, version: str, filename: str) -> str:
|
|
71
|
+
return f"/api/packages/{namespace}/generic/{name}/{version}/{filename}"
|
|
72
|
+
|
|
73
|
+
def upload_blob(
|
|
74
|
+
self, namespace: str, name: str, version: str, filename: str, data: bytes
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Write the archive to Gitea's generic package registry; returns the
|
|
77
|
+
download path (relative to the Gitea base URL) as the artifact pointer.
|
|
78
|
+
"""
|
|
79
|
+
path = self._blob_path(namespace, name, version, filename)
|
|
80
|
+
try:
|
|
81
|
+
response = self._client.put(path, content=data)
|
|
82
|
+
except httpx.HTTPError as exc:
|
|
83
|
+
raise GiteaError(f"cannot reach Gitea: {exc}") from exc
|
|
84
|
+
if response.status_code == 201:
|
|
85
|
+
return path
|
|
86
|
+
if response.status_code in (401, 403):
|
|
87
|
+
raise GiteaAuthError("Gitea refused the blob write")
|
|
88
|
+
raise GiteaError(
|
|
89
|
+
f"Gitea blob write failed with {response.status_code}: {response.text}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def delete_blob(
|
|
93
|
+
self, namespace: str, name: str, version: str, filename: str
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Best-effort cleanup of an uploaded blob after a failed publish;
|
|
96
|
+
never raises — the publish error it accompanies matters more.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
self._client.delete(self._blob_path(namespace, name, version, filename))
|
|
100
|
+
except httpx.HTTPError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_gitea_client(authorization: str | None = Header(None)) -> GiteaClient:
|
|
105
|
+
"""FastAPI dependency: a GiteaClient acting as the request's caller.
|
|
106
|
+
|
|
107
|
+
Accepts ``Authorization: token <t>`` (Gitea's own convention) or
|
|
108
|
+
``Bearer <t>``.
|
|
109
|
+
"""
|
|
110
|
+
if authorization is None:
|
|
111
|
+
raise HTTPException(401, "publishing requires a Gitea token")
|
|
112
|
+
scheme, _, token = authorization.partition(" ")
|
|
113
|
+
if scheme.lower() not in ("token", "bearer") or not token:
|
|
114
|
+
raise HTTPException(401, "malformed Authorization header")
|
|
115
|
+
return GiteaClient(gitea_url(), token)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The write path: atomic server-mediated publish (D8, D32).
|
|
2
|
+
|
|
3
|
+
Order of operations is the atomicity guarantee: every validation and
|
|
4
|
+
permission check happens first, the blob goes to Gitea next, and the index
|
|
5
|
+
record is committed only after Gitea confirms the write. Failure at any
|
|
6
|
+
step rejects the whole publish; a commit failure after the blob write
|
|
7
|
+
triggers a best-effort blob delete so nothing dangles.
|
|
8
|
+
|
|
9
|
+
Nothing the client claims is trusted (D8): identity, variant tags,
|
|
10
|
+
dependencies, and the content hash are all derived from the uploaded
|
|
11
|
+
archive server-side.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
|
22
|
+
from sqlalchemy import select
|
|
23
|
+
from sqlalchemy.exc import IntegrityError
|
|
24
|
+
from sqlalchemy.orm import Session
|
|
25
|
+
|
|
26
|
+
from scripticus_schema.manifest import (
|
|
27
|
+
FORMAT_GROUPS,
|
|
28
|
+
NAMESPACE_RE,
|
|
29
|
+
PACKAGE_NAME_RE,
|
|
30
|
+
Manifest,
|
|
31
|
+
ManifestError,
|
|
32
|
+
commands_of,
|
|
33
|
+
load_manifest,
|
|
34
|
+
)
|
|
35
|
+
from scripticus_schema.publish_api import PublishedArtifact, PublishResult
|
|
36
|
+
from scripticus_schema.treehash import tree_hash
|
|
37
|
+
from scripticus_server import db
|
|
38
|
+
from scripticus_server.db import get_session
|
|
39
|
+
from scripticus_server.gitea import (
|
|
40
|
+
GiteaAuthError,
|
|
41
|
+
GiteaClient,
|
|
42
|
+
GiteaError,
|
|
43
|
+
get_gitea_client,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
router = APIRouter()
|
|
47
|
+
|
|
48
|
+
RESERVED_NAMESPACES = ("library",)
|
|
49
|
+
|
|
50
|
+
_SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _archive_format(filename: str) -> str:
|
|
54
|
+
if filename.endswith((".tar.gz", ".tgz")):
|
|
55
|
+
return "tar.gz"
|
|
56
|
+
if filename.endswith(".zip"):
|
|
57
|
+
return "zip"
|
|
58
|
+
raise HTTPException(400, f"'{filename}' is not a supported archive (.tar.gz or .zip)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_archive(archive: Path, destination: Path) -> Path:
|
|
62
|
+
"""Extract and return the single root directory of the package tree.
|
|
63
|
+
|
|
64
|
+
The tar "data" filter (PEP 706) rejects path traversal, links pointing
|
|
65
|
+
outside the tree, and device files; zipfile's extract sanitises
|
|
66
|
+
absolute paths and parent references itself.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
if archive.name.endswith(".zip"):
|
|
70
|
+
with zipfile.ZipFile(archive) as zf:
|
|
71
|
+
zf.extractall(destination)
|
|
72
|
+
else:
|
|
73
|
+
with tarfile.open(archive) as tar:
|
|
74
|
+
tar.extractall(destination, filter="data")
|
|
75
|
+
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
|
|
76
|
+
raise HTTPException(400, f"cannot extract archive: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
roots = list(destination.iterdir())
|
|
79
|
+
if len(roots) != 1 or not roots[0].is_dir():
|
|
80
|
+
raise HTTPException(400, "archive does not contain a single package directory")
|
|
81
|
+
return roots[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _artifact_platforms(manifest: Manifest, archive_format: str) -> list[str]:
|
|
85
|
+
group = dict(FORMAT_GROUPS)[archive_format]
|
|
86
|
+
platforms = [os_name for os_name in group if os_name in manifest.platforms.os]
|
|
87
|
+
if not platforms:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
422,
|
|
90
|
+
f"a .{archive_format} archive carries {'/'.join(group)} targets, but the"
|
|
91
|
+
f" manifest declares platforms {manifest.platforms.os}",
|
|
92
|
+
)
|
|
93
|
+
return platforms
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_dependency_targets(session: Session, manifest: Manifest) -> None:
|
|
97
|
+
"""Publish-time dependency rules (D33): targets must be fully namespaced
|
|
98
|
+
and already present in the index.
|
|
99
|
+
"""
|
|
100
|
+
missing = []
|
|
101
|
+
for target in sorted(manifest.dependencies.packages):
|
|
102
|
+
namespace, _, name = target.partition("/")
|
|
103
|
+
if not (NAMESPACE_RE.match(namespace) and name and PACKAGE_NAME_RE.match(name)):
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
422,
|
|
106
|
+
f"dependency '{target}' is not a fully namespaced package"
|
|
107
|
+
" reference (namespace/name)",
|
|
108
|
+
)
|
|
109
|
+
known = session.scalar(
|
|
110
|
+
select(db.Package)
|
|
111
|
+
.join(db.Namespace)
|
|
112
|
+
.where(db.Namespace.name == namespace, db.Package.name == name)
|
|
113
|
+
)
|
|
114
|
+
if known is None or not known.versions:
|
|
115
|
+
missing.append(target)
|
|
116
|
+
if missing:
|
|
117
|
+
raise HTTPException(
|
|
118
|
+
422,
|
|
119
|
+
"dependencies not present in the index: " + ", ".join(missing),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_no_cycle(session: Session, manifest: Manifest) -> None:
|
|
124
|
+
"""Reject a publish that would make the publishing package reachable from
|
|
125
|
+
its own dependencies (D33). Edges are package-level: the union of every
|
|
126
|
+
version's declared dependencies.
|
|
127
|
+
"""
|
|
128
|
+
rows = session.execute(
|
|
129
|
+
select(db.Namespace.name, db.Package.name, db.Dependency.target)
|
|
130
|
+
.join(db.Package, db.Package.namespace_id == db.Namespace.id)
|
|
131
|
+
.join(db.PackageVersion, db.PackageVersion.package_id == db.Package.id)
|
|
132
|
+
.join(db.Dependency, db.Dependency.package_version_id == db.PackageVersion.id)
|
|
133
|
+
).all()
|
|
134
|
+
edges: dict[str, set[str]] = {}
|
|
135
|
+
for namespace, name, target in rows:
|
|
136
|
+
edges.setdefault(f"{namespace}/{name}", set()).add(target)
|
|
137
|
+
|
|
138
|
+
publishing = f"{manifest.package.namespace}/{manifest.package.name}"
|
|
139
|
+
edges.setdefault(publishing, set()).update(manifest.dependencies.packages)
|
|
140
|
+
|
|
141
|
+
seen = set()
|
|
142
|
+
stack = list(edges[publishing])
|
|
143
|
+
while stack:
|
|
144
|
+
node = stack.pop()
|
|
145
|
+
if node == publishing:
|
|
146
|
+
raise HTTPException(
|
|
147
|
+
422,
|
|
148
|
+
f"publishing would create a dependency cycle back to '{publishing}'",
|
|
149
|
+
)
|
|
150
|
+
if node in seen:
|
|
151
|
+
continue
|
|
152
|
+
seen.add(node)
|
|
153
|
+
stack.extend(edges.get(node, ()))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _storage_filename(upload_name: str, manifest: Manifest, archive_format: str) -> str:
|
|
157
|
+
"""The blob's filename in Gitea. The client's wheel-style name is kept
|
|
158
|
+
when sane (filenames are human-legible redundancy only — the manifest is
|
|
159
|
+
the source of truth), otherwise a minimal name is generated.
|
|
160
|
+
"""
|
|
161
|
+
candidate = Path(upload_name).name
|
|
162
|
+
if _SAFE_FILENAME_RE.match(candidate) and candidate.endswith(f".{archive_format}"):
|
|
163
|
+
return candidate
|
|
164
|
+
package = manifest.package
|
|
165
|
+
return (
|
|
166
|
+
f"{package.name.replace('-', '_')}-{package.version.replace('-', '_')}"
|
|
167
|
+
f".{archive_format}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.post("/packages", status_code=201)
|
|
172
|
+
def publish(
|
|
173
|
+
archive: UploadFile,
|
|
174
|
+
session: Session = Depends(get_session),
|
|
175
|
+
gitea: GiteaClient = Depends(get_gitea_client),
|
|
176
|
+
) -> PublishResult:
|
|
177
|
+
data = archive.file.read()
|
|
178
|
+
archive_format = _archive_format(Path(archive.filename or "").name)
|
|
179
|
+
|
|
180
|
+
with tempfile.TemporaryDirectory(prefix="scripticus-publish-") as staging_dir:
|
|
181
|
+
staging = Path(staging_dir)
|
|
182
|
+
upload_path = staging / f"upload.{archive_format}"
|
|
183
|
+
upload_path.write_bytes(data)
|
|
184
|
+
package_root = _extract_archive(upload_path, staging / "tree")
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
manifest = load_manifest(package_root)
|
|
188
|
+
except ManifestError as exc:
|
|
189
|
+
raise HTTPException(422, str(exc)) from exc
|
|
190
|
+
meta = manifest.package
|
|
191
|
+
|
|
192
|
+
if meta.namespace in RESERVED_NAMESPACES:
|
|
193
|
+
raise HTTPException(
|
|
194
|
+
403, f"the '{meta.namespace}' namespace is reserved"
|
|
195
|
+
)
|
|
196
|
+
platforms = _artifact_platforms(manifest, archive_format)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
user = gitea.authenticated_user()
|
|
200
|
+
allowed = gitea.can_publish(meta.namespace, user)
|
|
201
|
+
except GiteaAuthError as exc:
|
|
202
|
+
raise HTTPException(401, str(exc)) from exc
|
|
203
|
+
except GiteaError as exc:
|
|
204
|
+
raise HTTPException(502, str(exc)) from exc
|
|
205
|
+
if not allowed:
|
|
206
|
+
raise HTTPException(
|
|
207
|
+
403, f"'{user}' cannot publish to namespace '{meta.namespace}'"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
content_hash = tree_hash(package_root)
|
|
211
|
+
manifest_text = (package_root / "meta.toml").read_text()
|
|
212
|
+
|
|
213
|
+
namespace = session.scalar(
|
|
214
|
+
select(db.Namespace).where(db.Namespace.name == meta.namespace)
|
|
215
|
+
)
|
|
216
|
+
package = session.scalar(
|
|
217
|
+
select(db.Package)
|
|
218
|
+
.join(db.Namespace)
|
|
219
|
+
.where(db.Namespace.name == meta.namespace, db.Package.name == meta.name)
|
|
220
|
+
)
|
|
221
|
+
existing = None
|
|
222
|
+
if package is not None:
|
|
223
|
+
existing = next(
|
|
224
|
+
(pv for pv in package.versions if pv.version == meta.version), None
|
|
225
|
+
)
|
|
226
|
+
if existing is not None:
|
|
227
|
+
# The variant rule (D32): more artifacts may join a version only if
|
|
228
|
+
# they carry the identical tree, in a format not yet published.
|
|
229
|
+
recorded_hashes = {a.content_hash for a in existing.artifacts}
|
|
230
|
+
if recorded_hashes and content_hash not in recorded_hashes:
|
|
231
|
+
raise HTTPException(
|
|
232
|
+
409,
|
|
233
|
+
f"{meta.namespace}/{meta.name} {meta.version} already exists"
|
|
234
|
+
" with different content; versions are immutable",
|
|
235
|
+
)
|
|
236
|
+
if any(a.archive_format == archive_format for a in existing.artifacts):
|
|
237
|
+
raise HTTPException(
|
|
238
|
+
409,
|
|
239
|
+
f"{meta.namespace}/{meta.name} {meta.version} already has a"
|
|
240
|
+
f" .{archive_format} artifact; duplicate versions are rejected",
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
_check_dependency_targets(session, manifest)
|
|
244
|
+
_check_no_cycle(session, manifest)
|
|
245
|
+
|
|
246
|
+
filename = _storage_filename(archive.filename or "", manifest, archive_format)
|
|
247
|
+
try:
|
|
248
|
+
pointer = gitea.upload_blob(meta.namespace, meta.name, meta.version, filename, data)
|
|
249
|
+
except GiteaAuthError as exc:
|
|
250
|
+
raise HTTPException(401, str(exc)) from exc
|
|
251
|
+
except GiteaError as exc:
|
|
252
|
+
raise HTTPException(502, str(exc)) from exc
|
|
253
|
+
|
|
254
|
+
# Gitea has confirmed the write; only now may the index record go in.
|
|
255
|
+
try:
|
|
256
|
+
if existing is None:
|
|
257
|
+
if namespace is None:
|
|
258
|
+
namespace = db.Namespace(name=meta.namespace)
|
|
259
|
+
if package is None:
|
|
260
|
+
package = db.Package(namespace=namespace, name=meta.name)
|
|
261
|
+
version = db.PackageVersion(
|
|
262
|
+
package=package,
|
|
263
|
+
version=meta.version,
|
|
264
|
+
description=meta.description,
|
|
265
|
+
published_at=datetime.now(timezone.utc).isoformat(),
|
|
266
|
+
publisher=user,
|
|
267
|
+
)
|
|
268
|
+
for target, spec in sorted(manifest.dependencies.packages.items()):
|
|
269
|
+
version.dependencies.append(db.Dependency(target=target, spec=spec))
|
|
270
|
+
for tool in manifest.dependencies.tools.requires:
|
|
271
|
+
version.tool_deps.append(db.ToolDep(name=tool, required=True))
|
|
272
|
+
for tool in manifest.dependencies.tools.optional:
|
|
273
|
+
version.tool_deps.append(db.ToolDep(name=tool, required=False))
|
|
274
|
+
for command, script in sorted(commands_of(manifest).items()):
|
|
275
|
+
version.commands.append(db.Command(name=command, script_path=script))
|
|
276
|
+
db.ManifestBlob(package_version=version, toml=manifest_text)
|
|
277
|
+
else:
|
|
278
|
+
version = existing
|
|
279
|
+
db.Artifact(
|
|
280
|
+
package_version=version,
|
|
281
|
+
platforms=",".join(platforms),
|
|
282
|
+
language=meta.language,
|
|
283
|
+
archive_format=archive_format,
|
|
284
|
+
content_hash=content_hash,
|
|
285
|
+
size=len(data),
|
|
286
|
+
gitea_pointer=pointer,
|
|
287
|
+
)
|
|
288
|
+
session.add(version)
|
|
289
|
+
session.commit()
|
|
290
|
+
except IntegrityError as exc:
|
|
291
|
+
session.rollback()
|
|
292
|
+
gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
409,
|
|
295
|
+
f"{meta.namespace}/{meta.name} {meta.version} was published"
|
|
296
|
+
" concurrently; duplicate versions are rejected",
|
|
297
|
+
) from exc
|
|
298
|
+
except Exception:
|
|
299
|
+
session.rollback()
|
|
300
|
+
gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
return PublishResult(
|
|
304
|
+
namespace=meta.namespace,
|
|
305
|
+
name=meta.name,
|
|
306
|
+
version=meta.version,
|
|
307
|
+
content_hash=content_hash,
|
|
308
|
+
publisher=user,
|
|
309
|
+
artifact=PublishedArtifact(
|
|
310
|
+
filename=filename,
|
|
311
|
+
archive_format=archive_format,
|
|
312
|
+
platforms=platforms,
|
|
313
|
+
language=meta.language,
|
|
314
|
+
size=len(data),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
File without changes
|
|
File without changes
|