├── .github └── workflows │ └── push.yml ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── icon-diff.svg ├── icon-download.svg ├── icon-file.svg ├── icon-folder.svg ├── icon-package.svg ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── robots.txt ├── safari-pinned-tab.svg └── site.webmanifest ├── go.mod ├── go.sum ├── handlers ├── compare.go ├── download.go ├── read.go └── versions.go ├── internal ├── diff │ ├── compare.go │ └── patch.go ├── registry │ ├── client.go │ ├── error.go │ ├── mock │ │ └── client.go │ └── standard │ │ ├── client.go │ │ └── read.go └── util │ ├── break.go │ ├── break_test.go │ ├── semver.go │ ├── semver_test.go │ ├── size.go │ ├── unique.go │ └── unique_test.go ├── main.go ├── main_test.go └── templates ├── layout.html ├── logo.html ├── pages.go ├── pages ├── compare.html ├── directory.html ├── error.html ├── file.html ├── home.html └── versions.html └── templates.go /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: deploy 3 | jobs: 4 | deploy: 5 | name: deploy 6 | runs-on: ubuntu-latest 7 | steps: 8 | 9 | # Setup. 10 | - uses: actions/checkout@master 11 | 12 | # Run tests. Failure will abort deployment. 13 | - name: go test 14 | uses: cedrickring/golang-action@1.3.0 15 | 16 | # Auth glcoud command. 17 | - uses: 'google-github-actions/auth@v2' 18 | with: 19 | credentials_json: ${{ secrets.GCLOUD_AUTH }} 20 | 21 | # Setup gcloud command. 22 | - uses: google-github-actions/setup-gcloud@v2 23 | with: 24 | version: '275.0.0' 25 | 26 | # Build new deployable image. 27 | - run: gcloud builds submit --tag gcr.io/npmfs-242515/website 28 | env: 29 | CLOUDSDK_CORE_PROJECT: npmfs-242515 30 | 31 | # Deploy new image. 32 | - run: gcloud --quiet run deploy --image gcr.io/npmfs-242515/website --allow-unauthenticated --region=us-central1 --timeout=8s website --platform=managed 33 | env: 34 | CLOUDSDK_CORE_PROJECT: npmfs-242515 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.12-alpine AS build 2 | 3 | # Required to fetch go modules at build time. 4 | RUN apk add git 5 | 6 | WORKDIR /npmfs 7 | 8 | COPY . . 9 | 10 | RUN go build -o website . 11 | 12 | # 13 | 14 | FROM alpine:3.9 15 | 16 | RUN apk add ca-certificates 17 | RUN apk add git 18 | 19 | WORKDIR /npmfs 20 | 21 | # Copy server binary from first stage. 22 | COPY --from=build /npmfs/website . 23 | 24 | # Copy static files from project source. 25 | COPY assets assets 26 | COPY templates templates 27 | 28 | CMD ./website 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gabriel Harel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | # [npmfs](https://npmfs.com) 10 | 11 | ## Development 12 | 13 | ```bash 14 | $ go run main.go 15 | ``` 16 | 17 | _Requires a least `go1.11` (for go modules support) and `git` to be installed._ 18 | 19 | _Replace `go` with [`gin`](https://github.com/codegangsta/gin) for auto-restarts._ 20 | 21 | ## 22 | 23 | Tests are run using the standard go test command (`go test ./...`). 24 | 25 | ## Deployment 26 | 27 | This project is hosted on [Google Cloud Platform](https://cloud.google.com/)'s [Cloud Run](https://cloud.google.com/run/) product _(currently in beta)_. 28 | 29 | The [deployment workflow](./.github/main.workflow) uses [GitHub Actions](https://developer.github.com/actions/) to publish a new image and update the running service on push. 30 | 31 | ## License 32 | 33 | [MIT](./LICENSE) 34 | -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/favicon.ico -------------------------------------------------------------------------------- /assets/icon-diff.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icon-download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icon-file.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icon-folder.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icon-package.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/mstile-144x144.png -------------------------------------------------------------------------------- /assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/mstile-150x150.png -------------------------------------------------------------------------------- /assets/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/mstile-310x150.png -------------------------------------------------------------------------------- /assets/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/mstile-310x310.png -------------------------------------------------------------------------------- /assets/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/npmfs/30eef250fe81a3566647ce00027e364f540d26ba/assets/mstile-70x70.png -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Googlebot 2 | Allow: /*/README.md 3 | 4 | User-agent: * 5 | Allow: /$ 6 | Disallow: / 7 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 31 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/assets/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/g-harel/npmfs 2 | 3 | require ( 4 | github.com/NYTimes/gziphandler v1.1.1 5 | github.com/davecgh/go-spew v1.1.1 // indirect 6 | github.com/gorilla/mux v1.7.2 7 | github.com/stretchr/testify v1.4.0 // indirect 8 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 9 | gopkg.in/yaml.v2 v2.2.7 // indirect 10 | ) 11 | 12 | go 1.13 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= 2 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I= 8 | github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 12 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 13 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 14 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 15 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 16 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 17 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 20 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 21 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 22 | gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= 23 | gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 24 | -------------------------------------------------------------------------------- /handlers/compare.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/g-harel/npmfs/internal/diff" 9 | "github.com/g-harel/npmfs/internal/registry" 10 | "github.com/g-harel/npmfs/templates" 11 | "github.com/gorilla/mux" 12 | "golang.org/x/xerrors" 13 | ) 14 | 15 | // Compare handler displays a diff between two package versions. 16 | func Compare(client registry.Client) http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | vars := mux.Vars(r) 19 | name := vars["name"] 20 | versionA := vars["a"] 21 | versionB := vars["b"] 22 | 23 | if versionA == versionB { 24 | templates.PageError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)).Handler(w, r) 25 | return 26 | } 27 | versions := []string{versionA, versionB} 28 | 29 | // Download both package version contents in parallel. 30 | type downloadedDir struct { 31 | version string 32 | dir string 33 | err error 34 | } 35 | dirChan := make(chan downloadedDir) 36 | for _, version := range versions { 37 | go func(v string) { 38 | dir, err := client.Download(name, v) 39 | dirChan <- downloadedDir{v, dir, err} 40 | }(version) 41 | } 42 | 43 | // Wait for both version's contents to be downloaded. 44 | dirs := map[string]string{} 45 | for _ = range versions { 46 | dir := <-dirChan 47 | if dir.err != nil { 48 | var registryErr *registry.Error 49 | if xerrors.As(dir.err, ®istryErr) { 50 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 51 | return 52 | } 53 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 54 | log.Printf("ERROR download package '%v': %v", dir.version, dir.err) 55 | return 56 | } 57 | dirs[dir.version] = dir.dir 58 | } 59 | 60 | // Compare contents. 61 | patches, err := diff.Compare(dirs[versionA], dirs[versionB]) 62 | if err != nil { 63 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 64 | log.Printf("ERROR compare package contents: %v", err) 65 | return 66 | } 67 | 68 | // Cleanup created directories. 69 | for _, path := range dirs { 70 | _ = os.RemoveAll(path) 71 | } 72 | 73 | // Render page template. 74 | templates.PageCompare(name, versionA, versionB, patches).Handler(w, r) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /handlers/download.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "mime" 8 | "net/http" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/g-harel/npmfs/internal/registry" 13 | "github.com/g-harel/npmfs/templates" 14 | "github.com/gorilla/mux" 15 | "golang.org/x/xerrors" 16 | ) 17 | 18 | // DownloadFile handler serves a file from the package contents. 19 | func DownloadFile(client registry.Client) http.HandlerFunc { 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | vars := mux.Vars(r) 22 | name := vars["name"] 23 | version := vars["version"] 24 | path := vars["path"] 25 | 26 | filename := filepath.Base(path) 27 | 28 | w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(filename))) 29 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v", filename)) 30 | 31 | file, err := client.File(name, version, path) 32 | if err != nil { 33 | var registryErr *registry.Error 34 | if xerrors.As(err, ®istryErr) { 35 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 36 | return 37 | } 38 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 39 | log.Printf("ERROR fetch file: %v", err) 40 | return 41 | } 42 | io.WriteString(w, file) 43 | } 44 | } 45 | 46 | // DownloadDir handler serves a zip archive of the package contents. 47 | func DownloadDir(client registry.Client) http.HandlerFunc { 48 | return func(w http.ResponseWriter, r *http.Request) { 49 | vars := mux.Vars(r) 50 | name := vars["name"] 51 | version := vars["version"] 52 | path := vars["path"] 53 | 54 | filename := fmt.Sprintf("%v-%v-%v", name, version, strings.ReplaceAll(path, "/", "-")) 55 | filename = strings.TrimSuffix(filename, "-") 56 | filename += ".zip" 57 | 58 | w.Header().Set("Content-Type", "application/zip") 59 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v", filename)) 60 | 61 | err := client.Archive(name, version, path, w) 62 | if err != nil { 63 | var registryErr *registry.Error 64 | if xerrors.As(err, ®istryErr) { 65 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 66 | return 67 | } 68 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 69 | log.Printf("ERROR fetch package archive: %v", err) 70 | return 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /handlers/read.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/g-harel/npmfs/internal/registry" 9 | "github.com/g-harel/npmfs/internal/util" 10 | "github.com/g-harel/npmfs/templates" 11 | "github.com/gorilla/mux" 12 | "golang.org/x/xerrors" 13 | ) 14 | 15 | // ReadFile handler displays a file view of package contents at the provided path. 16 | func ReadFile(client registry.Client) http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | vars := mux.Vars(r) 19 | name := vars["name"] 20 | version := vars["version"] 21 | path := vars["path"] 22 | 23 | // Fetch file contents. 24 | file, err := client.File(name, version, path) 25 | if err != nil { 26 | var registryErr *registry.Error 27 | if xerrors.As(err, ®istryErr) { 28 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 29 | return 30 | } 31 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 32 | log.Printf("ERROR fetch file: %v", err) 33 | return 34 | } 35 | 36 | parts, links := util.BreakPathRelative(path) 37 | lines := strings.Split(file, "\n") 38 | 39 | // Render page template. 40 | templates.PageFile(name, version, util.ByteCount(int64(len(file))), parts, links, lines).Handler(w, r) 41 | } 42 | } 43 | 44 | // ReadDir handler displays a directory view of package contents at the provided path. 45 | func ReadDir(client registry.Client) http.HandlerFunc { 46 | return func(w http.ResponseWriter, r *http.Request) { 47 | vars := mux.Vars(r) 48 | name := vars["name"] 49 | version := vars["version"] 50 | path := vars["path"] 51 | 52 | dirs, files, err := client.Directory(name, version, path) 53 | if err != nil { 54 | var registryErr *registry.Error 55 | if xerrors.As(err, ®istryErr) { 56 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 57 | return 58 | } 59 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 60 | log.Printf("ERROR fetch directory: %v", err) 61 | return 62 | } 63 | 64 | parts, links := util.BreakPathRelative(path) 65 | 66 | // Render page template. 67 | templates.PageDirectory(name, version, parts, links, dirs, files).Handler(w, r) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /handlers/versions.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "sort" 7 | 8 | "github.com/g-harel/npmfs/internal/registry" 9 | "github.com/g-harel/npmfs/internal/util" 10 | "github.com/g-harel/npmfs/templates" 11 | "github.com/gorilla/mux" 12 | "golang.org/x/xerrors" 13 | ) 14 | 15 | // Versions handler displays all available package versions. 16 | func Versions(client registry.Client) http.HandlerFunc { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | vars := mux.Vars(r) 19 | name := vars["name"] 20 | disabled := vars["disabled"] 21 | 22 | // Fetch and sort version list. 23 | versions, latest, err := client.Versions(name) 24 | if err != nil { 25 | var registryErr *registry.Error 26 | if xerrors.As(err, ®istryErr) { 27 | templates.PageError(registryErr.StatusCode, registryErr.Error()).Handler(w, r) 28 | return 29 | } 30 | templates.PageError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)).Handler(w, r) 31 | log.Printf("ERROR fetch package versions: %v", err) 32 | return 33 | } 34 | sort.Sort(util.SemverSort(versions)) 35 | 36 | // Render page template. 37 | templates.PageVersions(name, latest, disabled, versions).Handler(w, r) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/diff/compare.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "path" 8 | "strings" 9 | 10 | "github.com/g-harel/npmfs/internal/util" 11 | "golang.org/x/xerrors" 12 | ) 13 | 14 | // ExecGit runs a git command in the specified directory and returns its output. 15 | func execGit(dir string, arg ...string) (string, error) { 16 | cmd := exec.Command("git", arg...) 17 | cmd.Dir = dir 18 | 19 | out, err := cmd.CombinedOutput() 20 | if err != nil { 21 | return "", xerrors.Errorf("run command 'git %v': %v\n%w", strings.Join(arg, " "), err, string(out)) 22 | } 23 | 24 | return string(out), nil 25 | } 26 | 27 | // Compare diffs the contents of two directories. 28 | // The current implementation uses "git-diff-tree" to detect renames. 29 | // Whitespace changes are ignored. 30 | func Compare(a, b string) ([]*Patch, error) { 31 | // Create temporary working directory. 32 | dir, err := ioutil.TempDir("", "") 33 | if err != nil { 34 | return nil, xerrors.Errorf("create temp dir: %w", err) 35 | } 36 | contentPath := path.Join(dir, "content") 37 | 38 | // Initialize git repository. 39 | _, err = execGit(dir, "init") 40 | if err != nil { 41 | return nil, err 42 | } 43 | _, err = execGit(dir, "config", "user.email", "server@npmfs.com") 44 | if err != nil { 45 | return nil, err 46 | } 47 | _, err = execGit(dir, "config", "user.name", "server") 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // Move contents from "a" into repository. 53 | err = os.Rename(a, contentPath) 54 | if err != nil { 55 | return nil, xerrors.Errorf("copy contents: %w", err) 56 | } 57 | 58 | // Commit version "a" to the repository. 59 | _, err = execGit(dir, "add", ".") 60 | if err != nil { 61 | return nil, err 62 | } 63 | _, err = execGit(dir, "commit", "--allow-empty", "-m", a) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // Return contents from "a" to original path. 69 | err = os.Rename(contentPath, a) 70 | if err != nil { 71 | return nil, xerrors.Errorf("copy contents: %w", err) 72 | } 73 | 74 | // Move contents from "b" into repository. 75 | err = os.Rename(b, contentPath) 76 | if err != nil { 77 | return nil, xerrors.Errorf("copy contents: %w", err) 78 | } 79 | 80 | // Commit version "b" to the repository. 81 | _, err = execGit(dir, "add", ".") 82 | if err != nil { 83 | return nil, err 84 | } 85 | _, err = execGit(dir, "commit", "--allow-empty", "-m", b) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | // Compute diff between contents. 91 | out, err := execGit(dir, "diff-tree", "--patch", "-r", "--find-renames", "--ignore-all-space", "HEAD~", "HEAD") 92 | if err != nil { 93 | return nil, xerrors.Errorf("compute diff: %w", err) 94 | } 95 | 96 | // Return contents from "b" to original path. 97 | err = os.Rename(contentPath, b) 98 | if err != nil { 99 | return nil, xerrors.Errorf("copy contents: %w", err) 100 | } 101 | 102 | // Clean up temporary directory. 103 | _ = os.RemoveAll(dir) 104 | 105 | // Parse output text. 106 | patches, err := patchParse(out) 107 | if err != nil { 108 | return nil, xerrors.Errorf("parse output: %w", err) 109 | } 110 | 111 | // Attach size delta to patches. 112 | for _, patch := range patches { 113 | statA, err := os.Stat(path.Join(a, patch.PathA)) 114 | if err != nil { 115 | return nil, xerrors.Errorf("stat file a: %v", err) 116 | } 117 | 118 | statB, err := os.Stat(path.Join(b, patch.PathB)) 119 | if err != nil { 120 | return nil, xerrors.Errorf("stat file b: %v", err) 121 | } 122 | 123 | patch.SizeChange = util.SignedByteCount(statB.Size() - statA.Size()) 124 | } 125 | 126 | return patches, nil 127 | } 128 | -------------------------------------------------------------------------------- /internal/diff/patch.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Patch represents a single file diff. 10 | // File was created/deleted when one of "PathA" or "PathB" is empty. 11 | // File was renamed when "PathA" and "PathB" are not equal. 12 | type Patch struct { 13 | PathA string 14 | PathB string 15 | SizeChange string 16 | Lines []PatchLine 17 | } 18 | 19 | // PatchLine represents a single line of diff output. 20 | // Line was created/deleted when one of "LineA" or "LineB" is zero. 21 | // Hunks starts when both "LineA" and "LineB" are zero. 22 | type PatchLine struct { 23 | LineA int 24 | LineB int 25 | Content string 26 | } 27 | 28 | // PatchParse parses standard diff output. 29 | func patchParse(out string) ([]*Patch, error) { 30 | hunkPattern := regexp.MustCompile(`@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*`) 31 | 32 | patches := []*Patch{} 33 | lines := strings.Split(out, "\n") 34 | 35 | patch := &Patch{} 36 | lineA := 0 37 | lineB := 0 38 | for _, line := range lines { 39 | // Detect start of new patch. 40 | if strings.HasPrefix(line, "diff --git ") { 41 | patch = &Patch{} 42 | patches = append(patches, patch) 43 | continue 44 | } 45 | 46 | // Detect file name in "a". 47 | if strings.HasPrefix(line, "---") { 48 | if strings.HasPrefix(line, "--- a/content/") { 49 | patch.PathA = strings.TrimPrefix(line, "--- a/content/") 50 | } 51 | continue 52 | } 53 | 54 | // Detect file name in "b". 55 | if strings.HasPrefix(line, "+++") { 56 | if strings.HasPrefix(line, "+++ b/content/") { 57 | patch.PathB = strings.TrimPrefix(line, "+++ b/content/") 58 | } 59 | continue 60 | } 61 | 62 | // Detect start of hunk. 63 | if strings.HasPrefix(line, "@@ ") { 64 | match := hunkPattern.FindStringSubmatch(line) 65 | if len(match) < 3 { 66 | continue 67 | } 68 | 69 | lineA, _ = strconv.Atoi(match[1]) 70 | lineB, _ = strconv.Atoi(match[2]) 71 | 72 | if len(patch.Lines) != 0 { 73 | patch.Lines = append(patch.Lines, PatchLine{0, 0, line}) 74 | } 75 | continue 76 | } 77 | 78 | // Detect added lines. 79 | if strings.HasPrefix(line, "+") { 80 | patch.Lines = append(patch.Lines, PatchLine{0, lineB, strings.TrimPrefix(line, "+")}) 81 | lineB++ 82 | continue 83 | } 84 | 85 | // Detect deleted lines. 86 | if strings.HasPrefix(line, "-") { 87 | patch.Lines = append(patch.Lines, PatchLine{lineA, 0, strings.TrimPrefix(line, "-")}) 88 | lineA++ 89 | continue 90 | } 91 | 92 | // Detect unchanged lines. 93 | if strings.HasPrefix(line, " ") { 94 | patch.Lines = append(patch.Lines, PatchLine{lineA, lineB, strings.TrimPrefix(line, " ")}) 95 | lineA++ 96 | lineB++ 97 | continue 98 | } 99 | 100 | // Unrecognized lines are ignored. 101 | } 102 | 103 | return patches, nil 104 | } 105 | -------------------------------------------------------------------------------- /internal/registry/client.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import "io" 4 | 5 | // Client defines the required interface for a registry. 6 | type Client interface { 7 | // Archive writes a zip archive of the package contents at path to out. 8 | Archive(name, version, path string, out io.Writer) (err error) 9 | // Directory reads files and sub-directories at the given path. 10 | Directory(name, version, path string) (dirs, files []string, err error) 11 | // Download writes a package's contents to a temporary directory and returns its path. 12 | Download(name, version string) (dir string, err error) 13 | // File reads a file's contents at the given path. 14 | File(name, version, path string) (file string, err error) 15 | // Versions fetches all package versions from the registry. 16 | Versions(name string) (versions []string, latest string, err error) 17 | } 18 | -------------------------------------------------------------------------------- /internal/registry/error.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Error indicates the appropriate status code return to the user. 8 | type Error struct { 9 | StatusCode int 10 | } 11 | 12 | // Error implements the error interface and provides a short description of the error. 13 | func (e *Error) Error() string { 14 | return http.StatusText(e.StatusCode) 15 | } 16 | 17 | // ErrNotFound signals a package and version combination is not found. 18 | var ErrNotFound = &Error{http.StatusNotFound} 19 | 20 | // ErrGatewayTimeout signals the remote registry did not respond before the timeout duration. 21 | var ErrGatewayTimeout = &Error{http.StatusGatewayTimeout} 22 | 23 | // ErrBadGateway signals an unexpected upstream error. 24 | var ErrBadGateway = &Error{http.StatusBadGateway} 25 | -------------------------------------------------------------------------------- /internal/registry/mock/client.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/g-harel/npmfs/internal/registry" 11 | "github.com/g-harel/npmfs/internal/util" 12 | "golang.org/x/xerrors" 13 | ) 14 | 15 | // Client is a mock implementation of the registry.Client interface. 16 | type Client struct { 17 | Contents map[string]map[string]string 18 | Latest string 19 | DirectoryErr error 20 | DownloadErr error 21 | FileErr error 22 | VersionsErr error 23 | } 24 | 25 | var _ registry.Client = &Client{} 26 | 27 | // Archive writes a zip archive of all mocked contents to out. 28 | func (c *Client) Archive(name, version, path string, out io.Writer) error { 29 | // TODO 30 | return nil 31 | } 32 | 33 | // Directory lists all the sub-directories and files at the given path in the mocked contents. 34 | // Package name is ignored. 35 | func (c *Client) Directory(name, version, path string) ([]string, []string, error) { 36 | versionContents, ok := c.Contents[version] 37 | if !ok { 38 | return nil, nil, registry.ErrNotFound 39 | } 40 | 41 | dirs := []string{} 42 | files := []string{} 43 | for filepath := range versionContents { 44 | if strings.HasPrefix(filepath, path) { 45 | filepath := strings.TrimPrefix(filepath, path) 46 | pathParts := strings.Split(filepath, "/") 47 | if len(pathParts) == 1 { 48 | files = append(files, pathParts[0]) 49 | } else { 50 | dirs = append(dirs, pathParts[0]) 51 | } 52 | } 53 | } 54 | if len(dirs) == 0 && len(files) == 0 { 55 | return nil, nil, registry.ErrNotFound 56 | } 57 | 58 | return util.Unique(dirs), util.Unique(files), nil 59 | } 60 | 61 | // Download writes the mocked contents to a temporary directory. 62 | // Package name is ignored. 63 | func (c *Client) Download(name, version string) (string, error) { 64 | dir, err := ioutil.TempDir("", "") 65 | if err != nil { 66 | return "", xerrors.Errorf("create temp dir: %w", err) 67 | } 68 | 69 | versionContents, ok := c.Contents[version] 70 | if !ok { 71 | return "", registry.ErrNotFound 72 | } 73 | 74 | for filepath, contents := range versionContents { 75 | outPath := path.Join(dir, filepath, "package") 76 | 77 | err := os.MkdirAll(path.Dir(outPath), os.ModePerm) 78 | if err != nil { 79 | return "", xerrors.Errorf("create file output path: %w", err) 80 | } 81 | 82 | outFile, err := os.Create(outPath) 83 | if err != nil { 84 | return "", xerrors.Errorf("create output file: %w", err) 85 | } 86 | 87 | _, err = io.Copy(outFile, strings.NewReader(contents)) 88 | if err != nil { 89 | return "", xerrors.Errorf("write file contents: %w", err) 90 | } 91 | 92 | err = outFile.Close() 93 | if err != nil { 94 | return "", xerrors.Errorf("close file: %w", err) 95 | } 96 | } 97 | 98 | return dir, c.DownloadErr 99 | } 100 | 101 | // File reads a file's contents at the given path in the mocked contents. 102 | // Package name is ignored. 103 | func (c *Client) File(name, version, path string) (string, error) { 104 | versionContents, ok := c.Contents[version] 105 | if !ok { 106 | return "", registry.ErrNotFound 107 | } 108 | 109 | fileContents, ok := versionContents[path] 110 | if !ok { 111 | return "", registry.ErrNotFound 112 | } 113 | 114 | return fileContents, c.FileErr 115 | } 116 | 117 | // Versions returns all versions listed in the contents and the specified latest value. 118 | // Package name is ignored. 119 | func (c *Client) Versions(name string) ([]string, string, error) { 120 | versions := []string{} 121 | for v := range c.Contents { 122 | versions = append(versions, v) 123 | } 124 | 125 | return versions, c.Latest, c.VersionsErr 126 | } 127 | -------------------------------------------------------------------------------- /internal/registry/standard/client.go: -------------------------------------------------------------------------------- 1 | package standard 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "os" 13 | "path" 14 | "strings" 15 | "time" 16 | 17 | "github.com/g-harel/npmfs/internal/registry" 18 | "github.com/g-harel/npmfs/internal/util" 19 | "golang.org/x/xerrors" 20 | ) 21 | 22 | // Client implements the registry.Client interface for standard registries. 23 | type Client struct { 24 | Host string 25 | } 26 | 27 | var _ registry.Client = &Client{} 28 | 29 | // Archive writes a zip archive of the package contents at path to out. 30 | func (c *Client) Archive(name, version, path string, out io.Writer) error { 31 | w := zip.NewWriter(out) 32 | defer w.Close() 33 | err := c.read(name, version, func(name string, contents io.Reader) error { 34 | filepath := strings.TrimPrefix(name, "package/") 35 | if !strings.HasPrefix(filepath, path) { 36 | return nil 37 | } 38 | filepath = strings.TrimPrefix(filepath, path) 39 | f, err := w.Create(filepath) 40 | if err != nil { 41 | return xerrors.Errorf("create file: %w", err) 42 | } 43 | _, err = io.Copy(f, contents) 44 | if err != nil { 45 | return xerrors.Errorf("copy file contents: %w", err) 46 | } 47 | return nil 48 | }) 49 | if err != nil { 50 | return xerrors.Errorf("read package contents: %w", err) 51 | } 52 | return nil 53 | } 54 | 55 | // Directory reads files and sub-directories at the given path. 56 | func (c *Client) Directory(name, version, path string) ([]string, []string, error) { 57 | dirs := []string{} 58 | files := []string{} 59 | err := c.read(name, version, func(name string, contents io.Reader) error { 60 | filepath := strings.TrimPrefix(name, "package/") 61 | if strings.HasPrefix(filepath, path) { 62 | filepath := strings.TrimPrefix(filepath, path) 63 | pathParts := strings.Split(filepath, "/") 64 | if len(pathParts) == 1 { 65 | files = append(files, pathParts[0]) 66 | } else { 67 | dirs = append(dirs, pathParts[0]) 68 | } 69 | } 70 | return nil 71 | }) 72 | if err != nil { 73 | return nil, nil, xerrors.Errorf("read package contents: %w", err) 74 | } 75 | if len(dirs) == 0 && len(files) == 0 { 76 | log.Printf("ERROR standard registry: directory: empty: %v:%v@%v/%v", c.Host, name, version, path) 77 | return nil, nil, registry.ErrNotFound 78 | } 79 | 80 | return util.Unique(dirs), util.Unique(files), nil 81 | } 82 | 83 | // Download writes a package's contents to a temporary directory and returns its path. 84 | func (c *Client) Download(name, version string) (string, error) { 85 | // Create temporary directory. 86 | dir, err := ioutil.TempDir("", "") 87 | if err != nil { 88 | return "", xerrors.Errorf("create temp dir: %w", err) 89 | } 90 | 91 | // Write package contents to the target directory. 92 | err = c.read(name, version, func(name string, contents io.Reader) error { 93 | outPath := path.Join(dir, strings.TrimPrefix(name, "package")) 94 | 95 | err := os.MkdirAll(path.Dir(outPath), os.ModePerm) 96 | if err != nil { 97 | return xerrors.Errorf("create file output path: %w", err) 98 | } 99 | 100 | outFile, err := os.Create(outPath) 101 | if err != nil { 102 | return xerrors.Errorf("create output file: %w", err) 103 | } 104 | 105 | _, err = io.Copy(outFile, contents) 106 | if err != nil { 107 | return xerrors.Errorf("write file contents: %w", err) 108 | } 109 | 110 | err = outFile.Close() 111 | if err != nil { 112 | return xerrors.Errorf("close file: %w", err) 113 | } 114 | 115 | return nil 116 | }) 117 | if err != nil { 118 | return "", xerrors.Errorf("read package contents: %w", err) 119 | } 120 | 121 | return dir, nil 122 | } 123 | 124 | // File reads a file's contents at the given path. 125 | func (c *Client) File(name, version, path string) (string, error) { 126 | file := "" 127 | found := false 128 | err := c.read(name, version, func(name string, contents io.Reader) error { 129 | if !found && strings.TrimPrefix(name, "package/") == path { 130 | buf := new(bytes.Buffer) 131 | _, err := buf.ReadFrom(contents) 132 | if err != nil { 133 | return xerrors.Errorf("copy contents: %w", err) 134 | } 135 | file = buf.String() 136 | found = true 137 | } 138 | return nil 139 | }) 140 | if err != nil { 141 | return "", xerrors.Errorf("read package contents: %w", err) 142 | } 143 | if !found { 144 | log.Printf("ERROR standard registry: file: not found: %v:%v@%v/%v", c.Host, name, version, path) 145 | return "", registry.ErrNotFound 146 | } 147 | 148 | return file, nil 149 | } 150 | 151 | // Versions fetches all package versions from the registry. 152 | func (c *Client) Versions(name string) ([]string, string, error) { 153 | client := &http.Client{Timeout: 4 * time.Second} 154 | 155 | url := fmt.Sprintf("https://%s/%s", c.Host, name) 156 | response, err := client.Get(url) 157 | if os.IsTimeout(err) { 158 | log.Printf("ERROR standard registry: versions: timeout: %v:%v", c.Host, name) 159 | return nil, "", registry.ErrGatewayTimeout 160 | } 161 | if err != nil { 162 | return nil, "", xerrors.Errorf("request contents: %w", err) 163 | } 164 | defer response.Body.Close() 165 | 166 | if response.StatusCode == http.StatusNotFound { 167 | log.Printf("ERROR standard registry: versions: not found (%v)", url) 168 | return nil, "", registry.ErrNotFound 169 | } 170 | if response.StatusCode != http.StatusOK { 171 | log.Printf("ERROR standard registry: versions: unexpected status code (%v): %v", url, response.StatusCode) 172 | return nil, "", registry.ErrBadGateway 173 | } 174 | 175 | data := &struct { 176 | Versions map[string]interface{} `json:"versions"` 177 | Tags struct { 178 | Latest string `json:"latest"` 179 | } `json:"dist-tags"` 180 | }{} 181 | 182 | err = json.NewDecoder(response.Body).Decode(data) 183 | if err != nil { 184 | return nil, "", xerrors.Errorf("decode response body: %w", err) 185 | } 186 | 187 | versions := make([]string, len(data.Versions)) 188 | count := 0 189 | for version := range data.Versions { 190 | versions[count] = version 191 | count++ 192 | } 193 | 194 | return versions, data.Tags.Latest, nil 195 | } 196 | -------------------------------------------------------------------------------- /internal/registry/standard/read.go: -------------------------------------------------------------------------------- 1 | package standard 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/g-harel/npmfs/internal/registry" 15 | "golang.org/x/xerrors" 16 | ) 17 | 18 | type stdReadHandler func(name string, contents io.Reader) error 19 | 20 | // Helper to fetch the contents of a package and call the handler with each file. 21 | func (c *Client) read(name, version string, handler stdReadHandler) error { 22 | client := &http.Client{Timeout: 4 * time.Second} 23 | 24 | // Extract the package name without its scope (@abc/def -> def). 25 | // Non-scoped package names do not need to be changed. 26 | scopedName := name 27 | if strings.Contains(name, "/") { 28 | scopedName = strings.Split(name, "/")[1] 29 | } 30 | 31 | // Fetch .tgz archive of package contents. 32 | url := fmt.Sprintf("https://%s/%s/-/%s-%s.tgz", c.Host, name, scopedName, version) 33 | response, err := client.Get(url) 34 | if os.IsTimeout(err) { 35 | log.Printf("ERROR standard registry: read: timeout (%v)", url) 36 | return registry.ErrGatewayTimeout 37 | } 38 | if err != nil { 39 | return xerrors.Errorf("request contents: %w", err) 40 | } 41 | defer response.Body.Close() 42 | 43 | if response.StatusCode == http.StatusNotFound { 44 | log.Printf("ERROR standard registry: read: not found (%v)", url) 45 | return registry.ErrNotFound 46 | } 47 | if response.StatusCode != http.StatusOK { 48 | log.Printf("ERROR standard registry: read: unexpected status code (%v): %v", url, response.StatusCode) 49 | return registry.ErrBadGateway 50 | } 51 | 52 | // Extract tarball from body. 53 | extractedBody, err := gzip.NewReader(response.Body) 54 | if err != nil { 55 | return xerrors.Errorf("extract gzip data: %w", err) 56 | } 57 | 58 | // Extract package contents from tarball. 59 | tarball := tar.NewReader(extractedBody) 60 | for { 61 | header, err := tarball.Next() 62 | if err == io.EOF { 63 | break 64 | } 65 | if err != nil { 66 | return xerrors.Errorf("advance to next file: %w", err) 67 | } 68 | 69 | if header.FileInfo().IsDir() { 70 | continue 71 | } 72 | 73 | err = handler(header.Name, tarball) 74 | if err != nil { 75 | return xerrors.Errorf("handler error: %w", err) 76 | } 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/util/break.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // BreakPathRelative splits up the given path and calculates a relative link for each part. 8 | func BreakPathRelative(path string) (parts []string, links []string) { 9 | // Remove leading slash. 10 | if len(path) > 0 && path[0] == '/' { 11 | path = path[1:] 12 | } 13 | 14 | // Empty paths are ignored. 15 | if len(path) == 0 { 16 | return 17 | } 18 | 19 | parts = strings.Split(path, "/") 20 | 21 | // Generate a decreasing series of relative links (ex. "../../", "../", ""). 22 | links = []string{} 23 | for i := len(parts); i >= 0; i-- { 24 | links = append(links, strings.Repeat("../", i)) 25 | } 26 | 27 | // Change behavior if the path represents a directory. 28 | if path[len(path)-1] == '/' { 29 | // Remove last path part, which will always be empty because of trailing slash. 30 | return parts[:len(parts)-1], links[2:] 31 | } 32 | 33 | // Add a "./" entry in the before-last position in the returned links. 34 | links = append(links[:len(links)-1], "./", links[len(links)-1]) 35 | return parts, links[2:] 36 | } 37 | -------------------------------------------------------------------------------- /internal/util/break_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/g-harel/npmfs/internal/util" 7 | ) 8 | 9 | func sliceEqual(t *testing.T, expected, received []string) { 10 | if len(expected) != len(received) { 11 | t.Fatalf("expected/received do not match\n%v\n%v", expected, received) 12 | } 13 | for i := range expected { 14 | if expected[i] != received[i] { 15 | t.Fatalf("expected/received do not match\n%v\n%v", expected, received) 16 | } 17 | } 18 | } 19 | 20 | func TestBreakPathRelative(t *testing.T) { 21 | tt := map[string]struct { 22 | Input string 23 | ExpectedParts []string 24 | ExpectedLinks []string 25 | }{ 26 | "root": { 27 | Input: "", 28 | ExpectedParts: []string{}, 29 | ExpectedLinks: []string{}, 30 | }, 31 | "root file": { 32 | Input: "img.jpg", 33 | ExpectedParts: []string{"img.jpg"}, 34 | ExpectedLinks: []string{""}, 35 | }, 36 | "single dir": { 37 | Input: "test/", 38 | ExpectedParts: []string{"test"}, 39 | ExpectedLinks: []string{""}, 40 | }, 41 | "nested file": { 42 | Input: "test/img.jpg", 43 | ExpectedParts: []string{"test", "img.jpg"}, 44 | ExpectedLinks: []string{"./", ""}, 45 | }, 46 | "nested dir": { 47 | Input: "test/path/", 48 | ExpectedParts: []string{"test", "path"}, 49 | ExpectedLinks: []string{"../", ""}, 50 | }, 51 | "deeply nested file": { 52 | Input: "test/path/img.jpg", 53 | ExpectedParts: []string{"test", "path", "img.jpg"}, 54 | ExpectedLinks: []string{"../", "./", ""}, 55 | }, 56 | } 57 | 58 | for name, tc := range tt { 59 | t.Run(name, func(t *testing.T) { 60 | parts, links := util.BreakPathRelative(tc.Input) 61 | 62 | t.Run("parts", func(t *testing.T) { 63 | sliceEqual(t, tc.ExpectedParts, parts) 64 | }) 65 | 66 | t.Run("links", func(t *testing.T) { 67 | sliceEqual(t, tc.ExpectedLinks, links) 68 | }) 69 | 70 | t.Run("leading slash ignored", func(t *testing.T) { 71 | parts, links := util.BreakPathRelative("/" + tc.Input) 72 | 73 | t.Run("parts", func(t *testing.T) { 74 | sliceEqual(t, tc.ExpectedParts, parts) 75 | }) 76 | 77 | t.Run("links", func(t *testing.T) { 78 | sliceEqual(t, tc.ExpectedLinks, links) 79 | }) 80 | }) 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/util/semver.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "sort" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // SemverSort is a helper type to order semver version slices in decreasing order. 10 | // All elements of the slice are assumed to be valid semver versions. 11 | type SemverSort []string 12 | 13 | var _ sort.Interface = SemverSort{} 14 | 15 | func (s SemverSort) Len() int { 16 | return len(s) 17 | } 18 | 19 | func (s SemverSort) Swap(i, j int) { 20 | s[i], s[j] = s[j], s[i] 21 | } 22 | 23 | func (s SemverSort) Less(i, j int) bool { 24 | splitA := semverSplit(s[i]) 25 | splitB := semverSplit(s[j]) 26 | 27 | for i := 0; ; i++ { 28 | // When major, minor, and patch are equal, a pre-release version has lower precedence than a normal version. 29 | // A larger set of pre-release fields has a higher precedence than a smaller set, if all of the preceding identifiers are equal. 30 | // Pre-release fields start at index i == 3. 31 | if len(splitA) <= i { 32 | return i <= 3 33 | } 34 | if len(splitB) <= i { 35 | return i > 3 36 | } 37 | 38 | res := semverIdentifierCompare(splitA[i], splitB[i]) 39 | if res == 0 { 40 | // Equal identifiers means the next ones should be compared. 41 | continue 42 | } 43 | return res > 0 44 | } 45 | } 46 | 47 | // SemverSplit separates version strings into identifiers (1.2.3-alpha.1 => [1, 2, 3, alpha, 1]). 48 | // First split around the first "-" for version/pre-release. 49 | // Then split around "." for individual identifiers. 50 | func semverSplit(version string) []string { 51 | dashSplit := strings.SplitN(version, "-", 2) 52 | parts := strings.Split(dashSplit[0], ".") 53 | if len(dashSplit) > 1 { 54 | parts = append(parts, strings.Split(dashSplit[1], ".")...) 55 | } 56 | return parts 57 | } 58 | 59 | // SemverIdentifierCompare compares two identifiers to determine precedence. 60 | // Identifiers consisting of only digits are compared numerically. 61 | // Identifiers with letters or hyphens are compared lexically in ASCII sort order 62 | // Numeric identifiers always have lower precedence than non-numeric identifiers 63 | func semverIdentifierCompare(a, b string) int { 64 | aNum, aStr := strconv.Atoi(a) 65 | bNum, bStr := strconv.Atoi(b) 66 | 67 | if aStr != nil && bStr != nil { 68 | return strings.Compare(a, b) 69 | } 70 | if aStr != nil { 71 | return 1 72 | } 73 | if bStr != nil { 74 | return -1 75 | } 76 | 77 | return aNum - bNum 78 | } 79 | -------------------------------------------------------------------------------- /internal/util/semver_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "math/rand" 5 | "sort" 6 | "testing" 7 | 8 | "github.com/g-harel/npmfs/internal/util" 9 | ) 10 | 11 | func TestSemverSort(t *testing.T) { 12 | // https://semver.org/#semantic-versioning-specification-semver 13 | tt := map[string]struct { 14 | Expected []string 15 | }{ 16 | "single element": { 17 | Expected: []string{"1.11.0", "1.10.0", "1.9.0"}, 18 | }, 19 | "element priority": { 20 | Expected: []string{"2.1.1", "2.1.0", "2.0.0", "1.0.0"}, 21 | }, 22 | "pre-release": { 23 | Expected: []string{"1.0.0", "1.0.0-alpha"}, 24 | }, 25 | "pre-release identifiers": { 26 | Expected: []string{"1.0.0", "1.0.0-rc.1", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-beta", "1.0.0-alpha.beta", "1.0.0-alpha.1", "1.0.0-alpha"}, 27 | }, 28 | } 29 | 30 | for name, tc := range tt { 31 | t.Run(name, func(t *testing.T) { 32 | // Shuffle and re-sort test case. 33 | input := append([]string{}, tc.Expected...) 34 | rand.Shuffle(len(input), func(i, j int) { 35 | input[i], input[j] = input[j], input[i] 36 | }) 37 | sort.Sort(util.SemverSort(input)) 38 | 39 | sliceEqual(t, tc.Expected, input) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/util/size.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | ) 7 | 8 | // https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/ 9 | func ByteCount(b int64) string { 10 | const unit = 1000 11 | if b < unit { 12 | return fmt.Sprintf("%d B", b) 13 | } 14 | div, exp := int64(unit), 0 15 | for n := b / unit; n >= unit; n /= unit { 16 | div *= unit 17 | exp++ 18 | } 19 | return fmt.Sprintf("%.1f %cB", 20 | float64(b)/float64(div), "kMGTPE"[exp]) 21 | } 22 | 23 | func SignedByteCount(b int64) string { 24 | sign := "+" 25 | if b < 0 { 26 | sign = "-" 27 | } 28 | return sign + ByteCount(int64(math.Abs(float64(b)))) 29 | } 30 | -------------------------------------------------------------------------------- /internal/util/unique.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | // Unique sorts and de-duplicates the input slice. 8 | func Unique(s []string) []string { 9 | m := map[string]interface{}{} 10 | for _, item := range s { 11 | m[item] = true 12 | } 13 | out := []string{} 14 | for key := range m { 15 | out = append(out, key) 16 | } 17 | sort.Strings(out) 18 | return out 19 | } 20 | -------------------------------------------------------------------------------- /internal/util/unique_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/g-harel/npmfs/internal/util" 7 | ) 8 | 9 | func TestUnique(t *testing.T) { 10 | tt := map[string]struct { 11 | Input []string 12 | Expected []string 13 | }{ 14 | "empty": { 15 | Input: []string{}, 16 | Expected: []string{}, 17 | }, 18 | "unique": { 19 | Input: []string{"a", "a", "b", "c", "c", "c"}, 20 | Expected: []string{"a", "b", "c"}, 21 | }, 22 | "sort": { 23 | Input: []string{"z", "y", "x", "a", "b", "c"}, 24 | Expected: []string{"a", "b", "c", "x", "y", "z"}, 25 | }, 26 | "sort_and_unique": { 27 | Input: []string{"aa", "a", "ccc", "bb", "a", "ccc", "b"}, 28 | Expected: []string{"a", "aa", "b", "bb", "ccc"}, 29 | }, 30 | } 31 | 32 | for name, tc := range tt { 33 | t.Run(name, func(t *testing.T) { 34 | sliceEqual(t, tc.Expected, util.Unique(tc.Input)) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "regexp" 9 | 10 | "github.com/NYTimes/gziphandler" 11 | "github.com/g-harel/npmfs/handlers" 12 | "github.com/g-harel/npmfs/internal/registry" 13 | "github.com/g-harel/npmfs/internal/registry/standard" 14 | "github.com/g-harel/npmfs/templates" 15 | "github.com/gorilla/mux" 16 | ) 17 | 18 | // Known public registries. 19 | var ( 20 | NPM registry.Client = &standard.Client{Host: "registry.npmjs.com"} 21 | Yarn registry.Client = &standard.Client{Host: "registry.yarnpkg.com"} 22 | Open registry.Client = &standard.Client{Host: "npm.open-registry.dev"} 23 | ) 24 | 25 | // Redirect responds with a temporary redirect to the rewritten path. 26 | // Path params (ex. "{name}") are looked up in "mux.Vars()". 27 | func redirect(path string) http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | vars := mux.Vars(r) 30 | param := regexp.MustCompile("\\{.+?\\}") 31 | newPath := param.ReplaceAllStringFunc(path, func(match string) string { 32 | return vars[match[1:len(match)-1]] 33 | }) 34 | http.Redirect(w, r, newPath, http.StatusFound) 35 | } 36 | } 37 | 38 | // HTTPSHandler is middleware to redirect HTTP requests to the HTTPS equivalent. 39 | // The middleware assumes it is receiving a forwarded request. 40 | // Local development is not impacted unless requests specify the checked header. 41 | func httpsHandler(h http.Handler) http.Handler { 42 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | if r.Header.Get("X-Forwarded-Proto") == "http" { 44 | newURL := fmt.Sprintf("https://%v%v", r.Host, r.RequestURI) 45 | http.Redirect(w, r, newURL, http.StatusMovedPermanently) 46 | } 47 | h.ServeHTTP(w, r) 48 | }) 49 | } 50 | 51 | // Routes returns an http handler with all the routes/handlers attached. 52 | func routes(client registry.Client) http.Handler { 53 | r := mux.NewRouter() 54 | 55 | // Add gzip middleware to all handlers. 56 | r.Use(gziphandler.GzipHandler) 57 | 58 | // Redirect all HTTP requests. 59 | r.Use(httpsHandler) 60 | 61 | // Show homepage. 62 | r.HandleFunc("/", templates.PageHome().Handler) 63 | 64 | var ( 65 | // Name pattern matches with simple and org-scoped names. 66 | // (ex. "lodash", "react", "@types/express") 67 | nameP = "{name:(?:@[^/]+\\/)?[^/@]+}" 68 | // Directory path pattern matches everything that ends with a path separator. 69 | dirP = "{path:(?:.+/)?$}" 70 | // File path pattern matches everything that does not end in a path separator. 71 | fileP = "{path:.*[^/]$}" 72 | ) 73 | 74 | // Show package versions. 75 | r.HandleFunc("/package/"+nameP+"/", handlers.Versions(client)) 76 | r.HandleFunc("/package/"+nameP, redirect("/package/{name}/")) 77 | r.HandleFunc("/compare/"+nameP+"/", redirect("/package/{name}/")) 78 | r.HandleFunc("/compare/"+nameP, redirect("/package/{name}/")) 79 | 80 | // Show package contents. 81 | r.HandleFunc("/package/"+nameP+"/v/{version}", redirect("/package/{name}/{version}/")) 82 | r.HandleFunc("/package/"+nameP+"/v/{version}/", redirect("/package/{name}/{version}/")) 83 | r.PathPrefix("/package/" + nameP + "/{version}/" + dirP).HandlerFunc(handlers.ReadDir(client)) 84 | r.PathPrefix("/package/" + nameP + "/{version}/" + fileP).HandlerFunc(handlers.ReadFile(client)) 85 | r.HandleFunc("/package/"+nameP+"/{version}", redirect("/package/{name}/{version}/")) 86 | 87 | // Pick second version to compare to. 88 | r.HandleFunc("/compare/"+nameP+"/{disabled}/", handlers.Versions(client)) 89 | r.HandleFunc("/compare/"+nameP+"/{disabled}", redirect("/compare/{name}/{disabled}/")) 90 | 91 | // Compare package versions. 92 | r.HandleFunc("/compare/"+nameP+"/{a}/{b}/", handlers.Compare(client)) 93 | r.HandleFunc("/compare/"+nameP+"/{a}/{b}", redirect("/compare/{name}/{a}/{b}/")) 94 | 95 | // Download package contents. 96 | r.HandleFunc("/download/"+nameP+"/{version}/"+dirP, handlers.DownloadDir(client)) 97 | r.HandleFunc("/download/"+nameP+"/{version}/"+fileP, handlers.DownloadFile(client)) 98 | r.HandleFunc("/download/"+nameP+"/{version}", redirect("/download/{name}/{version}/")) 99 | 100 | // Static assets. 101 | assets := http.FileServer(http.Dir("assets")) 102 | r.PathPrefix("/assets/").Handler(http.StripPrefix("/assets/", assets)) 103 | r.HandleFunc("/favicon.ico", redirect("/assets/favicon.ico")) 104 | r.HandleFunc("/robots.txt", redirect("/assets/robots.txt")) 105 | 106 | // Attempt to match single path as package name. 107 | // Handlers registered before this point have a higher matching priority. 108 | r.HandleFunc("/"+nameP, redirect("/package/{name}/")) 109 | r.HandleFunc("/"+nameP+"/", redirect("/package/{name}/")) 110 | 111 | // Catch all requests that don't match any other handler as 404. 112 | r.PathPrefix("/").HandlerFunc(func (w http.ResponseWriter, r *http.Request) { 113 | templates.PageError(http.StatusNotFound, http.StatusText(http.StatusNotFound)).Handler(w, r) 114 | }) 115 | 116 | return r 117 | } 118 | 119 | func main() { 120 | // Take port number from environment if provided. 121 | // https://cloud.google.com/run/docs/reference/container-contract 122 | port := os.Getenv("PORT") 123 | if port == "" { 124 | port = "8080" 125 | } 126 | 127 | log.Printf("accepting connections at :%v", port) 128 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%v", port), routes(NPM))) 129 | } 130 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | mockRegistry "github.com/g-harel/npmfs/internal/registry/mock" 10 | ) 11 | 12 | func TestRoutes(t *testing.T) { 13 | tt := map[string]struct { 14 | Path string 15 | Status int 16 | Redirect string 17 | }{ 18 | "home": { 19 | Path: "/", 20 | Status: http.StatusOK, 21 | }, 22 | "static favicon": { 23 | Path: "/favicon.ico", 24 | Status: http.StatusOK, 25 | Redirect: "/assets/favicon.ico", 26 | }, 27 | "static robots.txt": { 28 | Path: "/robots.txt", 29 | Status: http.StatusOK, 30 | Redirect: "/assets/robots.txt", 31 | }, 32 | "static icon": { 33 | Path: "/assets/icon-package.svg", 34 | Status: http.StatusOK, 35 | }, 36 | "package versions shortcut": { 37 | Path: "/test", 38 | Status: http.StatusOK, 39 | Redirect: "/package/test/", 40 | }, 41 | "namespaced package versions shortcut": { 42 | Path: "/@test/test", 43 | Status: http.StatusOK, 44 | Redirect: "/package/@test/test/", 45 | }, 46 | "package versions": { 47 | Path: "/package/test", 48 | Status: http.StatusOK, 49 | Redirect: "/package/test/", 50 | }, 51 | "namespaced package versions": { 52 | Path: "/package/@test/test", 53 | Status: http.StatusOK, 54 | Redirect: "/package/@test/test/", 55 | }, 56 | "package versions from compare": { 57 | Path: "/compare/test", 58 | Status: http.StatusOK, 59 | Redirect: "/package/test/", 60 | }, 61 | "namespaced package versions from compare": { 62 | Path: "/compare/@test/test", 63 | Status: http.StatusOK, 64 | Redirect: "/package/@test/test/", 65 | }, 66 | "package contents": { 67 | Path: "/package/test/0.0.0", 68 | Status: http.StatusOK, 69 | Redirect: "/package/test/0.0.0/", 70 | }, 71 | "package contents v redirect": { 72 | Path: "/package/test/v/0.0.0", 73 | Status: http.StatusOK, 74 | Redirect: "/package/test/0.0.0/", 75 | }, 76 | "namespaced package contents": { 77 | Path: "/package/@test/test/0.0.0", 78 | Status: http.StatusOK, 79 | Redirect: "/package/@test/test/0.0.0/", 80 | }, 81 | "namespaced package contents v redirect": { 82 | Path: "/package/@test/test/v/0.0.0", 83 | Status: http.StatusOK, 84 | Redirect: "/package/@test/test/0.0.0/", 85 | }, 86 | "package file": { 87 | Path: "/package/test/0.0.0/README.md", 88 | Status: http.StatusOK, 89 | }, 90 | "namespaced package file": { 91 | Path: "/package/@test/test/0.0.0/README.md", 92 | Status: http.StatusOK, 93 | }, 94 | "package compare picker": { 95 | Path: "/compare/test/0.0.0", 96 | Status: http.StatusOK, 97 | Redirect: "/compare/test/0.0.0/", 98 | }, 99 | "namespaced package compare picker": { 100 | Path: "/compare/@test/test/0.0.0", 101 | Status: http.StatusOK, 102 | Redirect: "/compare/@test/test/0.0.0/", 103 | }, 104 | "package compare": { 105 | Path: "/compare/test/0.0.0/1.1.1", 106 | Status: http.StatusOK, 107 | Redirect: "/compare/test/0.0.0/1.1.1/", 108 | }, 109 | "namespaced package compare": { 110 | Path: "/compare/@test/test/0.0.0/1.1.1", 111 | Status: http.StatusOK, 112 | Redirect: "/compare/@test/test/0.0.0/1.1.1/", 113 | }, 114 | "package download": { 115 | Path: "/download/test/0.0.0", 116 | Status: http.StatusOK, 117 | Redirect: "/download/test/0.0.0/", 118 | }, 119 | "namespaced package download": { 120 | Path: "/download/@test/test/0.0.0", 121 | Status: http.StatusOK, 122 | Redirect: "/download/@test/test/0.0.0/", 123 | }, 124 | "file download": { 125 | Path: "/download/test/0.0.0/README.md", 126 | Status: http.StatusOK, 127 | }, 128 | "namespaced file download": { 129 | Path: "/download/@test/test/0.0.0/README.md", 130 | Status: http.StatusOK, 131 | }, 132 | "invalid": { 133 | Path: "/@a", 134 | Status: http.StatusNotFound, 135 | }, 136 | } 137 | 138 | client := &mockRegistry.Client{ 139 | Latest: "1.1.1", 140 | Contents: map[string]map[string]string{ 141 | "0.0.0": { 142 | "README.md": "", 143 | }, 144 | "1.1.1": { 145 | "README.md": "", 146 | }, 147 | }, 148 | } 149 | 150 | srv := httptest.NewServer(routes(client)) 151 | defer srv.Close() 152 | 153 | for name, tc := range tt { 154 | t.Run(name, func(t *testing.T) { 155 | println("xxx - ",tc.Path) 156 | res, err := http.Get(fmt.Sprintf("%v%v", srv.URL, tc.Path)) 157 | if err != nil { 158 | t.Fatalf("send GET request: %v", err) 159 | } 160 | 161 | if res.StatusCode != tc.Status { 162 | t.Fatalf("expected/received status codes do not match\n%v\n%v", tc.Status, res.StatusCode) 163 | } 164 | 165 | path := res.Request.URL.EscapedPath() 166 | if tc.Path != path { 167 | if tc.Redirect == "" { 168 | t.Fatalf("unexpected redirection\n%v\n%v", tc.Path, path) 169 | } else { 170 | if tc.Redirect != path { 171 | t.Fatalf("expected/received redirected paths do not match\n%v\n%v", tc.Redirect, path) 172 | } 173 | } 174 | } 175 | }) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | npmfs - {{ template "title" . -}} 18 | 19 | 20 | 21 | 49 | 50 | 51 | 90 | 91 | 92 | 116 | 239 | 240 | {{- template "style" . -}} 241 | 242 | 243 |
244 | {{- template "logo" -}} 245 |
246 |
247 | {{- template "body" . -}} 248 |
249 | 257 | 258 | 444 | 445 | 446 | -------------------------------------------------------------------------------- /templates/logo.html: -------------------------------------------------------------------------------- 1 | {{- define "logo" -}} 2 | 11 | 12 | 35 | {{- end -}} 36 | -------------------------------------------------------------------------------- /templates/pages.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "github.com/g-harel/npmfs/internal/diff" 5 | ) 6 | 7 | // PageHome returns a renderer for the home page. 8 | func PageHome() *Renderer { 9 | return &Renderer{ 10 | filenames: []string{ 11 | "templates/layout.html", 12 | "templates/logo.html", 13 | "templates/pages/home.html", 14 | }, 15 | context: nil, 16 | } 17 | } 18 | 19 | // PageCompare returns a renderer for the compare page. 20 | func PageCompare(name, versionA, versionB string, patches []*diff.Patch) *Renderer { 21 | return &Renderer{ 22 | filenames: []string{ 23 | "templates/layout.html", 24 | "templates/logo.html", 25 | "templates/pages/compare.html", 26 | }, 27 | context: struct { 28 | Package string 29 | VersionA string 30 | VersionB string 31 | Patches []*diff.Patch 32 | }{ 33 | Package: name, 34 | VersionA: versionA, 35 | VersionB: versionB, 36 | Patches: patches, 37 | }, 38 | } 39 | } 40 | 41 | // PageDirectory returns a renderer for the directory page. 42 | func PageDirectory(name, version string, path, links, dirs, files []string) *Renderer { 43 | return &Renderer{ 44 | filenames: []string{ 45 | "templates/layout.html", 46 | "templates/logo.html", 47 | "templates/pages/directory.html", 48 | }, 49 | context: struct { 50 | Package string 51 | Version string 52 | Path []string 53 | PathLinks []string 54 | Directories []string 55 | Files []string 56 | }{ 57 | Package: name, 58 | Version: version, 59 | Path: path, 60 | PathLinks: links, 61 | Directories: dirs, 62 | Files: files, 63 | }, 64 | } 65 | } 66 | 67 | // PageFile returns a renderer for the file page. 68 | func PageFile(name, version, size string, path, links, lines []string) *Renderer { 69 | return &Renderer{ 70 | filenames: []string{ 71 | "templates/layout.html", 72 | "templates/logo.html", 73 | "templates/pages/file.html", 74 | }, 75 | context: struct { 76 | Package string 77 | Version string 78 | Size string 79 | Path []string 80 | PathLinks []string 81 | Lines []string 82 | }{ 83 | Package: name, 84 | Version: version, 85 | Size: size, 86 | Path: path, 87 | PathLinks: links, 88 | Lines: append([]string{""}, lines...), 89 | }, 90 | } 91 | } 92 | 93 | // PageVersions returns a renderer for the versions page. 94 | func PageVersions(name, latest, disabled string, versions []string) *Renderer { 95 | return &Renderer{ 96 | filenames: []string{ 97 | "templates/layout.html", 98 | "templates/logo.html", 99 | "templates/pages/versions.html", 100 | }, 101 | context: struct { 102 | Package string 103 | Latest string 104 | Disabled string 105 | Versions []string 106 | }{ 107 | Package: name, 108 | Latest: latest, 109 | Disabled: disabled, 110 | Versions: versions, 111 | }, 112 | } 113 | } 114 | 115 | // PageError returns a renderer for a generic error page. 116 | func PageError(status int, info string) *Renderer { 117 | return &Renderer{ 118 | statusCode: status, 119 | filenames: []string{ 120 | "templates/layout.html", 121 | "templates/logo.html", 122 | "templates/pages/error.html", 123 | }, 124 | context: struct { 125 | Status int 126 | Info string 127 | }{ 128 | Status: status, 129 | Info: info, 130 | }, 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /templates/pages/compare.html: -------------------------------------------------------------------------------- 1 | 20 | 21 | {{- define "title" -}} 22 | {{- .Package -}} 23 | {{- end -}} 24 | 25 | {{- define "description" -}} 26 | Diff package contents {{ .Package }} @ {{ .VersionA }} .. {{ .VersionB }} 27 | {{- end -}} 28 | 29 | {{- define "style" -}} 30 | 77 | {{- end -}} 78 | 79 | {{- define "body" -}} 80 | {{- $package := .Package -}} 81 | {{- $versionA := .VersionA -}} 82 | {{- $versionB := .VersionB -}} 83 | {{- range $i, $patch := .Patches -}} 84 | {{- if gt (len $patch.Lines) 0 -}} 85 | 86 |
87 |
88 |
89 | {{- $packageA := print "/package/" $package "/" $versionA "/" -}} 90 | {{- $packageB := print "/package/" $package "/" $versionB "/" -}} 91 | 92 | {{- $package -}} 93 | @ 94 | {{- $versionA -}} 95 | .. 96 | {{- $versionB -}} 97 | - 98 | 99 | {{ $linkA := print $packageA $patch.PathA -}} 100 | {{- $linkB := print $packageB $patch.PathB -}} 101 | 102 | {{- if and (eq $patch.PathA "") (ne $patch.PathB "") -}} 103 | create 104 | {{- $patch.PathB -}} 105 | {{- else if and (ne $patch.PathA "") (eq $patch.PathB "") -}} 106 | delete 107 | {{- $patch.PathA -}} 108 | {{- else if eq $patch.PathA $patch.PathB -}} 109 | {{- $patch.PathB -}} 110 | {{- else -}} 111 | rename 112 | {{- $patch.PathB -}} 113 | 🡒 114 | {{- $patch.PathA -}} 115 | {{- end -}} 116 |  ({{- $patch.SizeChange -}}) 117 |
118 |
119 |
120 | {{- range $j, $line := $patch.Lines -}} 121 | 122 | 123 | {{- if and (eq $line.LineA 0) (eq $line.LineB 0) -}} 124 |
125 |
126 |
127 |
{{- $line.Content -}}
128 |
129 | 130 |
131 | {{- else if and (ne $line.LineA 0) (ne $line.LineB 0) -}} 132 | 133 |
134 | {{- $line.LineA -}} 135 | {{- $line.LineB -}} 136 |
{{- $line.Content -}}
137 |
138 | 139 |
140 | {{- else if and (ne $line.LineA 0) (eq $line.LineB 0) -}} 141 | 142 |
143 | {{- $line.LineA -}} 144 |
-
145 |
{{- $line.Content -}}
146 |
147 | 148 |
149 | {{- else if and (eq $line.LineA 0) (ne $line.LineB 0) -}} 150 | 151 |
152 |
+
153 | {{- $line.LineB -}} 154 |
{{- $line.Content -}}
155 |
156 | 157 |
158 | {{- end -}} 159 | 160 | {{- end -}} 161 |
162 |
163 | {{- end -}} 164 | {{- end -}} 165 | {{- end -}} 166 | -------------------------------------------------------------------------------- /templates/pages/directory.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | {{- define "title" -}} 15 | {{- .Package -}} 16 | {{- end -}} 17 | 18 | {{- define "description" -}} 19 | View directory contents {{ .Package }} @ {{ .Version -}} 20 | {{- if gt (len .Path) 0 }} - {{ end -}} 21 | {{ range $i, $part := .Path -}} 22 | {{- if ne $i 0 -}}/{{- end -}} 23 | {{- $part -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{- define "style" -}} 28 | 50 | {{- end -}} 51 | 52 | {{- define "body" -}} 53 |
54 |
55 | {{- .Package -}} 56 | @ 57 | 58 | 59 | {{- if gt (len .Path) 0 -}} 60 | {{- .Version -}} 61 | -  62 | {{- else -}} 63 | {{- .Version -}} 64 | {{- end -}} 65 | 66 | {{- $links := .PathLinks -}} 67 | {{- range $i, $part := .Path -}} 68 | {{- if ne $part "" -}} 69 | 70 | {{- $link := (index $links $i) -}} 71 | {{- if ne $link "" -}} 72 | {{- if ne $i 0 -}}/{{- end -}}{{- $part -}} 73 | {{- else -}} 74 | {{- if ne $i 0 -}}/{{- end -}}{{- $part -}} 75 | {{- end -}} 76 | {{- end -}} 77 | {{- end -}} 78 |
79 | 80 |
81 | 82 | {{- if eq (len .Path) 0 -}} 83 | 84 | 85 | 86 | diff 88 | 89 | {{- end -}} 90 | 91 | 92 | 93 | download 94 | 95 | 96 |
97 |
98 | {{- if ne (len .Directories) 0 -}} 99 |
100 | {{- range $i, $name := .Directories -}} 101 |
102 | 103 | 104 | {{- $name -}} 105 | 106 |
107 | {{- end -}} 108 |
109 | {{- end -}} 110 | {{- if ne (len .Files) 0 -}} 111 |
112 | {{- range $i, $name := .Files -}} 113 |
114 | 115 | 116 | {{- $name -}} 117 | 118 |
119 | {{- end -}} 120 |
121 | {{- end -}} 122 | {{- end -}} 123 | -------------------------------------------------------------------------------- /templates/pages/error.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | {{- define "title" -}} 11 | {{- .Status }} {{ .Info -}} 12 | {{- end -}} 13 | 14 | {{- define "description" -}} 15 | {{- .Status }} {{ .Info -}} 16 | {{- end -}} 17 | 18 | {{- define "style" -}} 19 | 37 | {{- end -}} 38 | 39 | {{- define "body" -}} 40 |
41 | 42 | {{- .Status }} {{ .Info -}} 43 | 44 |
45 | {{- end -}} 46 | -------------------------------------------------------------------------------- /templates/pages/file.html: -------------------------------------------------------------------------------- 1 | 15 | 16 | {{- define "title" -}} 17 | {{- .Package -}} 18 | {{- end -}} 19 | 20 | {{- define "description" -}} 21 | View file contents {{ .Package }} @ {{ .Version }} - {{ range $i, $part := .Path -}} 22 | {{- if ne $i 0 -}}/{{- end -}} 23 | {{- $part -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{- define "style" -}} 28 | 29 | {{- end -}} 30 | 31 | {{- define "body" -}} 32 |
33 |
34 | {{- .Package -}} 35 | @ 36 | {{- .Version -}} 37 | -  38 | 39 | {{- $links := .PathLinks -}} 40 | {{- range $i, $part := .Path -}} 41 | 42 | {{- $link := (index $links $i) -}} 43 | {{- if ne $link "" -}} 44 | {{- if ne $i 0 -}}/{{- end -}}{{- $part -}} 45 | {{- else -}} 46 | {{- if ne $i 0 -}}/{{- end -}}{{- $part -}} 47 | {{- end -}} 48 | {{- end -}} 49 |  ({{- .Size -}}) 50 |
51 | 52 |
53 | 54 | 55 | 56 | download 57 | 58 | 59 |
60 |
61 |
62 | {{- range $i, $line := .Lines -}} 63 | {{- if ne $i 0 -}} 64 |
65 | {{- $i -}} 66 |
{{- $line -}}
67 |
68 | 69 |
70 | {{- end -}} 71 | {{- end -}} 72 |
73 | {{- end -}} 74 | -------------------------------------------------------------------------------- /templates/pages/home.html: -------------------------------------------------------------------------------- 1 | {{- define "title" -}} 2 | home 3 | {{- end -}} 4 | 5 | {{- define "description" -}} 6 | JavaScript Package Inspector 7 | {{- end -}} 8 | 9 | {{- define "style" -}} 10 | 78 | {{- end -}} 79 | 80 | {{- define "body" -}} 81 |
82 |

JavaScript Package Inspector

83 | 105 | 106 |

Motivations

107 |

Published packages don't always include a link to their source on GitHub.

108 |

The linked repository is not necessarily representative of published package.

109 | 110 |

Usage

111 |

Package root provides links to all available versions, and highlights the one tagged as latest.

112 |
113 |             https://npmfs.com/<name>
114 |             https://npmfs.com/package/<name>
115 |         
116 |

When viewing a package on npm, it is conveniently accessible with a one-char edit to the page url.

117 |
118 |              https://www.npmjs.com/package/<name>
119 |              https://www.npmfs.com/package/<name>
120 |         
121 |
122 | Examples 123 | https://npmfs.com/lodash 124 |
125 | https://npmfs.com/package/lodash 126 |
127 | https://npmfs.com/request 128 |
129 | https://npmfs.com/package/request 130 |
131 |
132 | 133 |

Specific package versions can be accessed directly.

134 |
135 |             https://npmfs.com/package/<name>/<version>
136 |         
137 |
138 | Examples 139 | https://npmfs.com/package/chalk/2.4.2 140 |
141 | https://npmfs.com/package/chalk/1.0.0 142 |
143 | https://npmfs.com/package/commander/2.20.0 144 |
145 | https://npmfs.com/package/commander/1.0.0 146 |
147 |
148 | 149 |

Directories and files inside the package are viewed by appending the path.

150 |
151 |             https://npmfs.com/package/<name>/<version>/<path>
152 |             https://npmfs.com/package/<name>/<version>/example/
153 |             https://npmfs.com/package/<name>/<version>/example/index.js
154 |         
155 |
156 | Examples 157 | https://npmfs.com/package/async/3.0.1/internal/ 158 |
159 | https://npmfs.com/package/async/3.0.1/internal/once.js 160 |
161 | https://npmfs.com/package/react/16.8.6/umd/ 162 |
163 | https://npmfs.com/package/react/16.8.6/package.json 164 |
165 |
166 | 167 |

Package diffs (version-0 .. version-1) are viewed by navigating to the root directory of version-0, clicking on the diff link in the top right, and selecting version-1 in the version list.

168 |
169 |             https://npmfs.com/compare/<name>/<version-0>/<version-1>
170 |         
171 |

In this compare view, line numbers are a shortcut to their respective file's source.

172 |
173 | Examples 174 | https://npmfs.com/compare/debug/4.1.0/4.1.1 175 |
176 | https://npmfs.com/compare/debug/3.0.0/4.0.0 177 |
178 | https://npmfs.com/compare/underscore/1.9.0/1.9.1 179 |
180 | https://npmfs.com/compare/underscore/1.6.0/1.7.0 181 |
182 |
183 | 184 |

Deep links are found on the right side of lines in both the file and diff views. These links will add a hash to the url which scrolls the browser to the selected line, and highlights it.

185 |
186 |             https://npmfs.com/package/<name>/<version>/index.js#<line>
187 |             https://npmfs.com/compare/<name>/<version-0>/<version-1>#<line>
188 |         
189 |
190 | Examples 191 | https://npmfs.com/package/bluebird/3.5.5/js/release/race.js#L32 192 |
193 | https://npmfs.com/compare/bluebird/3.5.4/3.5.5#D0L35 194 |
195 | https://npmfs.com/package/moment/2.24.0/locale/ca.js#L81 196 |
197 | https://npmfs.com/compare/moment/2.23.0/2.24.0#D19L0 198 |
199 | 200 |

More precise selections can be created by editing the URL's hash. These can link to line ranges and highlight specific substrings within a line.

201 |
202 | Examples 203 | https://npmfs.com/package/bluebird/3.5.5/js/release/race.js#L15-L21 204 |
205 | https://npmfs.com/compare/bluebird/3.5.4/3.5.5/#D0L24:12-L24:36 206 |
207 | https://npmfs.com/package/moment/2.24.0/locale/ca.js#L53-L66 208 |
209 | https://npmfs.com/compare/moment/2.23.0/2.24.0/#D15L0:1337-L0:1440 210 |
211 |
212 | 213 |

The sticky header sections contain useful links to navigate between pages.

214 | 215 |
216 | {{- end -}} 217 | -------------------------------------------------------------------------------- /templates/pages/versions.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | {{- define "title" -}} 13 | {{- .Package -}} 14 | {{- end -}} 15 | 16 | {{- define "description" -}} 17 | Inspect the published contents of any {{ .Package }} version. 18 | {{- end -}} 19 | 20 | {{- define "style" -}} 21 | 48 | {{- end -}} 49 | 50 | {{- define "body" -}} 51 |
52 |
53 | {{- if eq .Disabled "" -}} 54 | {{- .Package -}} 55 | {{- else -}} 56 | {{- .Package -}} 57 | @ 58 | {{- .Disabled -}} 59 | .. 60 | {{- end -}} 61 |
62 |
63 |
64 | {{- $latest := .Latest -}} 65 | {{- $disabled := .Disabled -}} 66 | {{- range $version := .Versions -}} 67 |
68 | 69 | 70 | {{- $version -}} 71 | {{- if eq $latest $version -}} 72 | latest 73 | {{- end -}} 74 | 75 |
76 | {{- end -}} 77 |
78 | {{- end -}} 79 | -------------------------------------------------------------------------------- /templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "html/template" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // Renderer is used to store state before a template is executed. 10 | type Renderer struct { 11 | statusCode int 12 | filenames []string 13 | context interface{} 14 | } 15 | 16 | // Handler executes the templates and handles errors. 17 | // Does not attempt to render the error page template to avoid possible infinite recursion. 18 | func (r *Renderer) Handler(w http.ResponseWriter, _ *http.Request) { 19 | if r.statusCode > 0 { 20 | w.WriteHeader(r.statusCode) 21 | } 22 | 23 | tmpl, err := template.ParseFiles(r.filenames...) 24 | if err != nil { 25 | http.Error(w, err.Error(), http.StatusInternalServerError) 26 | log.Printf("ERROR parse template: %v", err) 27 | return 28 | } 29 | 30 | err = tmpl.Execute(w, r.context) 31 | if err != nil { 32 | http.Error(w, err.Error(), http.StatusInternalServerError) 33 | log.Printf("ERROR execute template: %v", err) 34 | return 35 | } 36 | } 37 | --------------------------------------------------------------------------------