diff --git a/cmd/internal/migrations/lists.go b/cmd/internal/migrations/lists.go index 864a77e..ae536fd 100644 --- a/cmd/internal/migrations/lists.go +++ b/cmd/internal/migrations/lists.go @@ -70,6 +70,8 @@ var Migrations = []Migration{ v3migrations.MigrateSessionExtractor, v3migrations.MigrateSessionStore, v3migrations.MigrateKeyAuthConfig, + v3migrations.MigrateJWTExtractor, + v3migrations.MigratePasetoExtractor, v3migrations.MigrateTimeoutConfig, v3migrations.MigrateBasicauthAuthorizer, v3migrations.MigrateBasicauthConfig, diff --git a/cmd/internal/migrations/v3/common.go b/cmd/internal/migrations/v3/common.go index 7a99f81..3fb3deb 100644 --- a/cmd/internal/migrations/v3/common.go +++ b/cmd/internal/migrations/v3/common.go @@ -3,6 +3,7 @@ package v3 import ( "fmt" "regexp" + "sort" "strconv" "strings" ) @@ -152,7 +153,19 @@ func removeConfigField(src, field string) string { // fn with the parsed components. If fn returns an empty string, the field is // removed entirely. func replaceKeyLookup(src string, fn func(indent, val, comma, comment, newline string) string) string { - re := regexp.MustCompile(`(?m)(\s*)KeyLookup:\s*([^\n]+)(\n?)`) + return replaceStringField(src, "KeyLookup", fn) +} + +func replaceStringField(src, field string, fn func(indent, val, comma, comment, newline string) string) string { + return replaceFieldImpl(src, field, true, fn) +} + +func replaceField(src, field string, fn func(indent, val, comma, comment, newline string) string) string { + return replaceFieldImpl(src, field, false, fn) +} + +func replaceFieldImpl(src, field string, unquote bool, fn func(indent, val, comma, comment, newline string) string) string { + re := regexp.MustCompile(`(?m)^(\s*)` + regexp.QuoteMeta(field) + `:\s*([^\n]+)(\n?)`) return re.ReplaceAllStringFunc(src, func(s string) string { sub := re.FindStringSubmatch(s) indent := sub[1] @@ -174,25 +187,49 @@ func replaceKeyLookup(src string, fn func(indent, val, comma, comment, newline s val = strings.TrimSpace(strings.TrimSuffix(val, ",")) } - if uq, err := strconv.Unquote(val); err == nil { - val = uq - repl := fn(indent, val, comma, comment, newline) - if repl == "" { + if unquote { + uq, err := strconv.Unquote(val) + if err != nil { if comment != "" { - return fmt.Sprintf("%s%s%s", indent, comment, newline) + return fmt.Sprintf("%s// TODO: migrate %s: %s %s%s", indent, field, val, comment, newline) } - return newline + return fmt.Sprintf("%s// TODO: migrate %s: %s%s", indent, field, val, newline) } - return repl + val = uq } - if comment != "" { - return fmt.Sprintf("%s// TODO: migrate KeyLookup: %s %s%s", indent, val, comment, newline) + repl := fn(indent, val, comma, comment, newline) + if repl == "" { + if comment != "" { + return fmt.Sprintf("%s%s%s", indent, comment, newline) + } + return newline } - return fmt.Sprintf("%s// TODO: migrate KeyLookup: %s%s", indent, val, newline) + return repl }) } +func collectAliases(content string, reImport *regexp.Regexp, defaults []string) []string { + aliases := map[string]struct{}{} + for _, m := range reImport.FindAllStringSubmatch(content, -1) { + alias := strings.TrimSpace(m[1]) + if alias == "" { + for _, d := range defaults { + aliases[d] = struct{}{} + } + continue + } + aliases[alias] = struct{}{} + } + + result := make([]string, 0, len(aliases)) + for alias := range aliases { + result = append(result, alias) + } + sort.Strings(result) + return result +} + // splitArgs splits a comma-separated argument list into its individual arguments // while respecting nested parentheses, brackets, and braces as well as quoted // strings. It returns the trimmed arguments without altering inner spacing. diff --git a/cmd/internal/migrations/v3/jwt_extractor.go b/cmd/internal/migrations/v3/jwt_extractor.go new file mode 100644 index 0000000..ddea3e4 --- /dev/null +++ b/cmd/internal/migrations/v3/jwt_extractor.go @@ -0,0 +1,112 @@ +package v3 + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/gofiber/cli/cmd/internal" +) + +func MigrateJWTExtractor(cmd *cobra.Command, cwd string, _, _ *semver.Version) error { + reImport := regexp.MustCompile(`(?m)^\s*(?:import\s+)?(?:([\w\.]+)\s+)?"github\.com/gofiber/contrib/jwt(?:/v\d+)?"`) + reAuthScheme := regexp.MustCompile(`(?m)^\s*AuthScheme:\s*([^,\n]+)`) + reAuthLine := regexp.MustCompile(`(?m)^\s*AuthScheme:\s*[^\n]+\n?`) + reFilter := regexp.MustCompile(`(?m)^(\s*)Filter:\s*`) + + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + aliases := collectAliases(content, reImport, []string{"jwtware", "jwt"}) + if len(aliases) == 0 { + return content + } + + updated := content + for _, alias := range aliases { + reConfig := regexp.MustCompile(regexp.QuoteMeta(alias) + `\.Config{(?:[^{}]|{[^{}]*})*}`) + updated = reConfig.ReplaceAllStringFunc(updated, func(cfg string) string { + schemeArg := "\"Bearer\"" + if am := reAuthScheme.FindStringSubmatch(cfg); len(am) > 1 { + raw := strings.TrimSpace(am[1]) + if uq, err := strconv.Unquote(raw); err == nil { + schemeArg = fmt.Sprintf("%q", uq) + } else { + schemeArg = raw + } + } + + cfg = replaceStringField(cfg, "TokenLookup", func(indent, val, comma, comment, newline string) string { + parts := strings.Split(val, ",") + var extractors []string + for _, p := range parts { + p = strings.TrimSpace(p) + switch { + case strings.HasPrefix(p, "header:"): + header := strings.TrimPrefix(p, "header:") + if strings.EqualFold(header, "Authorization") { + extractors = append(extractors, fmt.Sprintf("extractors.FromAuthHeader(%s)", schemeArg)) + } else { + extractors = append(extractors, fmt.Sprintf("extractors.FromHeader(%q)", header)) + } + case strings.HasPrefix(p, "query:"): + extractors = append(extractors, fmt.Sprintf("extractors.FromQuery(%q)", strings.TrimPrefix(p, "query:"))) + case strings.HasPrefix(p, "param:"): + extractors = append(extractors, fmt.Sprintf("extractors.FromParam(%q)", strings.TrimPrefix(p, "param:"))) + case strings.HasPrefix(p, "cookie:"): + extractors = append(extractors, fmt.Sprintf("extractors.FromCookie(%q)", strings.TrimPrefix(p, "cookie:"))) + case strings.HasPrefix(p, "form:"): + extractors = append(extractors, fmt.Sprintf("extractors.FromForm(%q)", strings.TrimPrefix(p, "form:"))) + default: + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%s// TODO: migrate TokenLookup: %s%s%s", indent, val, comment, newline) + } + } + + extractor := "" + switch len(extractors) { + case 1: + extractor = extractors[0] + case 0: + default: + extractor = fmt.Sprintf("extractors.Chain(%s)", strings.Join(extractors, ", ")) + } + + if extractor == "" { + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%s// TODO: migrate TokenLookup: %s%s%s", indent, val, comment, newline) + } + + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%sExtractor: %s%s%s%s", indent, extractor, comma, comment, newline) + }) + + cfg = reAuthLine.ReplaceAllString(cfg, "") + cfg = reFilter.ReplaceAllString(cfg, "${1}Next: ") + return cfg + }) + } + + if updated != content && strings.Contains(updated, "extractors.") { + updated = addImport(updated, "github.com/gofiber/fiber/v3/extractors") + } + return updated + }) + if err != nil { + return fmt.Errorf("failed to migrate jwt extractor config: %w", err) + } + if !changed { + return nil + } + + cmd.Println("Migrating jwt middleware configs") + return nil +} diff --git a/cmd/internal/migrations/v3/jwt_extractor_test.go b/cmd/internal/migrations/v3/jwt_extractor_test.go new file mode 100644 index 0000000..c908790 --- /dev/null +++ b/cmd/internal/migrations/v3/jwt_extractor_test.go @@ -0,0 +1,118 @@ +package v3_test + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gofiber/cli/cmd/internal/migrations/v3" +) + +func Test_MigrateJWTExtractor(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mjwt") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import ( + "github.com/gofiber/fiber/v3" + jwtware "github.com/gofiber/contrib/jwt" +) +var _ = jwtware.New(jwtware.Config{ + TokenLookup: "header:Authorization, cookie:jwt", + AuthScheme: "Token", + Filter: func(c fiber.Ctx) bool { return false }, +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigrateJWTExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.NotContains(t, content, "TokenLookup") + assert.NotContains(t, content, "AuthScheme") + assert.Contains(t, content, `Extractor: extractors.Chain(extractors.FromAuthHeader("Token"), extractors.FromCookie("jwt"))`) + assert.Contains(t, content, "Next:") + assert.Contains(t, content, "func(c fiber.Ctx) bool { return false },") + assert.Contains(t, content, `"github.com/gofiber/fiber/v3/extractors"`) + assert.Contains(t, buf.String(), "Migrating jwt middleware configs") +} + +func Test_MigrateJWTExtractor_TokenLookupExpr(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mjwt_expr") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import ( + jwtware "github.com/gofiber/contrib/jwt" + "strings" +) +var _ = jwtware.New(jwtware.Config{ + TokenLookup: strings.Join([]string{"header:Authorization"}, ","), + AuthScheme: "Bearer", +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigrateJWTExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.Contains(t, content, "// TODO: migrate TokenLookup: strings.Join([]string{\"header:Authorization\"}, \",\")") + assert.NotContains(t, content, "AuthScheme") + assert.Contains(t, buf.String(), "Migrating jwt middleware configs") +} + +func Test_MigrateJWTExtractor_CustomAlias(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mjwt_alias") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import authjwt "github.com/gofiber/contrib/jwt/v3" +var _ = authjwt.New(authjwt.Config{ + TokenLookup: "cookie:session", +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigrateJWTExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.NotContains(t, content, "TokenLookup") + assert.Contains(t, content, `Extractor: extractors.FromCookie("session")`) + assert.Contains(t, content, `"github.com/gofiber/fiber/v3/extractors"`) + assert.Contains(t, buf.String(), "Migrating jwt middleware configs") +} + +func Test_MigrateJWTExtractor_SkipUnrelatedPackage(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mjwt_skip") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + original := `package main +import jwtware "example.com/jwtware" +var _ = jwtware.Config{ + TokenLookup: "header:Authorization", +}` + file := writeTempFile(t, dir, original) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigrateJWTExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.Equal(t, original, content) + assert.Empty(t, buf.String()) +} diff --git a/cmd/internal/migrations/v3/paseto_extractor.go b/cmd/internal/migrations/v3/paseto_extractor.go new file mode 100644 index 0000000..d19478d --- /dev/null +++ b/cmd/internal/migrations/v3/paseto_extractor.go @@ -0,0 +1,133 @@ +package v3 + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/gofiber/cli/cmd/internal" +) + +func MigratePasetoExtractor(cmd *cobra.Command, cwd string, _, _ *semver.Version) error { + reImport := regexp.MustCompile(`(?m)^\s*(?:import\s+)?(?:([\w\.]+)\s+)?"github\.com/gofiber/contrib/paseto(?:/v\d+)?"`) + reTokenPrefix := regexp.MustCompile(`(?m)\s*TokenPrefix:\s*([^,\n]+)`) + + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + aliases := collectAliases(content, reImport, []string{"pasetoware", "paseto"}) + if len(aliases) == 0 { + return content + } + + updated := content + for _, alias := range aliases { + reConfig := regexp.MustCompile(regexp.QuoteMeta(alias) + `\.Config{(?:[^{}]|{[^{}]*})*}`) + updated = reConfig.ReplaceAllStringFunc(updated, func(cfg string) string { + schemeArg := "" + hasPrefix := false + if am := reTokenPrefix.FindStringSubmatch(cfg); len(am) > 1 { + hasPrefix = true + raw := strings.TrimSpace(am[1]) + if uq, err := strconv.Unquote(raw); err == nil { + schemeArg = fmt.Sprintf("%q", uq) + } else { + schemeArg = raw + } + } + + cfg = replaceField(cfg, "TokenLookup", func(indent, val, comma, comment, newline string) string { + lookup := strings.TrimSpace(val) + lookup = strings.TrimPrefix(lookup, "[2]string") + lookup = strings.TrimSpace(strings.TrimPrefix(lookup, "{")) + lookup = strings.TrimSuffix(lookup, "}") + parts := splitArgs(lookup) + if len(parts) < 2 { + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%s// TODO: migrate TokenLookup: %s%s%s", indent, val, comment, newline) + } + + source := strings.TrimSpace(parts[0]) + key := strings.TrimSpace(parts[1]) + + sourceLower := strings.ToLower(source) + if uq, err := strconv.Unquote(sourceLower); err == nil { + sourceLower = strings.ToLower(uq) + } + switch { + case strings.Contains(sourceLower, "lookupheader"): + sourceLower = "header" + case strings.Contains(sourceLower, "lookupquery"): + sourceLower = "query" + case strings.Contains(sourceLower, "lookupparam"): + sourceLower = "param" + case strings.Contains(sourceLower, "lookupcookie"): + sourceLower = "cookie" + case strings.Contains(sourceLower, "lookupform"): + sourceLower = "form" + default: + // preserve original value for unsupported lookups + } + + keyArg := key + if uq, err := strconv.Unquote(keyArg); err == nil { + keyArg = fmt.Sprintf("%q", uq) + } + + var extractor string + switch sourceLower { + case "header": + if strings.EqualFold(strings.Trim(keyArg, "\""), "Authorization") && hasPrefix { + extractor = fmt.Sprintf("extractors.FromAuthHeader(%s)", schemeArg) + } else { + extractor = fmt.Sprintf("extractors.FromHeader(%s)", keyArg) + } + case "query": + extractor = fmt.Sprintf("extractors.FromQuery(%s)", keyArg) + case "param": + extractor = fmt.Sprintf("extractors.FromParam(%s)", keyArg) + case "cookie": + extractor = fmt.Sprintf("extractors.FromCookie(%s)", keyArg) + case "form": + extractor = fmt.Sprintf("extractors.FromForm(%s)", keyArg) + default: + // leave extractor empty to emit TODO comment below + } + + if extractor == "" { + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%s// TODO: migrate TokenLookup: %s%s%s", indent, val, comment, newline) + } + + if comment != "" { + comment = " " + comment + } + return fmt.Sprintf("%sExtractor: %s%s%s%s", indent, extractor, comma, comment, newline) + }) + + cfg = removeConfigField(cfg, "TokenPrefix") + return cfg + }) + } + + if updated != content && strings.Contains(updated, "extractors.") { + updated = addImport(updated, "github.com/gofiber/fiber/v3/extractors") + } + return updated + }) + if err != nil { + return fmt.Errorf("failed to migrate paseto extractor config: %w", err) + } + if !changed { + return nil + } + + cmd.Println("Migrating paseto middleware configs") + return nil +} diff --git a/cmd/internal/migrations/v3/paseto_extractor_test.go b/cmd/internal/migrations/v3/paseto_extractor_test.go new file mode 100644 index 0000000..fc8f0ec --- /dev/null +++ b/cmd/internal/migrations/v3/paseto_extractor_test.go @@ -0,0 +1,134 @@ +package v3_test + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gofiber/cli/cmd/internal/migrations/v3" +) + +func Test_MigratePasetoExtractor_HeaderPrefix(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mpaseto") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import pasetoware "github.com/gofiber/contrib/paseto" +var _ = pasetoware.New(pasetoware.Config{ + TokenLookup: [2]string{"header", "Authorization"}, + TokenPrefix: "Bearer", +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigratePasetoExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.NotContains(t, content, "TokenLookup") + assert.NotContains(t, content, "TokenPrefix") + assert.Contains(t, content, `Extractor: extractors.FromAuthHeader("Bearer")`) + assert.Contains(t, content, `"github.com/gofiber/fiber/v3/extractors"`) + assert.Contains(t, buf.String(), "Migrating paseto middleware configs") +} + +func Test_MigratePasetoExtractor_QueryNoPrefix(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mpaseto_query") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import pasetoware "github.com/gofiber/contrib/paseto" +var _ = pasetoware.New(pasetoware.Config{ + TokenLookup: [2]string{pasetoware.LookupQuery, "token"}, +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigratePasetoExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.NotContains(t, content, "TokenLookup") + assert.Contains(t, content, `Extractor: extractors.FromQuery("token")`) + assert.Contains(t, buf.String(), "Migrating paseto middleware configs") +} + +func Test_MigratePasetoExtractor_UnsupportedLookup(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mpaseto_todo") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import ( + pasetoware "github.com/gofiber/contrib/paseto" + "strings" +) +var _ = pasetoware.New(pasetoware.Config{ + TokenLookup: [2]string{strings.ToUpper("header"), "Authorization"}, +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigratePasetoExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.Contains(t, content, "// TODO: migrate TokenLookup: [2]string{strings.ToUpper(\"header\"), \"Authorization\"}") + assert.NotContains(t, content, "TokenPrefix") + assert.Contains(t, buf.String(), "Migrating paseto middleware configs") +} + +func Test_MigratePasetoExtractor_CustomAlias(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mpaseto_alias") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + file := writeTempFile(t, dir, `package main +import authpaseto "github.com/gofiber/contrib/paseto/v3" +var _ = authpaseto.New(authpaseto.Config{ + TokenLookup: [2]string{"cookie", "session"}, +})`) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigratePasetoExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.NotContains(t, content, "TokenLookup") + assert.Contains(t, content, `Extractor: extractors.FromCookie("session")`) + assert.Contains(t, content, `"github.com/gofiber/fiber/v3/extractors"`) + assert.Contains(t, buf.String(), "Migrating paseto middleware configs") +} + +func Test_MigratePasetoExtractor_SkipUnrelatedPackage(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mpaseto_skip") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + original := `package main +import pasetoware "example.com/paseto" +var _ = pasetoware.Config{ + TokenLookup: [2]string{"header", "Authorization"}, +}` + file := writeTempFile(t, dir, original) + + var buf bytes.Buffer + cmd := newCmd(&buf) + require.NoError(t, v3.MigratePasetoExtractor(cmd, dir, nil, nil)) + + content := readFile(t, file) + assert.Equal(t, original, content) + assert.Empty(t, buf.String()) +}