Compare commits

..

3 Commits

Author SHA1 Message Date
OLUWADAMILOLA OKUSANYA cb52ddab05 Adding command line options 2023-06-30 15:09:57 -04:00
OLUWADAMILOLA OKUSANYA e3e81130ba Adding tags command.
Refactoring main and app.go to cmd. Refactoring to adding logrus instead of log. Refactoring handlers to add a global logger.
2023-06-17 18:41:07 -04:00
OLUWADAMILOLA OKUSANYA 234b593c26 Publish, View and Tar commands added
Adding middlewares. Adding a filesystem service. Adding types to represent the put request and the index.json that is stored at the root of the package. Adding conf.go to pull config variables from json
2023-06-17 00:08:50 -04:00
23 changed files with 1229 additions and 64 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
**/userdata/*

28
app.go
View File

@ -1,28 +0,0 @@
package main
import (
"log"
"github.com/gorilla/mux"
"gosimplenpm/handler"
)
type application struct {
logger *log.Logger
}
func (app *application) Routes() *mux.Router {
m := mux.NewRouter()
// main handler
m.HandleFunc("/{name}", handler.Get).Methods("GET")
m.HandleFunc("/{name}", handler.Publish).Methods("PUT")
// tar handlers
m.HandleFunc("/{name}/-/{tar}", handler.Tar).Methods("GET")
// tag handlers
m.HandleFunc("/-/package/{name}/dist-tags/{tag}", handler.DistTagDelete).Methods("DELETE")
m.HandleFunc("/-/package/{name}/dist-tags/{tag}", handler.DistTagPut).Methods("PUT")
m.HandleFunc("/-/package/{name}/dist-tags", handler.DistTagGet).Methods("GET")
return m
}

26
cmd/gosimplenpm/conf.go Normal file
View File

@ -0,0 +1,26 @@
package gosimplenpm
import (
"fmt"
"gosimplenpm/config"
"os"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Aliases: []string{"conf"},
Short: "Display the config file",
Run: func(_ *cobra.Command, _ []string) {
err := config.PrintConfigFile()
if err != nil {
fmt.Printf("Error printing config: %+v\n", err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(configCmd)
}

83
cmd/gosimplenpm/root.go Normal file
View File

@ -0,0 +1,83 @@
package gosimplenpm
import (
"fmt"
"gosimplenpm/config"
"gosimplenpm/handler"
"os"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "gosimplenpm",
Short: "Gosimplenpm is an implementation of npm registry server",
Long: `Gosimplenpm is an implemenation of npm registry server.
It creates the repository to host all your private npm repos. It can
serve the npm packages offline.
Documentation about the npm private registry:
https://docs.npmjs.com/packages-and-modules
`,
Run: func(_ *cobra.Command, _ []string) {
err := config.VerifyConfig()
if err != nil {
fmt.Printf("Error verifying config: %+v\n", err)
os.Exit(1)
}
var cfg config.Config
err = config.LoadOrCreateConfig(&cfg)
if err != nil {
fmt.Printf("Error loading config: %+v\n", err)
os.Exit(1)
}
lvl, err := logrus.ParseLevel(cfg.LogLevel)
if err != nil {
fmt.Printf("%+v", err)
os.Exit(1)
}
log := &logrus.Logger{
Out: os.Stdout,
Level: lvl,
Formatter: &logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2009-01-02 15:15:15",
},
}
app := &handler.Application{
Conf: cfg,
Logger: log,
}
fmt.Println("\n Server is starting....")
err = app.Start()
fmt.Println("Why!")
if err != nil {
fmt.Printf("Server start up error: %+v\n", err)
os.Exit(1)
}
},
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.CompletionOptions.HiddenDefaultCmd = true
// hide help command
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
// hide help flag
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// TODO: get some variables from flags
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for server")
_ = rootCmd.MarkFlagRequired("listen")
rootCmd.Flags().BoolVarP(&config.CanLog, "verbose", "v", config.CanLog, "Logging level for server")
rootCmd.Flags().StringVar(&config.NpmRepoDir, "repodir", config.NpmRepoDir, "Repo dir to house published packages")
}

316
config/conf.go Normal file
View File

@ -0,0 +1,316 @@
package config
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path"
"golang.org/x/crypto/bcrypt"
)
var (
// File to store the app configuration, like username, password, token, repo dir, logging level
ConfigFilePath string
// Repo dir
NpmRepoDir string
// HTTPListen
HTTPListen string
// Set Verbose Logging
CanLog bool
// Logging Level
LoggingLvl string
// Username
RegUser string
// Password
RegPwd string
)
type Config struct {
Token string `json:"token"`
RepoDir string `json:"repoDir"`
IpAddress string `json:"ipAddress"`
LogLevel string `json:"logLevel"`
}
func checkIfCorrectIPPort(s string) bool {
host, port, _ := net.SplitHostPort(s)
if host == "" || port == "" {
return false
}
if net.ParseIP(host) != nil {
return true
}
_, err := net.ResolveIPAddr("ip", host)
return err == nil
}
func VerifyConfig() error {
if !checkIfCorrectIPPort(HTTPListen) {
return errors.New("ip address should be in the format of <ip>:<port>")
}
// Check if config file exists
dirname, err := os.UserHomeDir()
if err != nil {
return err
}
configDirPath, err := checkOrCreateConfigDir(dirname, true)
if err != nil {
return err
}
ConfigFilePath = path.Join(configDirPath, "config.json")
if NpmRepoDir == "" {
NpmRepoDir = path.Join(dirname, ".gosimplenpm", "registry")
err := checkOrCreateRepoDir(NpmRepoDir)
if err != nil {
return err
}
} else if NpmRepoDir != "" {
err := checkOrCreateRepoDir(NpmRepoDir)
if err != nil {
return err
}
}
if CanLog {
LoggingLvl = "DEBUG"
fmt.Println("\n Enabled debug logging")
} else {
LoggingLvl = "INFO"
}
return nil
}
func checkOrCreateConfigDir(fp string, canCreate bool) (string, error) {
configDirPath := path.Join(fp, ".gosimplenpm", "config")
ok := isDir(configDirPath)
if !ok && canCreate {
err := os.MkdirAll(configDirPath, os.ModePerm)
if err != nil {
return "", err
}
}
if !ok && !canCreate {
return "", nil
}
return configDirPath, nil
}
func checkOrCreateRepoDir(repoDirPath string) error {
ok := isDir(repoDirPath)
if !ok {
err := os.MkdirAll(repoDirPath, os.ModePerm)
if err != nil {
return err
}
}
return nil
}
func createConfig(cfg *Config, recreate bool) error {
var scanner *bufio.Scanner
if recreate {
fmt.Println("\nNew config variables. Saving...")
} else {
fmt.Println("\nConfig file is not found. Creating...")
}
configFile, err := os.Create(ConfigFilePath)
if err != nil {
return err
}
defer configFile.Close()
cfg.IpAddress = HTTPListen
cfg.LogLevel = LoggingLvl
// Get username
if cfg.Token == "" {
fmt.Println("Enter your username: ")
scanner = bufio.NewScanner(os.Stdin)
scanner.Scan()
err = scanner.Err()
if err != nil {
return err
}
RegUser = scanner.Text()
fmt.Println("Enter your password: ")
scanner = bufio.NewScanner(os.Stdin)
scanner.Scan()
err = scanner.Err()
if err != nil {
return err
}
RegPwd = scanner.Text()
token, err := generateAuthToken()
if err != nil {
return err
}
cfg.Token = token
}
fmt.Printf("The npm authToken is %s.\n", cfg.Token)
err = json.NewEncoder(configFile).Encode(cfg)
if err != nil {
return err
}
return nil
}
func loadConfig(cfg *Config) error {
configFile, err := os.Open(ConfigFilePath)
if err != nil {
return err
}
defer configFile.Close()
err = json.NewDecoder(configFile).Decode(cfg)
if err != nil {
return err
}
return nil
}
func LoadOrCreateConfig(cfg *Config) error {
var err error
ok := isFile(ConfigFilePath)
// If file is not found
if !ok {
err = createConfig(cfg, false)
if err != nil {
return err
}
}
if ok {
// File is found
err = loadConfig(cfg)
if err != nil {
return err
}
if cfg.Token == "" || cfg.IpAddress != HTTPListen || cfg.LogLevel != LoggingLvl || cfg.RepoDir != NpmRepoDir {
// recreate the config file
err = createConfig(cfg, true)
if err != nil {
return err
}
}
}
return err
}
func isFile(fp string) bool {
info, err := os.Stat(fp)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
func isDir(fp string) bool {
info, err := os.Stat(fp)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}
// Hash password
func hashPassword(password string) (string, error) {
// Convert password string to byte slice
var passwordBytes = []byte(password)
// Hash password with Bcrypt's min cost
hashedPasswordBytes, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.MinCost)
return string(hashedPasswordBytes), err
}
// Check if two passwords match using Bcrypt's CompareHashAndPassword
// which return nil on success and an error on failure.
// func doPasswordsMatch(hashedPassword, currPassword string) bool {
// err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(currPassword))
// return err == nil
// }
func generateAuthToken() (string, error) {
hashed, err := hashPassword(RegPwd)
if err != nil {
return "", err
}
token := fmt.Sprintf("%s::%s", RegUser, hashed)
return token, nil
}
func PrintConfigFile() error {
// Check if config file exists
dirname, err := os.UserHomeDir()
if err != nil {
return err
}
configDirPath, err := checkOrCreateConfigDir(dirname, false)
if err != nil {
return err
}
if configDirPath == "" {
return errors.New("config dir is not found")
}
ConfigFilePath = path.Join(configDirPath, "config.json")
ok := isFile(ConfigFilePath)
if !ok {
return errors.New("config file is not found")
}
configFile, err := os.Open(ConfigFilePath)
if err != nil {
return err
}
defer configFile.Close()
b, err := io.ReadAll(configFile)
if err != nil {
return err
}
var result map[string]interface{}
err = json.Unmarshal([]byte(b), &result)
if err != nil {
return err
}
// Pretty-print the result
marshaled, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Printf("Printing config located at %s: \n %s\n", ConfigFilePath, (marshaled))
return nil
}

14
go.mod
View File

@ -2,6 +2,16 @@ module gosimplenpm
go 1.20 go 1.20
require github.com/gorilla/mux v1.8.0 require (
github.com/gorilla/mux v1.8.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
golang.org/x/crypto v0.10.0
golang.org/x/mod v0.11.0
)
// replace gosimplenpm/handler => ./handler require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.9.0 // indirect
)

29
go.sum
View File

@ -1,2 +1,31 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

48
handler/app.go Normal file
View File

@ -0,0 +1,48 @@
package handler
import (
"gosimplenpm/config"
"gosimplenpm/middlewares"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
)
type Application struct {
Logger *logrus.Logger
Conf config.Config
Mux *mux.Router
}
func (app *Application) Routes() {
// Need to use UseEncodedPath as shown here https://github.com/gorilla/mux/blob/master/mux.go#L269
m := mux.NewRouter().StrictSlash(true).UseEncodedPath()
m.Use(middlewares.LogMiddleware(app.Logger))
// main handler
m.HandleFunc("/{name}", GetPackage(app.Logger, app.Conf)).Methods("GET")
m.HandleFunc("/{name}", middlewares.AuthMiddleware(app.Conf)(Publish(app.Logger, app.Conf))).Methods("PUT")
// tar handlers
m.HandleFunc("/{name}/-/{tar}", PackageTarGet(app.Logger, app.Conf)).Methods("GET")
// tag handlers
m.HandleFunc("/-/package/{name}/dist-tags/{tag}", middlewares.AuthMiddleware(app.Conf)(DistTagDelete(app.Logger, app.Conf))).Methods("DELETE")
m.HandleFunc("/-/package/{name}/dist-tags/{tag}", middlewares.AuthMiddleware(app.Conf)(DistTagPut(app.Logger, app.Conf))).Methods("PUT")
m.HandleFunc("/-/package/{name}/dist-tags", DistTagGet(app.Logger, app.Conf)).Methods("GET")
m.NotFoundHandler = http.HandlerFunc(NotFound)
app.Mux = m
}
func (app *Application) Start() error {
app.Routes()
server := &http.Server{
Addr: app.Conf.IpAddress,
Handler: app.Mux,
ReadTimeout: 4 * time.Second,
WriteTimeout: 4 * time.Second,
}
return server.ListenAndServe()
}

View File

@ -3,27 +3,36 @@ package handler
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"gosimplenpm/config"
"gosimplenpm/storage" "gosimplenpm/storage"
) )
func Get(w http.ResponseWriter, r *http.Request) { func GetPackage(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
packageName := mux.Vars(r)["name"] return func(w http.ResponseWriter, r *http.Request) {
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "get-package",
}).Debugf("Package name => %s\n", packageName)
fileToServe, err := storage.GetPackageFromStore(packageName) fileToServe, found, err := storage.GetIndexJsonFromStore(packageName, cfg.RepoDir, lg)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
}
if !found {
ret := fmt.Sprintf("Package not found: %s", packageName)
http.Error(w, ret, http.StatusNotFound)
return
}
// serve file
http.ServeFile(w, r, fileToServe)
} }
if fileToServe == "" {
ret := fmt.Sprintf("Package not found: %s", packageName)
http.Error(w, ret, http.StatusNotFound)
return
}
// serve file
http.ServeFile(w, r, fileToServe)
} }

10
handler/notfound.go Normal file
View File

@ -0,0 +1,10 @@
package handler
import (
"net/http"
)
// This handler is executed when the router cannot match any route
func NotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid url", http.StatusBadRequest)
}

View File

@ -1,9 +1,142 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"gosimplenpm/config"
"gosimplenpm/serviceidos"
"gosimplenpm/storage"
"net/http" "net/http"
"net/url"
"path"
"strings"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
) )
func Publish(w http.ResponseWriter, r *http.Request) { type NPMClientPutRequest struct {
Request serviceidos.IndexJson
}
func Publish(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// (1) Parse Json Body
// (2) Check if package exists in the folder.
// (a) if it does, ckeck if it is the same version. If it is, return error. Else modify index.json from (2)
// (b) If it does not, add the latest tag to the new index.json
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("Package name => %s\n", packageName)
var cr NPMClientPutRequest
// Parse json body
err := json.NewDecoder(r.Body).Decode(&cr.Request)
if err != nil {
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("Error unmarshaling put request: %+v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Extract relevant data from index.json
index := 0
var tag string
var version string
var versionData serviceidos.IndexJsonVersions
// TODO: Fix this as the order is not guaranteed
for key, value := range cr.Request.DistTags {
if index == 0 {
tag = key
version = value
break
}
index++
}
versionData = cr.Request.Versions[version]
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("For version(%s) with tag(%s), versionData => %+v\n", version, tag, versionData)
// Rewrite the tarball path
tarballFileName := strings.Split(versionData.Dist.Tarball, "/-/")[1]
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("TarballName => %s\n", tarballFileName)
// versionData.Dist.Tarball = fmt.Sprintf("file://%s", packageFilePath)
versionData.Dist.Tarball = fmt.Sprintf("http://%s/%s/-/%s", r.Host, url.PathEscape(packageName), url.PathEscape(tarballFileName))
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("versionData.Dist.Tarball => %s\n", versionData.Dist.Tarball)
tarBallFile := strings.Split(tarballFileName, "/")[1]
packageFilePath := path.Join(cfg.RepoDir, packageName, tarBallFile)
// Try to get the index.json from the store
fileToServe, found, err := storage.GetIndexJsonFromStore(packageName, cfg.RepoDir, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var jsonFile serviceidos.IndexJson
if !found {
// new package
jsonFile = cr.Request
jsonFile.DistTags["latest"] = version
} else {
// old package
err = storage.ReadIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Checking that you are not publishing over a pervious published version
if jsonFile.Versions[version].Version == version {
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugf("Version %s of package %s already exists!!\n", version, packageName)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Rewrite attachments
jsonFile.DistTags[tag] = version
nAttachments := make(map[string]serviceidos.IndexJsonAttachments)
nAttachments[fmt.Sprintf("%s-%s.tgz", packageName, version)] = cr.Request.Attachments[fmt.Sprintf("%s-%s.tgz", packageName, version)]
jsonFile.Attachments = nAttachments
// Merge in the new version data
jsonFile.Versions[version] = versionData
}
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugln("FiletoServe ==> ", fileToServe)
// Write index.json
err = storage.WriteIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
lg.WithFields(logrus.Fields{
"function": "publish",
}).Debugln("Package path => ", packageFilePath)
// Write bundled package
packageData := jsonFile.Attachments[fmt.Sprintf("%s-%s.tgz", packageName, version)].Data
err = storage.WritePackageToStore(packageFilePath, packageData, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
} }

View File

@ -1,9 +1,84 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"gosimplenpm/config"
"gosimplenpm/serviceidos"
"gosimplenpm/storage"
"net/http" "net/http"
"net/url"
"strconv"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"golang.org/x/mod/semver"
) )
func DistTagDelete(w http.ResponseWriter, r *http.Request) { func DistTagDelete(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "dist-tags-delete",
}).Debugf("Package name => %s\n", packageName)
escapedName = mux.Vars(r)["tag"]
tag, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "dist-tags-delete",
}).Debugf("Tag => %s\n", tag)
if semver.IsValid(tag) {
http.Error(w, "Tag cannot be a semver version", http.StatusBadRequest)
return
}
if tag == "latest" {
http.Error(w, "Cannot delete the latest tag", http.StatusBadRequest)
return
}
fileToServe, found, err := storage.GetIndexJsonFromStore(packageName, cfg.RepoDir, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !found {
ret := fmt.Sprintf("Package not found: %s", packageName)
http.Error(w, ret, http.StatusNotFound)
return
}
var jsonFile serviceidos.IndexJson
err = storage.ReadIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
delete(jsonFile.DistTags, tag)
// Write index.json
err = storage.WriteIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
distTagsString, _ := json.Marshal(jsonFile.DistTags)
response := serviceidos.TagPutResponse{
Ok: true,
ID: escapedName,
DistTags: string(distTagsString),
}
jsonString, _ := json.Marshal(response)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(jsonString)))
w.Write(jsonString)
}
} }

View File

@ -1,9 +1,50 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"gosimplenpm/config"
"gosimplenpm/serviceidos"
"gosimplenpm/storage"
"net/http" "net/http"
"net/url"
"strconv"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
) )
func DistTagGet(w http.ResponseWriter, r *http.Request) { func DistTagGet(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "dist-tags-get",
}).Debugf("Package name => %s\n", packageName)
fileToServe, found, err := storage.GetIndexJsonFromStore(packageName, cfg.RepoDir, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !found {
ret := fmt.Sprintf("Package not found: %s", packageName)
http.Error(w, ret, http.StatusNotFound)
return
}
var jsonFile serviceidos.IndexJson
err = storage.ReadIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jsonString, _ := json.Marshal(jsonFile.DistTags)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(jsonString)))
w.Write(jsonString)
}
} }

View File

@ -1,9 +1,92 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"gosimplenpm/config"
"gosimplenpm/serviceidos"
"gosimplenpm/storage"
"io"
"net/http" "net/http"
"net/url"
"strconv"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"golang.org/x/mod/semver"
) )
func DistTagPut(w http.ResponseWriter, r *http.Request) { func DistTagPut(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "dist-tags-put",
}).Debugf("Package name => %s\n", packageName)
escapedName = mux.Vars(r)["tag"]
tag, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "dist-tags-put",
}).Debugf("Tag => %s\n", tag)
if semver.IsValid(tag) {
http.Error(w, "Tag cannot be a semver version", http.StatusBadRequest)
return
}
if tag == "latest" {
http.Error(w, "Cannot delete the latest tag", http.StatusBadRequest)
return
}
body, _ := io.ReadAll(r.Body)
var version string
_ = json.Unmarshal(body, &version)
lg.WithFields(logrus.Fields{
"function": "dist-tags-put",
}).Debugf("Body => %s", version)
fileToServe, found, err := storage.GetIndexJsonFromStore(packageName, cfg.RepoDir, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !found {
ret := fmt.Sprintf("Package not found: %s", packageName)
http.Error(w, ret, http.StatusNotFound)
return
}
var jsonFile serviceidos.IndexJson
err = storage.ReadIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jsonFile.DistTags[tag] = version
// Write index.json
err = storage.WriteIndexJson(fileToServe, &jsonFile, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
distTagsString, _ := json.Marshal(jsonFile.DistTags)
response := serviceidos.TagPutResponse{
Ok: true,
ID: escapedName,
DistTags: string(distTagsString),
}
jsonString, _ := json.Marshal(response)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(jsonString)))
w.Write(jsonString)
}
} }

View File

@ -1,9 +1,50 @@
package handler package handler
import ( import (
"bytes"
"gosimplenpm/config"
"gosimplenpm/storage"
"io"
"net/http" "net/http"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
) )
func Tar(w http.ResponseWriter, r *http.Request) { func PackageTarGet(lg *logrus.Logger, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Sample output of npm view
// Public
// dist
// .tarball: https://registry.npmjs.org/react/-/react-18.2.0.tgz
// LocalHost
// dist
// .tarball: http://localhost:4000/@ookusanya/package1/-/package1-0.2.0.tgz
escapedName := mux.Vars(r)["name"]
packageName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "get-tar",
}).Debugf("Package name => %s\n", packageName)
escapedName = mux.Vars(r)["tar"]
tarFileName, _ := url.PathUnescape(escapedName)
lg.WithFields(logrus.Fields{
"function": "get-tar",
}).Debugf("Tarfile name => %s\n", tarFileName)
versionName := strings.Split(strings.Split(tarFileName, "-")[1], ".tgz")[0]
fileAsString, err := storage.GetTarFromStore(packageName, versionName, cfg.RepoDir, lg)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Sending the tar as a base64 string
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.Itoa(len([]byte(fileAsString))))
io.Copy(w, bytes.NewReader([]byte(fileAsString)))
}
} }

34
main.go
View File

@ -1,13 +1,35 @@
package main package main
import ( import (
"log" "gosimplenpm/cmd/gosimplenpm"
"net/http"
) )
func main() { func main() {
app := new(application) gosimplenpm.Execute()
log.Print("Starting server on port 4000")
err := http.ListenAndServe(":4000", app.Routes())
log.Fatal(err)
} }
// func main() {
// log := &logrus.Logger{
// Out: os.Stdout,
// Level: logrus.DebugLevel,
// Formatter: &logrus.TextFormatter{
// FullTimestamp: true,
// TimestampFormat: "2009-01-02 15:15:15",
// },
// }
// var cfg config.Config
// err := config.LoadConfiguration("userdata/config.json", &cfg)
// if err != nil {
// log.Fatalf("Config is not loaded: %+v\n", err)
// }
// app := &gosimplenpm.Application{
// Conf: cfg,
// Logger: log,
// }
// log.Infoln("Starting server on port 4000")
// err = http.ListenAndServe(":4000", app.Routes())
// log.Fatal(err)
// }

28
middlewares/auth.go Normal file
View File

@ -0,0 +1,28 @@
package middlewares
import (
"gosimplenpm/config"
"net/http"
"strings"
)
func AuthMiddleware(cfg config.Config) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// get headers
authHeader := r.Header.Get("Authorization")
authFields := strings.Fields(authHeader)
if len(authFields) != 2 || strings.ToLower(authFields[0]) != "bearer" {
http.Error(w, "Authentication Error", http.StatusForbidden)
return
}
token := authFields[1]
if token != cfg.Token {
http.Error(w, "Authentication Error", http.StatusForbidden)
return
}
next(w, r)
}
}
}

29
middlewares/logRequest.go Normal file
View File

@ -0,0 +1,29 @@
package middlewares
import (
"net/http"
"net/http/httputil"
"github.com/sirupsen/logrus"
)
func LogMiddleware(lg *logrus.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lg.Debugf("%s - %s - %s", r.Method, r.URL, r.Host)
hasBody := false
if r.Method == "PUT" {
hasBody = true
}
requestDump, err := httputil.DumpRequest(r, hasBody)
if err != nil {
lg.Debugln(err)
}
lg.Debugln("RequestDump: ", string(requestDump))
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,54 @@
package serviceidos
type IndexJsonAttachments struct {
ContentType string `json:"content_type"`
Data string `json:"data"`
Length int `json:"length"`
}
type IndexJsonDist struct {
Integrity string `json:"integrity"`
Shasum string `json:"shasum"`
Tarball string `json:"tarball"`
}
type IndexJsonVersions struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Main string `json:"main,omitempty"`
Scripts map[string]string `json:"scripts,omitempty"`
Author string `json:"author,omitempty"`
License string `json:"license"`
Files []string `json:"files"`
Readme string `json:"readme,omitempty"`
ID string `json:"_id"`
NodeVersion string `json:"_nodeVersion"`
NpmVersion string `json:"_npmVersion"`
Dist IndexJsonDist `json:"dist"`
Dependencies map[string]string `json:"dependencies,omitempty"`
DevDependencies map[string]string `json:"devDependencies,omitempty"`
Resolutions map[string]string `json:"resolutions,omitempty"`
}
type IndexJson struct {
ID string `json:"_id"`
Name string `json:"name"`
Description string `json:"description"`
DistTags map[string]string `json:"dist-tags"`
Versions map[string]IndexJsonVersions `json:"versions"`
Access string `json:"access"`
Attachments map[string]IndexJsonAttachments `json:"_attachments"`
}
type TagPutResponse struct {
Ok bool `json:"ok"`
ID string `json:"id"`
DistTags string `json:"dist-tags"`
}
type TagDeleteResponse struct {
Ok bool `json:"ok"`
ID string `json:"id"`
DistTags string `json:"dist-tags"`
}

View File

@ -1,33 +1,187 @@
package storage package storage
import ( import (
"archive/tar"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"os"
"path" "path"
"strings" "strings"
"gosimplenpm/serviceidos"
"path/filepath" "path/filepath"
"github.com/sirupsen/logrus"
) )
func GetPackageFromStore(packageName string) (string, error) { func GetIndexJsonFromStore(packageName string, registryPath string, log *logrus.Logger) (string, bool, error) {
fileToServe := "" fileToServe := ""
searchDir, err := filepath.Abs("./examples") found := false
err := filepath.WalkDir(registryPath, func(fp string, info fs.DirEntry, e error) error {
if strings.Contains(fp, path.Join(packageName, "index.json")) {
fileToServe = fp
found = true
}
return e
})
if err != nil { if err != nil {
fmt.Printf("File repo not found: +%v/n", err) log.WithFields(logrus.Fields{
return "", err "function": "get-index-json-from-store",
}).Debugf("List files error: +%v\n", err)
return fileToServe, found, err
} }
err = filepath.WalkDir(searchDir, func(fp string, info fs.DirEntry, e error) error { if fileToServe == "" && !found {
if strings.Contains(fp, path.Join(packageName, "index.json")) { fileToServe = path.Join(registryPath, packageName, "index.json")
}
return fileToServe, found, nil
}
func GetTarFromStore(packageName string, tarFileName string, registryPath string, log *logrus.Logger) (string, error) {
fileToServe := ""
err := filepath.WalkDir(registryPath, func(fp string, info fs.DirEntry, e error) error {
if strings.Contains(fp, path.Join(packageName, tarFileName)) {
fileToServe = fp fileToServe = fp
} }
return e return e
}) })
if err != nil { if err != nil {
fmt.Printf("List files error: +%v/n", err) log.WithFields(logrus.Fields{
"function": "get-tar-from-store",
}).Debugf("List files error: +%v\n", err)
return fileToServe, err
}
if fileToServe == "" {
return fileToServe, fmt.Errorf("file %s is not found for package %s", tarFileName, packageName)
}
file, err := os.Open(fileToServe)
if err != nil {
log.WithFields(logrus.Fields{
"function": "get-tar-from-store",
}).Debugf("Open error: %s\n", fileToServe)
return "", err return "", err
} }
return fileToServe, nil archive, err := gzip.NewReader(file)
if err != nil {
log.WithFields(logrus.Fields{
"function": "get-tar-from-store",
}).Debugf("Archive Open error: %s\n", fileToServe)
return "", err
}
tr := tar.NewReader(archive)
bs, err := io.ReadAll(tr)
if err != nil {
log.WithFields(logrus.Fields{
"function": "get-tar-from-store",
}).Debugf("Archive Read error: %s\n", fileToServe)
return "", err
}
return base64.StdEncoding.EncodeToString(bs), err
}
func ReadIndexJson(fPath string, res *serviceidos.IndexJson, log *logrus.Logger) error {
jsonFile, err := os.Open(fPath)
if err != nil {
log.WithFields(logrus.Fields{
"function": "read-index-json",
}).Debugf("File Not found: %s\n", fPath)
return err
}
defer jsonFile.Close()
err = json.NewDecoder(jsonFile).Decode(res)
if err != nil {
log.WithFields(logrus.Fields{
"function": "read-index-json",
}).Debugf("Unmarshalerror: %+v\n", err)
return err
}
return nil
}
func WriteIndexJson(fPath string, res *serviceidos.IndexJson, log *logrus.Logger) error {
// Need to create the directory first
parent := path.Dir(fPath)
err := os.MkdirAll(parent, os.ModePerm)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-index-json",
}).Debugf("Folder (%s) creation failed.\n", fPath)
return err
}
// Create the file
jsonFile, err := os.Create(fPath)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-index-json",
}).Debugf("Creation error for path(%s): %+v\n ", fPath, err)
return err
}
defer jsonFile.Close()
err = json.NewEncoder(jsonFile).Encode(res)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-index-json",
}).Debugf("Marshalerror: %+v\n", err)
return err
}
return nil
}
func WritePackageToStore(fPath string, data string, log *logrus.Logger) error {
dec, err := base64.StdEncoding.DecodeString(data)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-package-to-store",
}).Debugf("Base64 Decode error: %+v\n", err)
return err
}
dataFile, err := os.Create(fPath)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-package-to-store",
}).Debugf("Creation error: %s\n", fPath)
return err
}
defer dataFile.Close()
_, err = dataFile.Write(dec)
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-package-to-store",
}).Debugf("Write error: %s\n", fPath)
return err
}
err = dataFile.Sync()
if err != nil {
log.WithFields(logrus.Fields{
"function": "write-package-to-store",
}).Debugf("Sync error: %s\n", fPath)
return err
}
return nil
} }

View File

@ -0,0 +1 @@
{"_id":"@ookusanya/simplepackone","name":"@ookusanya/simplepackone","description":"This is a very rough implementation of a private npm registry.","dist-tags":{"latest":"1.2.0"},"versions":{"1.0.0":{"name":"@ookusanya/simplepackone","version":"1.0.0","description":"This is a very rough implementation of a private npm registry.","main":"index.js","scripts":{"test":"echo \"Error: no test specified\" \u0026\u0026 exit 1"},"license":"MIT","files":null,"readme":"# Gosimplenpm\n\nThis is a very rough implementation of a private npm registry.\n\n# Features\n\nTODO...\n\n# Enhancements\n\nTODO...\n\n# Work List\n\nTODO...","_id":"@ookusanya/simplepackone@1.0.0","_nodeVersion":"16.20.0","_npmVersion":"8.19.4","dist":{"integrity":"sha512-x9cAEHDPM0dLhdyd8baWLTNYskr41QXLNv5HS5WNDg1CVzwcMaAU+N9lYVuOvLf/xPHLielWmX+wLXEtRWQvGw==","shasum":"545a7737b59b1a6f064339a4ebe056e8a9b1511d","tarball":"http://localhost:4000/@ookusanya/simplepackone/-/@ookusanya/simplepackone-1.0.0.tgz"},"dependencies":{"eslint":"^7.x"}},"1.2.0":{"name":"@ookusanya/simplepackone","version":"1.2.0","description":"This is a very rough implementation of a private npm registry.","main":"index.js","scripts":{"test":"echo \"Error: no test specified\" \u0026\u0026 exit 1"},"license":"MIT","files":null,"readme":"# Gosimplenpm\n\nThis is a very rough implementation of a private npm registry.\n\n# Features\n\nTODO...\n\n# Enhancements\n\nTODO...\n\n# Work List\n\nTODO...","_id":"@ookusanya/simplepackone@1.2.0","_nodeVersion":"16.20.0","_npmVersion":"8.19.4","dist":{"integrity":"sha512-coOiU+ywV/do/+HwK91mfuei9491yfQUedLWjspAjDa58RniJvQNTF/cXDp/sooVdpjgEbCqKVyLOju6C1i3pw==","shasum":"1adafdbe8878372c9f4b554bf30d5bfb1b7896c5","tarball":"http://localhost:4000/@ookusanya%2Fsimplepackone/-/@ookusanya%2Fsimplepackone-1.2.0.tgz"},"dependencies":{"eslint":"^7.x","mocha":"^10.x","uuid":"^9.x"}}},"access":"","_attachments":{"@ookusanya/simplepackone-1.2.0.tgz":{"content_type":"application/octet-stream","data":"H4sIAAAAAAAC/+1XbWvjOBDuZ/+KwQtLCyZ10pfl+uncRGnMOnawne0VlgXVVmJtHctIcl849r/fSEmb9nof73LcXoaA0Lw9M6PMSG5pcUeX7Jg3JXvsfVcH/wD5vn9+egp/xbc0OIGDk7Nzv98/P/dRz+/7nwY+rgc7oE5pKjGUvyFJJHhZ/yNUiEaJmvVqsTx0J0wy9+hgT/8fajf9v1lxAohmx/1/ev6u//tnZ/19/++CfncA3IaumHsB7q9C3HWKNk/0WPFVWzPzrxANcz2jdc+k4qIxiv3eoOevuSVTheSt3kjWzBXldvd8q6y5a0WFAoOKDM2UNmqsqAR8dYmUQl5AI8AIQLWs4AvOyq8ufPwI7JFr6Lto+cN6o52uhNxi1rxgjbJ5TMP8ObiWYQhNwdkrWKZq3ljgb596j1bTxCyKilpm399yu46X7oX77RdkGWjnh/NT9n9KgtGU9Fbl7u//waD/7v4/Oxns+38X9AGuxLrXm3blOHnFFeCPAnb7E0jRLSuw4hVrNDVdDmKB4lbye6oZoBVItuRKy6ee43yAMaO6k0yhr2SU9HqWSZqKNoX18VZwLeQdRGj9wt1fyf9K/0fhkMQZ6elHveP+H/hng3f3f9/f9/9OyBmK9knyZaXhsDiCgfkYS+rugZZ0xWtRU0g2TwLHmTG54sq8AcyIqPBj4fYJlpI2mpUeLCRjZjbgLSqXzAMtAM2gxVeDGRq3Gt8EvFk6FAqENJraDBslFvqBSobKJVClRMFxrpRQiqLbzpwFr5mCQ10xx802Fu6RBSkZrYE36I3BswgeOD4OOo2jCQcTL4wPD5WKuitNDM/imq/4BsGY2zoo47RTmIGJ04OVKPnCrMym1Xa3NVeV55Rm5vHbTiNTGaZ9fngmj2MhQbG6Nh7w5bHOdRud1UEUpzUF1ZsSWdyHSqzeZoIlWnSyQUhW2nQFlswifmeFNl6M+kLUtXjA1BCyKbnJSF2YcY51vRX3zOayPuZGaAx1HYI5gHZ7qhuRqijGfsucdcEQF8tLX6UjDTz2TaM51r4V0uL9OU0c8PmEQJaM8+sgJRBmMEuTL+GIjMANMty7HlyH+SSZ54AaaRDnN5CMIYhv4HMYjzyH/DZLSZZBkkI4nUUhGXkQxsNoPgrjK7hEuzjJIQrxwYdO8wQM4MZVSNBu7ExJOpzgNrgMozC/8WAc5rHxOUanAcyCNA+H8yhIYTZPZ0lGEH7kxEkcxuMUUciUxHkPUREKyBfcQDYJoshCBXOMPrXxDZPZTRpeTXKYJNGIIPOSOFEYXEZkDYVJDaMgnHowCqbBFbFWCXpJwahtorueEMtCvAB/wzxMYgdrMkziPMWth1mm+YvpdZgRD4I0zExBxmmC7k050SKxTtAuJmsvptTw5kRQxeznGdnGMiJBhL4yY/xaeX8x72lPe9rTT0V/APlm4rcAGAAA","length":1089}}}