Browse Source

chore: initial commit

e22m4u 1 month ago
commit
f52493fa75
7 changed files with 400 additions and 0 deletions
  1. 3 0
      .gitignore
  2. 46 0
      .goreleaser.yml
  3. 21 0
      LICENSE
  4. 62 0
      README.md
  5. 3 0
      go.mod
  6. 235 0
      main.go
  7. 30 0
      start.sh

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+nptm
+dist
+bin

+ 46 - 0
.goreleaser.yml

@@ -0,0 +1,46 @@
+version: 2
+
+project_name: nptm
+
+builds:
+  - id: "nptm-build"
+    main: .
+    binary: nptm
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - windows
+      - darwin
+    goarch:
+      - amd64
+      - arm64
+    ignore:
+      - goos: windows
+        goarch: arm64
+
+archives:
+  - formats:
+      - tar.gz
+    format_overrides:
+      - goos: windows
+        formats:
+          - zip
+    name_template: >-
+      {{ .ProjectName }}-
+      {{- .Version }}-
+      {{- .Os }}-
+      {{- .Arch }}
+    files:
+      - README.md
+      - LICENSE
+
+checksum:
+  name_template: 'checksums.txt'
+
+release:
+  github:
+    owner: e22m4u
+    name: npm-proxy-time-machine
+  draft: false
+  prerelease: auto

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024-2025 Mikhail Evstropov <e22m4u@yandex.ru>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 62 - 0
README.md

@@ -0,0 +1,62 @@
+## NPM Proxy Time Machine
+
+A proxy for the NPM registry that hides package versions released after
+a specified date.
+
+## Usage
+
+Start the proxy with a specified date.
+
+```bash
+./nptm --port=9000 --date=2021-12-05
+```
+
+#### Node.js configuration
+
+Switch to a Node.js version for that date.
+
+```bash
+nvm install 12
+nvm use 12
+```
+
+Or if you are using a project-wide `.nvmrc`, just run `nvm use`.
+
+#### Option 1: Global NPM configuration
+
+Check your current registry URL and save it (to restore later).
+
+```bash
+npm config get registry
+```
+
+Set the proxy as your NPM registry.
+
+```bash
+npm config set registry http://localhost:9000/
+```
+
+#### Option 2: Local NPM configuration (project-wide)
+
+Create an `.npmrc` file in your project with the proxy URL.
+
+```ini
+registry=http://localhost:9000/
+```
+
+#### That's it
+
+Now you can run `npm install` for your project. Dependencies will be resolved
+as of the specified date.
+
+## Build
+
+You need Go installed to build this project.
+
+```bash
+CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o nptm .
+```
+
+## License
+
+MIT

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module npm-proxy-time-machine
+
+go 1.25.5

+ 235 - 0
main.go

