1 // GoCheese -- Python private package repository and caching proxy
2 // Copyright (C) 2019-2024 Sergey Matveev <stargrave@stargrave.org>
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, version 3 of the License.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with this program. If not, see <http://www.gnu.org/licenses/>.
36 // https://warehouse.pypa.io/api-reference/legacy.html
39 HTMLRootTmplRaw string
40 HTMLRootTmpl = template.Must(template.New("root").Parse(HTMLRootTmplRaw))
43 HTMLReleasesTmplRaw string
44 HTMLReleasesTmpl = template.Must(template.New("list").Parse(HTMLReleasesTmplRaw))
45 KnownExts = []string{".tar.bz2", ".tar.gz", ".whl", ".zip", ".egg",
46 ".exe", ".dmg", ".msi", ".rpm", ".deb", ".tgz"}
49 func listRoot(w http.ResponseWriter, r *http.Request) {
50 files, err := os.ReadDir(Root)
52 log.Println("error", r.RemoteAddr, "root", err)
53 http.Error(w, err.Error(), http.StatusInternalServerError)
56 packages := make([]string, 0, len(files))
57 for _, f := range files {
58 packages = append(packages, f.Name())
60 sort.Strings(packages)
62 err = HTMLRootTmpl.Execute(&buf, struct {
66 RefreshURLPath: *RefreshURLPath,
70 log.Println("error", r.RemoteAddr, "root", err)
71 http.Error(w, err.Error(), http.StatusInternalServerError)
77 type PkgReleaseInfoByName []*PkgReleaseInfo
79 func (a PkgReleaseInfoByName) Len() int {
83 func (a PkgReleaseInfoByName) Swap(i, j int) {
84 a[i], a[j] = a[j], a[i]
87 func (a PkgReleaseInfoByName) Less(i, j int) bool {
88 if a[i].Version == a[j].Version {
89 return a[i].Filename < a[j].Filename
91 return a[i].Version < a[j].Version
94 // Version format is too complicated: https://www.python.org/dev/peps/pep-0386/
95 // So here is very simple parser working good enough for most packages
96 func filenameToVersion(fn string) string {
98 for _, ext := range KnownExts {
99 trimmed = strings.TrimSuffix(fn, ext)
105 cols := strings.Split(fn, "-")
106 for i := 0; i < len(cols); i++ {
107 if len(cols[i]) == 0 {
110 if ('0' <= cols[i][0]) && (cols[i][0] <= '9') {
120 func listDir(pkgName string, doSize bool) (int64, []*PkgReleaseInfo, error) {
121 dirPath := filepath.Join(Root, pkgName)
122 entries, err := os.ReadDir(dirPath)
126 files := make(map[string]fs.DirEntry, len(entries))
127 for _, entry := range entries {
131 if entry.Name()[0] == '.' {
134 files[entry.Name()] = entry
136 releaseFiles := make(map[string]*PkgReleaseInfo)
137 for _, algo := range KnownHashAlgos {
138 for fn, entry := range files {
140 return 0, nil, errors.New("killed")
142 if !strings.HasSuffix(fn, "."+algo) {
146 digest, err := os.ReadFile(filepath.Join(dirPath, fn))
150 fnClean := strings.TrimSuffix(fn, "."+algo)
151 release := releaseFiles[fnClean]
153 fi, err := entry.Info()
157 release = &PkgReleaseInfo{
159 Version: filenameToVersion(fnClean),
160 UploadTimeISO8601: fi.ModTime().UTC().Truncate(
162 ).Format(time.RFC3339),
163 Digests: make(map[string]string),
165 releaseFiles[fnClean] = release
166 if entry, exists := files[fnClean]; exists {
168 fi, err := entry.Info()
172 release.Size = fi.Size()
174 delete(files, fnClean)
177 release.Digests[algo] = hex.EncodeToString(digest)
180 releases := make([]*PkgReleaseInfo, 0, len(releaseFiles))
181 for _, release := range releaseFiles {
182 releases = append(releases, release)
184 sort.Sort(PkgReleaseInfoByName(releases))
185 fi, err := os.Stat(dirPath)
189 serial := fi.ModTime().Unix()
190 if fi, err = os.Stat(filepath.Join(dirPath, MDFile)); err == nil {
191 serial += fi.ModTime().Unix()
193 return serial, releases, nil
197 w http.ResponseWriter,
202 dirPath := filepath.Join(Root, pkgName)
204 if !refreshDir(w, r, pkgName, "") {
207 } else if _, err := os.Stat(dirPath); os.IsNotExist(err) &&
208 !refreshDir(w, r, pkgName, "") {
211 serial, releases, err := listDir(pkgName, false)
213 log.Println("error", r.RemoteAddr, "list", pkgName, err)
214 http.Error(w, err.Error(), http.StatusInternalServerError)
217 for _, release := range releases {
218 singleDigest := make(map[string]string)
219 if digest, exists := release.Digests[HashAlgoSHA256]; exists {
220 singleDigest[HashAlgoSHA256] = digest
221 } else if digest, exists := release.Digests[HashAlgoSHA512]; exists {
222 singleDigest[HashAlgoSHA512] = digest
223 } else if digest, exists := release.Digests[HashAlgoBLAKE2b256]; exists {
224 singleDigest[HashAlgoBLAKE2b256] = digest
226 singleDigest = release.Digests
228 release.Digests = singleDigest
231 err = HTMLReleasesTmpl.Execute(&buf, struct {
232 RefreshURLPath string
234 Releases []*PkgReleaseInfo
236 RefreshURLPath: *RefreshURLPath,
241 log.Println("error", r.RemoteAddr, "list", pkgName, err)
242 http.Error(w, err.Error(), http.StatusInternalServerError)
245 w.Header().Set("X-PyPI-Last-Serial", strconv.FormatInt(serial, 10))
247 w.Write([]byte(fmt.Sprintf("<!--SERIAL %d-->\n", serial)))