cmd/badges/main.go GO 622 lines View on github.com → Search inside
1package main23import (4	"context"5	"errors"6	"fmt"7	"math"8	"net/http"9	"net/url"10	"os"11	"os/exec"12	"path/filepath"13	"regexp"14	"slices"15	"strconv"16	"strings"17	"sync"18	"time"1920	"github.com/boyter/scc/v4/processor"21	"github.com/boyter/simplecache"22	jsoniter "github.com/json-iterator/go"23	"github.com/rs/zerolog/log"24)2526var (27	uniqueCode = "unique_code"28	cache      = simplecache.New[[]processor.LanguageSummary](simplecache.Option{29		MaxItems: intPtr(1000),30		MaxAge:   timePtr(time.Hour * 72),31	})32	countingSemaphore = make(chan bool, 1)33	tmpDir            = os.TempDir()34	json              = jsoniter.ConfigCompatibleWithStandardLibrary35	locationLog       = []string{}36	locationTracker   = map[string]int{}37	locationLogMutex  = sync.Mutex{}38)3940func intPtr(i int) *int {41	return &i42}4344func timePtr(t time.Duration) *time.Duration {45	return &t46}4748func main() {49	http.HandleFunc("/health-check/", func(w http.ResponseWriter, r *http.Request) {50		locationLogMutex.Lock()51		for k, v := range locationTracker {52			_, _ = fmt.Fprintf(w, "%s:%d\n", k, v)53		}54		locationLogMutex.Unlock()55	})5657	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {58		loc, err := processUrlPath(r.URL.Path)59		if err != nil {60			http.Redirect(w, r, "https://github.com/boyter/scc/?tab=readme-ov-file#badges-beta", http.StatusTemporaryRedirect)61			return62		}6364		if filterBad(loc) {65			log.Error().Str(uniqueCode, "bfee4bd8").Str("loc", loc.String()).Msg("filter bad")66			w.WriteHeader(http.StatusBadRequest)67			_, _ = w.Write([]byte("invalid request"))68			return69		}7071		appendLocationLog(loc.String())7273		res, err := process(1, loc)74		if err != nil {75			log.Error().Str(uniqueCode, "03ec75c3").Err(err).Str("loc", loc.String()).Send()76			w.WriteHeader(http.StatusBadRequest)77			_, _ = w.Write([]byte("something bad happened sorry"))78			return79		}8081		category := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category")))82		wage := tryParseInt(strings.TrimSpace(strings.ToLower(r.URL.Query().Get("avg-wage"))), 56286)83		title, value := calculate(category, wage, res)8485		if r.URL.Query().Get("lower") != "" {86			title = strings.ToLower(title)87		}8889		s := formatCount(float64(value))90		if category == "effort" {91			s = formatMonthsInt(value)92		}9394		textLength := "250"95		if len(s) <= 3 {96			textLength = "200"97		}9899		bs := parseBadgeSettings(r.URL.Query())100101		log.Info().Str(uniqueCode, "42c5269c").Str("loc", loc.String()).Str("category", category).Send()102		w.Header().Set("Content-Type", "image/svg+xml;charset=utf-8")103		if category == "effort" {104			_, _ = w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="240" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="` + bs.TopShadowAccentColor + `" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="240" height="20" rx="3" fill="#` + bs.FontColor + `"/></clipPath><g clip-path="url(#a)"><path fill="#` + bs.TitleBackgroundColor + `" d="M0 0h125v20H0z"/><path fill="#` + bs.BadgeBackgroundColor + `" d="M125 0h115v20H125z"/><path fill="url(#b)" d="M0 0h240v20H0z"/></g><g fill="#` + bs.FontColor + `" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"> <text x="625" y="150" fill="#` + bs.TextShadowColor + `" fill-opacity=".3" transform="scale(.1)">` + title + `</text><text x="625" y="140" transform="scale(.1)">` + title + `</text><text x="1825" y="150" fill="#` + bs.TextShadowColor + `" fill-opacity=".3" transform="scale(.1)">` + s + `</text><text x="1825" y="140" transform="scale(.1)">` + s + `</text></g> </svg>`))105		} else {106			_, _ = w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="20"><linearGradient id="b" x2="0" y2="100%"><stop offset="0" stop-color="` + bs.TopShadowAccentColor + `" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="a"><rect width="100" height="20" rx="3" fill="#` + bs.FontColor + `"/></clipPath><g clip-path="url(#a)"><path fill="#` + bs.TitleBackgroundColor + `" d="M0 0h69v20H0z"/><path fill="#` + bs.BadgeBackgroundColor + `" d="M69 0h31v20H69z"/><path fill="url(#b)" d="M0 0h100v20H0z"/></g><g fill="#` + bs.FontColor + `" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"> <text x="355" y="150" fill="#` + bs.TextShadowColor + `" fill-opacity=".3" transform="scale(.1)" textLength="590">` + title + `</text><text x="355" y="140" transform="scale(.1)" textLength="590">` + title + `</text><text x="835" y="150" fill="#` + bs.TextShadowColor + `" fill-opacity=".3" transform="scale(.1)" textLength="` + textLength + `">` + s + `</text><text x="835" y="140" transform="scale(.1)" textLength="` + textLength + `">` + s + `</text></g> </svg>`))107		}108	})109110	addr := ":8080"111	log.Info().Str(uniqueCode, "1876ce1e").Str("addr", addr).Msg("serving")112	if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {113		log.Error().Str(uniqueCode, "c28556e8").Err(err).Send()114		os.Exit(1)115	}116}117118func filterBad(loc location) bool {119	l := loc.String()120	bad := []string{"wp-content.com", "wp-admin.com", ".well-known", "wp-includes.com", ".php"}121	count := 0122	for _, b := range bad {123		if strings.Contains(l, b) {124			count++125		}126	}127128	return count >= 2129}130131func appendLocationLog(log string) {132	locationLogMutex.Lock()133	defer locationLogMutex.Unlock()134135	if slices.Contains(locationLog, log) {136		return137	}138	locationLog = append(locationLog, log)139	locationTracker[log] = locationTracker[log] + 1140141	if len(locationLog) > 100 {142		locationLog = locationLog[1:]143	}144}145146func calculate(category string, wage int, res []processor.LanguageSummary) (string, int64) {147	title := ""148	var value int64149150	switch category {151	case "code", "codes":152		title = "Code lines"153		for _, x := range res {154			value += x.Code155		}156	case "blank", "blanks":157		title = "Blank lines"158		for _, x := range res {159			value += x.Blank160		}161	case "comment", "comments":162		title = "Comments"163		for _, x := range res {164			value += x.Comment165		}166	case "cocomo":167		title = "COCOMO $"168		for _, x := range res {169			value += x.Code170		}171172		value = int64(estimateCost(value, wage))173	case "effort":174		title = "COCOMO Man Years"175		for _, x := range res {176			value += x.Code177		}178179		value = int64(estimateScheduleMonths(value))180	case "line", "lines": // lines is the default181		fallthrough182	default:183		//184		title = "Total lines"185		for _, x := range res {186			value += x.Lines187		}188	}189	return title, value190}191192type location struct {193	Provider string194	User     string195	Repo     string196}197198func (l *location) String() string {199	loc := ".com/"200	ext := ".git"201	switch strings.ToLower(l.Provider) {202	case "bitbucket", "codeberg":203		loc = ".org/"204	case "git.sr.ht":205		loc = "/"206		ext = ""207	}208209	rawURL := "https://" + l.Provider + loc + l.User + "/" + l.Repo + ext210	parse, err := url.Parse(rawURL)211	if err != nil {212		return rawURL213	}214	return parse.String()215}216217// processUrlPath takes in a string path and returns a struct218// that contains the location user and repo which is what most219// repositories need220// returns an error if we get anything other than 3 parts since thats221// the format we expect222func processUrlPath(path string) (location, error) {223	path = strings.ToLower(path)224	path = strings.TrimPrefix(path, "/")225	path = strings.TrimSuffix(path, "/")226	s := strings.Split(path, "/")227	if len(s) != 3 {228		return location{}, errors.New("invalid path part")229	}230231	if s[0] == "sr.ht" {232		s[0] = "git.sr.ht"233	}234235	return location{236		Provider: s[0],237		User:     s[1],238		Repo:     s[2],239	}, nil240}241242type badgeSettings struct {243	FontColor            string244	TextShadowColor      string245	TopShadowAccentColor string246	TitleBackgroundColor string247	BadgeBackgroundColor string248}249250// namedColors maps color names to their hex values (without #).251// Includes shields.io colors and common CSS color names for compatibility.252var namedColors = map[string]string{253	// shields.io specific colors254	"brightgreen": "44cc11",255	"green":       "97ca00",256	"yellowgreen": "a4a61d",257	"yellow":      "dfb317",258	"orange":      "fe7d37",259	"red":         "e05d44",260	"blue":        "007ec6",261	"lightgrey":   "9f9f9f",262	"lightgray":   "9f9f9f",263	"grey":        "555555",264	"gray":        "555555",265	"blueviolet":  "8a2be2",266	// shields.io semantic aliases267	"success":       "44cc11",268	"important":     "fe7d37",269	"critical":      "e05d44",270	"informational": "007ec6",271	"inactive":      "9f9f9f",272	// Common CSS color names273	"black":           "000000",274	"white":           "ffffff",275	"silver":          "c0c0c0",276	"maroon":          "800000",277	"purple":          "800080",278	"fuchsia":         "ff00ff",279	"lime":            "00ff00",280	"olive":           "808000",281	"navy":            "000080",282	"teal":            "008080",283	"aqua":            "00ffff",284	"cyan":            "00ffff",285	"magenta":         "ff00ff",286	"pink":            "ffc0cb",287	"coral":           "ff7f50",288	"salmon":          "fa8072",289	"gold":            "ffd700",290	"khaki":           "f0e68c",291	"violet":          "ee82ee",292	"indigo":          "4b0082",293	"crimson":         "dc143c",294	"turquoise":       "40e0d0",295	"tan":             "d2b48c",296	"brown":           "a52a2a",297	"chocolate":       "d2691e",298	"tomato":          "ff6347",299	"orchid":          "da70d6",300	"plum":            "dda0dd",301	"peru":            "cd853f",302	"sienna":          "a0522d",303	"beige":           "f5f5dc",304	"ivory":           "fffff0",305	"linen":           "faf0e6",306	"azure":           "f0ffff",307	"lavender":        "e6e6fa",308	"wheat":           "f5deb3",309	"snow":            "fffafa",310	"seashell":        "fff5ee",311	"honeydew":        "f0fff0",312	"mintcream":       "f5fffa",313	"aliceblue":       "f0f8ff",314	"ghostwhite":      "f8f8ff",315	"oldlace":         "fdf5e6",316	"papayawhip":      "ffefd5",317	"moccasin":        "ffe4b5",318	"bisque":          "ffe4c4",319	"mistyrose":       "ffe4e1",320	"lemonchiffon":    "fffacd",321	"cornsilk":        "fff8dc",322	"antiquewhite":    "faebd7",323	"floralwhite":     "fffaf0",324	"steelblue":       "4682b4",325	"royalblue":       "4169e1",326	"skyblue":         "87ceeb",327	"dodgerblue":      "1e90ff",328	"deepskyblue":     "00bfff",329	"cadetblue":       "5f9ea0",330	"cornflowerblue":  "6495ed",331	"mediumblue":      "0000cd",332	"darkblue":        "00008b",333	"midnightblue":    "191970",334	"slateblue":       "6a5acd",335	"darkslateblue":   "483d8b",336	"mediumslateblue": "7b68ee",337	"seagreen":        "2e8b57",338	"mediumseagreen":  "3cb371",339	"lightgreen":      "90ee90",340	"darkgreen":       "006400",341	"forestgreen":     "228b22",342	"limegreen":       "32cd32",343	"springgreen":     "00ff7f",344	"palegreen":       "98fb98",345	"darkseagreen":    "8fbc8f",346	"olivedrab":       "6b8e23",347	"darkolivegreen":  "556b2f",348	"darkred":         "8b0000",349	"firebrick":       "b22222",350	"indianred":       "cd5c5c",351	"lightsalmon":     "ffa07a",352	"darksalmon":      "e9967a",353	"lightcoral":      "f08080",354	"rosybrown":       "bc8f8f",355	"sandybrown":      "f4a460",356	"goldenrod":       "daa520",357	"darkgoldenrod":   "b8860b",358	"darkorange":      "ff8c00",359	"orangered":       "ff4500",360	"hotpink":         "ff69b4",361	"deeppink":        "ff1493",362	"palevioletred":   "db7093",363	"mediumvioletred": "c71585",364	"mediumpurple":    "9370db",365	"darkorchid":      "9932cc",366	"darkviolet":      "9400d3",367	"darkmagenta":     "8b008b",368	"slategray":       "708090",369	"slategrey":       "708090",370	"lightslategray":  "778899",371	"lightslategrey":  "778899",372	"darkslategray":   "2f4f4f",373	"darkslategrey":   "2f4f4f",374	"dimgray":         "696969",375	"dimgrey":         "696969",376	"darkgray":        "a9a9a9",377	"darkgrey":        "a9a9a9",378	"gainsboro":       "dcdcdc",379	"whitesmoke":      "f5f5f5",380}381382// resolveColor converts a color input (name or hex) to a hex value without #.383// Returns the hex value if valid, or empty string if invalid.384func resolveColor(color string) string {385	color = strings.ToLower(color)386387	// Check if it's a named color388	if hex, ok := namedColors[color]; ok {389		return hex390	}391392	// Check if it's a valid hex color (3, 4, 6, or 8 digits)393	hexRegex := regexp.MustCompile(`^(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$`)394	if hexRegex.MatchString(color) {395		return color396	}397398	return ""399}400401// Parses badge settings from url query params402// if error, ignore and return default badge settings403func parseBadgeSettings(values url.Values) *badgeSettings {404	bs := badgeSettings{405		FontColor:            "fff",406		TextShadowColor:      "010101",407		TopShadowAccentColor: "bbb",408		TitleBackgroundColor: "555",409		BadgeBackgroundColor: "4c1",410	}411412	fontColor := values.Get("font-color")413	textShadowColor := values.Get("font-shadow-color")414	topShadowAccentColor := values.Get("top-shadow-accent-color")415	titleBackgroundColor := values.Get("title-bg-color")416	badgeBackgroundColor := values.Get("badge-bg-color")417418	// Resolve colors (supports both named colors and hex codes)419	if resolved := resolveColor(fontColor); resolved != "" {420		bs.FontColor = resolved421	}422	if resolved := resolveColor(textShadowColor); resolved != "" {423		bs.TextShadowColor = resolved424	}425	if resolved := resolveColor(topShadowAccentColor); resolved != "" {426		bs.TopShadowAccentColor = resolved427	}428	if resolved := resolveColor(titleBackgroundColor); resolved != "" {429		bs.TitleBackgroundColor = resolved430	}431	if resolved := resolveColor(badgeBackgroundColor); resolved != "" {432		bs.BadgeBackgroundColor = resolved433	}434435	return &bs436}437438// formatCount turns a float into a string usable for display439// to the user so, 2532 would be 2.5k and such up the various440// units441func formatCount(count float64) string {442	type r struct {443		val float64444		sym string445	}446	ranges := []r{447		{1e18, "E"},448		{1e15, "P"},449		{1e12, "T"},450		{1e9, "G"},451		{1e6, "M"},452		{1e3, "k"},453	}454455	for _, v := range ranges {456		if count >= v.val {457			t := fmt.Sprintf("%.1f", math.Ceil(count/v.val*10)/10)458459			if len(t) > 3 {460				t = t[:strings.Index(t, ".")]461			}462463			return fmt.Sprintf("%v%v", t, v.sym)464		}465	}466467	return fmt.Sprintf("%v", math.Round(count))468}469470func process(id int, s location) ([]processor.LanguageSummary, error) {471	countingSemaphore <- true472	defer func() {473		<-countingSemaphore // remove one to free up concurrency474	}()475476	val, ok := cache.Get(s.String())477	if ok {478		return val, nil479	}480481	// Clean target just to be sure482	targetPath := filepath.Join(tmpDir, "scc-tmp-path-"+strconv.Itoa(id))483	if err := os.RemoveAll(targetPath); err != nil {484		return nil, err485	}486487	// Run git clone against the target488	// 180 seconds seems enough as the kernel itself takes about 60 seconds489	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)490	defer cancel()491492	cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", s.String(), targetPath)493494	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")495	_, err := cmd.Output()496497	if errors.Is(ctx.Err(), context.DeadlineExceeded) {498		return nil, err499	}500501	if err != nil {502		return nil, err503	}504505	// Run scc against what we just cloned506	fileName := processPath(s.String())507	filePath := filepath.Join(tmpDir, fileName)508509	if fileName == "" {510		return nil, errors.New("processPath returned empty")511	}512513	cmdArgs := []string{514		"-f",515		"json",516		"-o",517		filePath,518		targetPath,519	}520521	cmd = exec.Command("scc", cmdArgs...)522	err = cmd.Run()523	if err != nil {524		return nil, err525	}526527	data, err := os.ReadFile(filePath)528	if err != nil {529		return nil, err530	}531532	var res []processor.LanguageSummary533	err = json.Unmarshal(data, &res)534	if err != nil {535		return nil, err536	}537	_ = cache.Set(s.String(), res)538539	// Cleanup540	if err := os.RemoveAll(filePath); err != nil {541		return nil, err542	}543544	if err := os.RemoveAll(targetPath); err != nil {545		return nil, err546	}547548	return res, nil549}550551func processPath(s string) string {552	s = strings.ToLower(s)553	split := strings.Split(s, "/")554555	if len(split) != 5 {556		return ""557	}558559	sp := make([]string, 0, len(split))560561	for _, s := range split {562		sp = append(sp, cleanString(s))563	}564565	filename := strings.ReplaceAll(sp[2], ".com", "")566	filename = strings.ReplaceAll(filename, ".org", "")567	filename += "." + sp[3]568	filename += "." + strings.ReplaceAll(sp[4], ".git", "") + ".json"569570	return filename571}572573func cleanString(s string) string {574	reg, err := regexp.Compile("[^a-z0-9-._]+")575	if err != nil {576		log.Fatal().Err(err).Send()577	}578579	processedString := reg.ReplaceAllString(s, "")580581	return processedString582}583584func estimateEffort(codeCount int64) float64 {585	return 3.2 * math.Pow(float64(codeCount)/1000, 1.05) * 1586}587588func estimateCost(codeCount int64, averageWage int) float64 {589	return estimateEffort(codeCount) * (float64(averageWage) / 12) * 1.8590}591592func estimateScheduleMonths(codeCount int64) float64 {593	return 2.5 * math.Pow(estimateEffort(codeCount), 0.38)594}595596// FormatMonths converts a float64 representing months into a "X years Y months" string.597func formatMonths(totalMonths float64) string {598	years, fracMonths := math.Modf(totalMonths / 12)599	months := int(math.Round(fracMonths * 12))600601	return fmt.Sprintf("%d years %d months", int(years), months)602}603604// FormatMonthsInt converts an int64 representing months into a "X years Y months" string.605func formatMonthsInt(totalMonths int64) string {606	// Use integer division to get the number of years607	years := totalMonths / 12608609	// Use the modulo operator to get the remaining months610	months := totalMonths % 12611612	return fmt.Sprintf("%d years %d months", years, months)613}614615func tryParseInt(s string, def int) int {616	i, err := strconv.Atoi(s)617	if err != nil {618		return def619	}620	return i621}

Code quality findings 5

Ensure errors are handled or logged
warning correctness unhandled-error
if err != nil {
Declared map variable without initialization; writing to a nil map causes a panic. Use make() to initialize
warning correctness nil-map-write
var namedColors = map[string]string{
Ensure errors are handled or logged
warning correctness unhandled-error
if err != nil {
Regexp compiled inside function; compile once at package level to avoid recompilation on each call
info performance regexp-compile-in-func
hexRegex := regexp.MustCompile(`^(?:(?:[\da-f]{3}){1,2}|(?:[\da-f]{4}){1,2})$`)
Regexp compiled inside function; compile once at package level to avoid recompilation on each call
info performance regexp-compile-in-func
reg, err := regexp.Compile("[^a-z0-9-._]+")

Security findings 1

HTTP server with no ReadHeaderTimeout or ReadTimeout, so a slow client can hold connections open indefinitely; use an http.Server with ReadHeaderTimeout set
info security go-http-no-timeouts
if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.

Generated by searchcode.com · powered by code_get_file + code_get_findings