@@ -0,0 +1,235 @@
+package main
+
+import (
+	"bytes"
+	"compress/gzip"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// PackageMeta - alias для удобства
+type PackageMeta map[string]interface{}
+
+var (
+	targetURLStr = "https://registry.npmjs.org"
+	cutoffDate   time.Time
+)
+
+func main() {
+	dateFlag := flag.String("date", "", "Cutoff date in YYYY-MM-DD format (e.g., 2023-12-01)")
+	portFlag := flag.String("port", "8080", "Proxy server port")
+	flag.Parse()
+
+	if *dateFlag == "" {
+		fmt.Println("Usage: nptm --date=2023-12-01")
+		os.Exit(1)
+	}
+
+	var err error
+	cutoffDate, err = time.Parse("2006-01-02", *dateFlag)
+	if err != nil {
+		log.Fatalf("Invalid date format: %v", err)
+	}
+	cutoffDate = cutoffDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
+
+	log.Printf("Starting npm proxy on :%s", *portFlag)
+	log.Printf("Time Machine Active! Packages after %s will be hidden.", cutoffDate.Format(time.RFC3339))
+	
+	targetURL, _ := url.Parse(targetURLStr)
+	proxy := httputil.NewSingleHostReverseProxy(targetURL)
+
+	originalDirector := proxy.Director
+	proxy.Director = func(req *http.Request) {
+		originalDirector(req)
+		req.Host = targetURL.Host
+		req.Header.Set("Accept-Encoding", "identity")
+		req.Header.Set("Accept", "application/json") 
+	}
+
+	proxy.ModifyResponse = func(resp *http.Response) error {
+		if resp.StatusCode != http.StatusOK {
+			return nil
+		}
+
+		contentType := resp.Header.Get("Content-Type")
+		if !strings.Contains(contentType, "json") {
+			return nil
+		}
+
+		var bodyReader io.ReadCloser = resp.Body
+		var isGzipped bool
+
+		if resp.Header.Get("Content-Encoding") == "gzip" {
+			gzReader, err := gzip.NewReader(resp.Body)
+			if err != nil {
+				// Если не смогли распаковать, отдаем как есть
+				// (скорее всего будет ошибка у клиента, но мы сделали что могли)
+				resp.Body.Close()
+				return nil 
+			}
+			bodyReader = gzReader
+			isGzipped = true
+		}
+
+		bodyBytes, err := io.ReadAll(bodyReader)
+		bodyReader.Close()
+		if err != nil {
+			return err
+		}
+
+		// Вспомогательная функция для возврата тела
+		// обратно в ответ с коррекцией заголовков
+		setBody := func(data []byte) {
+			resp.Body = io.NopCloser(bytes.NewReader(data))
+			resp.ContentLength = int64(len(data))
+			resp.Header.Set("Content-Length", strconv.Itoa(len(data)))
+			if isGzipped {
+				resp.Header.Del("Content-Encoding")
+			}
+			// Удаляем ETag, так как мы (возможно) трогали тело
+			// (даже просто распаковка меняет байты)
+			resp.Header.Del("ETag")
+		}
+
+		var meta PackageMeta
+		if err := json.Unmarshal(bodyBytes, &meta); err != nil {
+			// Если это не JSON, но мы его распаковали из Gzip, 
+			// нужно вернуть распакованные данные с правильным Content-Length
+			setBody(bodyBytes)
+			return nil
+		}
+
+		times, okT := meta["time"].(map[string]interface{})
+		versions, okV := meta["versions"].(map[string]interface{})
+
+		if !okT || !okV {
+			// Аналогично, возвращаем распакованное тело с правильной длиной
+			setBody(bodyBytes)
+			return nil
+		}
+
+		modifiedMeta, err := filterPackageVersions(meta, times, versions)
+		if err != nil {
+			log.Printf("Filter error: %v", err)
+			setBody(bodyBytes)
+			return nil
+		}
+
+		finalVersions, _ := modifiedMeta["versions"].(map[string]interface{})
+		if len(finalVersions) == 0 {
+			resp.StatusCode = http.StatusNotFound
+			notFoundBody := []byte(fmt.Sprintf(`{"error": "Not found. All versions published after %s"}`, cutoffDate.Format("2006-01-02")))
+			
+			// Сбрасываем флаг gzip, так как это наше новое тело
+			isGzipped = false 
+			resp.Header.Del("Content-Encoding")
+			
+			resp.Body = io.NopCloser(bytes.NewReader(notFoundBody))
+			resp.ContentLength = int64(len(notFoundBody))
+			resp.Header.Set("Content-Length", strconv.Itoa(len(notFoundBody)))
+			return nil
+		}
+
+		newBody, err := json.Marshal(modifiedMeta)
+		if err != nil {
+			return err
+		}
+
+		// Устанавливаем итоговое отфильтрованное тело
+		setBody(newBody)
+		resp.Header.Set("X-NPM-Time-Machine", "true")
+
+		return nil
+	}
+
+	proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+		// Игнорируем ошибки "context canceled" (клиент отключился)
+		if err != nil && !strings.Contains(err.Error(), "context canceled") {
+			log.Printf("Proxy error for %s: %v", r.URL.Path, err)
+		}
+		w.WriteHeader(http.StatusBadGateway)
+	}
+
+	log.Fatal(http.ListenAndServe(":"+*portFlag, proxy))
+}
+
+func filterPackageVersions(meta PackageMeta, times map[string]interface{}, versions map[string]interface{}) (PackageMeta, error) {
+	toDelete := []string{}
+	var newLatestVer string
+	var newLatestTime time.Time
+
+	for ver, timeVal := range times {
+		if ver == "created" || ver == "modified" {
+			continue
+		}
+
+		timeStr, ok := timeVal.(string)
+		if !ok {
+			continue
+		}
+
+		pubDate, err := time.Parse(time.RFC3339, timeStr)
+		if err != nil {
+			continue
+		}
+
+		if pubDate.After(cutoffDate) {
+			toDelete = append(toDelete, ver)
+		} else {
+			// Ищем самую свежую по дате среди оставшихся
+			if pubDate.After(newLatestTime) {
+				newLatestTime = pubDate
+				newLatestVer = ver
+			}
+		}
+	}
+
+	for _, ver := range toDelete {
+		delete(versions, ver)
+		delete(times, ver)
+	}
+
+	if distTags, ok := meta["dist-tags"].(map[string]interface{}); ok {
+		// Если нашли кандидата на latest
+		if newLatestVer != "" {
+			currentLatest, _ := distTags["latest"].(string)
+			// Если текущий latest удален или (важно!) если по времени он в будущем
+			// (но не удален по какой-то причине), заменяем его. Самая простая проверка
+			// есть ли currentLatest в versions.
+			if _, exists := versions[currentLatest]; !exists {
+				distTags["latest"] = newLatestVer
+			} else {
+				// Дополнительная проверка: иногда latest не самый последний по времени,
+				// но в рамках TimeMachine мы, скорее всего, хотим видеть последний
+				// доступный на ту дату. Однако, безопаснее менять latest только если
+				// старый удален. 
+			}
+		}
+
+		for tag, verInterface := range distTags {
+			verStr, ok := verInterface.(string)
+			if !ok {
+				continue
+			}
+			if _, exists := versions[verStr]; !exists {
+				delete(distTags, tag)
+			}
+		}
+	}
+
+	if !newLatestTime.IsZero() {
+		times["modified"] = newLatestTime.Format(time.RFC3339)
+	}
+
+	return meta, nil
+}

+ 30 - 0
start.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# Example:
+# ./start.sh --date=2021-12-05
+
+PORT="9000"
+PROXY_URL="http://localhost:$PORT/"
+BIN_FILE="./nptm"
+
+CURRENT_REGISTRY=$(npm config get registry)
+echo "Current registry: $CURRENT_REGISTRY"
+
+cleanup() {
+    echo ""
+    echo "----------------------------------------"
+    echo "Shutdown..."
+    echo "Restore npm configuration..."
+    npm config set registry "$CURRENT_REGISTRY"
+    echo "Registry restored: $(npm config get registry)"
+}
+
+trap cleanup EXIT
+
+echo "Switching npm to port $PORT ($PROXY_URL)"
+npm config set registry "$PROXY_URL"
+
+echo "Starting Time Machine on port $PORT..."
+echo "----------------------------------------"
+
+"$BIN_FILE" --port="$PORT" "$@"