├── .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 |
246 |
247 | {{- template "body" . -}}
248 |
249 |
257 |
258 |
444 |
445 |
446 |
--------------------------------------------------------------------------------
/templates/logo.html:
--------------------------------------------------------------------------------
1 | {{- define "logo" -}}
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
140 | {{- else if and (ne $line.LineA 0) (eq $line.LineB 0) -}}
141 |
142 |
149 | {{- else if and (eq $line.LineA 0) (ne $line.LineB 0) -}}
150 |
151 |
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 |
97 |
98 | {{- if ne (len .Directories) 0 -}}
99 |
100 | {{- range $i, $name := .Directories -}}
101 |
107 | {{- end -}}
108 |
109 | {{- end -}}
110 | {{- if ne (len .Files) 0 -}}
111 |
112 | {{- range $i, $name := .Files -}}
113 |
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 |
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 |
60 |
61 |
62 | {{- range $i, $line := .Lines -}}
63 | {{- if ne $i 0 -}}
64 |
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 |
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 |
63 |
64 | {{- $latest := .Latest -}}
65 | {{- $disabled := .Disabled -}}
66 | {{- range $version := .Versions -}}
67 |
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 |
--------------------------------------------------------------------------------