diff --git a/README.md b/README.md index b51fef4..6d66d57 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Go-based tool for migrating GitLab repositories, users, groups, issues and relat More-or-less a port of [gitlab-to-gitea](https://git.autonomic.zone/kawaiipunk/gitlab-to-gitea) from python to Go because *fixing* python appears to be a thing I just can't get my mind around, but *rewriting* it? I'm actually OK at that. -Also includes: `cmd/forkfix`, for fixing fork relationships between migrated repositories by manipulating the gitea mysql database and `cmd/unmigrate` to delete everything from a gitea instance except for the admin users. +Also includes: `cmd/forkfix`, for fixing fork relationships between migrated repositories by manipulating the gitea mysql database, `cmd/unmigrate` to delete everything from a gitea instance except for the admin users, and `cmd/johnconnor` which is a super-dangerous script for eliminating spam accounts from gitlab instances. ## Core Functionality @@ -18,7 +18,7 @@ Also includes: `cmd/forkfix`, for fixing fork relationships between migrated rep - Modular package structure instead of monolithic script - Configuration via environment variables rather than hardcoded values -- Added utility tools (`forkfix` and `unmigrate`) +- Added utility tools (`forkfix`, `unmigrate`, `johnconnor`) - Database connectivity for commit action imports - Improved error handling with recovery mechanisms - Separation of API client code from migration logic diff --git a/cmd/forkfix/main.go b/cmd/forkfix/main.go new file mode 100644 index 0000000..febff6b --- /dev/null +++ b/cmd/forkfix/main.go @@ -0,0 +1,224 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "os" + + _ "github.com/mattn/go-sqlite3" +) + +// Repository represents a Gitea repository record +type Repository struct { + ID int64 + Name string + OwnerName string + IsFork bool + ForkID sql.NullInt64 +} + +func main() { + // Define command line flags + dbPath := flag.String("db", "", "Path to gitea.db (required)") + forkID := flag.Int64("fork", 0, "ID of the repository to mark as fork (required)") + originalID := flag.Int64("original", 0, "ID of the original repository (required)") + unsetFork := flag.Bool("unset", false, "Unset fork relationship instead of setting it") + listRepos := flag.Bool("list", false, "List all repositories in the database") + backup := flag.Bool("backup", true, "Create a backup of the database before making changes") + help := flag.Bool("help", false, "Show help") + + flag.Parse() + + // Display help if requested or if no arguments provided + if *help || flag.NFlag() == 0 { + printUsage() + return + } + + // List repositories if requested + if *listRepos { + if *dbPath == "" { + log.Fatal("Database path (-db) is required") + } + listRepositories(*dbPath) + return + } + + // Validate required arguments + if *dbPath == "" { + log.Fatal("Database path (-db) is required") + } + + if !*unsetFork && (*forkID == 0 || *originalID == 0) { + log.Fatal("Both fork ID (-fork) and original repository ID (-original) are required") + } + + if *unsetFork && *forkID == 0 { + log.Fatal("Fork ID (-fork) is required to unset a fork relationship") + } + + // Create backup if requested + if *backup { + backupFile := backupDatabase(*dbPath) + fmt.Printf("Created backup at: %s\n", backupFile) + } + + // Open database connection + db, err := sql.Open("sqlite3", *dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Verify the database connection + if err := db.Ping(); err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // Verify repositories exist before making changes + if !*unsetFork { + verifyRepository(db, *forkID, "fork") + verifyRepository(db, *originalID, "original") + } else { + verifyRepository(db, *forkID, "fork") + } + + // Execute the operation + if !*unsetFork { + // Set fork relationship + updateForkRelationship(db, *forkID, *originalID) + fmt.Printf("Repository ID %d is now marked as a fork of repository ID %d\n", *forkID, *originalID) + } else { + // Unset fork relationship + unsetForkRelationship(db, *forkID) + fmt.Printf("Repository ID %d is no longer marked as a fork\n", *forkID) + } +} + +// printUsage displays the help information +func printUsage() { + fmt.Println("Gitea Repository Fork Relationship Fixer") + fmt.Println("\nThis tool helps set, update, or remove fork relationships between repositories in a Gitea instance using SQLite.") + fmt.Println("\nUsage:") + fmt.Println(" Set a fork relationship:") + fmt.Println(" gitea-fork-fixer -db /path/to/gitea.db -fork 123 -original 456") + fmt.Println("\n Remove a fork relationship:") + fmt.Println(" gitea-fork-fixer -db /path/to/gitea.db -fork 123 -unset") + fmt.Println("\n List all repositories:") + fmt.Println(" gitea-fork-fixer -db /path/to/gitea.db -list") + fmt.Println("\nOptions:") + flag.PrintDefaults() + fmt.Println("\nNOTE: Always restart Gitea after making changes for them to take effect.") +} + +// backupDatabase creates a backup of the database file +func backupDatabase(dbPath string) string { + backupPath := dbPath + ".backup-" + fmt.Sprintf("%d", os.Getpid()) + + // Read the original database file + data, err := os.ReadFile(dbPath) + if err != nil { + log.Fatalf("Failed to read database for backup: %v", err) + } + + // Write to the backup file + err = os.WriteFile(backupPath, data, 0o644) + if err != nil { + log.Fatalf("Failed to create backup: %v", err) + } + + return backupPath +} + +// verifyRepository checks if a repository exists in the database +func verifyRepository(db *sql.DB, repoID int64, repoType string) { + var count int + err := db.QueryRow("SELECT COUNT(*) FROM repository WHERE id = ?", repoID).Scan(&count) + if err != nil { + log.Fatalf("Failed to verify %s repository: %v", repoType, err) + } + + if count == 0 { + log.Fatalf("%s repository with ID %d does not exist", repoType, repoID) + } +} + +// updateForkRelationship sets a repository as a fork of another +func updateForkRelationship(db *sql.DB, forkID, originalID int64) { + tx, err := db.Begin() + if err != nil { + log.Fatalf("Failed to begin transaction: %v", err) + } + + _, err = tx.Exec("UPDATE repository SET fork_id = ?, is_fork = 1 WHERE id = ?", originalID, forkID) + if err != nil { + tx.Rollback() + log.Fatalf("Failed to update fork relationship: %v", err) + } + + if err := tx.Commit(); err != nil { + log.Fatalf("Failed to commit changes: %v", err) + } +} + +// unsetForkRelationship removes a fork relationship +func unsetForkRelationship(db *sql.DB, forkID int64) { + tx, err := db.Begin() + if err != nil { + log.Fatalf("Failed to begin transaction: %v", err) + } + + _, err = tx.Exec("UPDATE repository SET fork_id = NULL, is_fork = 0 WHERE id = ?", forkID) + if err != nil { + tx.Rollback() + log.Fatalf("Failed to unset fork relationship: %v", err) + } + + if err := tx.Commit(); err != nil { + log.Fatalf("Failed to commit changes: %v", err) + } +} + +// listRepositories displays all repositories in the database +func listRepositories(dbPath string) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + rows, err := db.Query(` + SELECT r.id, r.name, u.name as owner_name, r.is_fork, r.fork_id + FROM repository r + JOIN user u ON r.owner_id = u.id + ORDER BY r.id + `) + if err != nil { + log.Fatalf("Failed to query repositories: %v", err) + } + defer rows.Close() + + fmt.Println("ID\tOwner/Name\tIs Fork\tFork of ID") + fmt.Println("--\t----------\t-------\t----------") + + for rows.Next() { + var repo Repository + if err := rows.Scan(&repo.ID, &repo.Name, &repo.OwnerName, &repo.IsFork, &repo.ForkID); err != nil { + log.Fatalf("Failed to scan row: %v", err) + } + + forkID := "NULL" + if repo.ForkID.Valid { + forkID = fmt.Sprintf("%d", repo.ForkID.Int64) + } + + fmt.Printf("%d\t%s/%s\t%t\t%s\n", + repo.ID, repo.OwnerName, repo.Name, repo.IsFork, forkID) + } + + if err := rows.Err(); err != nil { + log.Fatalf("Error iterating through results: %v", err) + } +} diff --git a/cmd/johnconner/main.go b/cmd/johnconner/main.go new file mode 100644 index 0000000..b806a8f --- /dev/null +++ b/cmd/johnconner/main.go @@ -0,0 +1,275 @@ +// This is different, and worse, than everything else in this repo. +// It is a script to delete users and groups from a GitLab instance. +// It still contains I2P-specific behavior. +// It does not use the official gitlab API. + +// I SERIOUSLY DON'T THINK THIS IS A GOOD IDEA. + +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" +) + +const ( + disclaimerMessage = ` +# This is a gitlab anti-bot script for selfhosted instances. +# It was used on i2pgit.org after a bot attack. +# You **WILL** need to modify it for other instances. +# You **WILL** need to set up an API key with very dangerous perms to use it. +# It **WILL** break your shit in terrible, unfixable ways if you aren't very very careful. +# You **SHOULD NEVER RUN THIS SCRIPT WITHOUT READING IT TWICE FIRST** +# FOR REAL this is like a BIG RED BUTTON UNDER A BULLETPROOF GLASS CASE WITH TWO KEYS. +# IN CASE OF BOT ARMY consider NOT using this thing but maybe if you really have to, then ask for help, then use it. +# IT'S WEEDEATING WITH A FLAMETHROWER. IT'S CHAINSAW SURGERY. I CANNOT EMPHASIZE THAT ENOUGH. +# I HAVE NO DIRECT KNOWLEDGE OF IT KILLING ANY KITTENS BUT I CANNOT RULE IT OUT. +# That said, it did take care of the bot problem. +` +) + +var ( + gitlabAPIURL = "https://gitlab.example.com/api/v4" // Modify to your GitLab instance + gitlabToken = "" // Set your API token here or via env var +) + +type User struct { + ID int `json:"id"` + LastActivityOn interface{} `json:"last_activity_on"` // Could be null + IsFollowed bool `json:"is_followed"` + CanCreateProject bool `json:"can_create_project"` +} + +type Membership struct { + SourceType string `json:"source_type"` + SourceName string `json:"source_name"` + SourceID int `json:"source_id"` +} + +func init() { + // Check for token in env var + if envToken := os.Getenv("GITLAB_API_TOKEN"); envToken != "" { + gitlabToken = envToken + } + + if gitlabToken == "" { + fmt.Println("ERROR: GITLAB_API_TOKEN not set. Please set it as an environment variable or in the code.") + os.Exit(1) + } + + // Display disclaimer + fmt.Println(disclaimerMessage) + fmt.Println("Are you ABSOLUTELY SURE you want to continue? This can cause IRREVERSIBLE DAMAGE.") + fmt.Print("Type 'YES I UNDERSTAND THE CONSEQUENCES' to continue: ") + + var confirmation string + fmt.Scanln(&confirmation) + if confirmation != "YES I UNDERSTAND THE CONSEQUENCES" { + fmt.Println("Aborted.") + os.Exit(0) + } +} + +func main() { + users, err := getAllUsers() + if err != nil { + fmt.Printf("Error getting users: %v\n", err) + os.Exit(1) + } + + for _, user := range users { + fmt.Println("begin loop") + fmt.Printf("User ID: %d\n", user.ID) + + if user.IsFollowed { + fmt.Println("User is followed, skipping") + continue + } + + if user.LastActivityOn == nil { + fmt.Printf("User %d has no activity, deleting\n", user.ID) + deleteUser(user.ID) + continue + } + + if !user.CanCreateProject { + fmt.Printf("User %d cannot create projects, deleting\n", user.ID) + deleteUser(user.ID) + continue + } + + memberships, err := getUserMemberships(user.ID) + if err != nil { + fmt.Printf("Error getting memberships for user %d: %v\n", user.ID, err) + continue + } + + if len(memberships) == 0 { + fmt.Printf("User %d has no memberships, deleting\n", user.ID) + deleteUser(user.ID) + continue + } + + hasI2PProject := false + for _, membership := range memberships { + fmt.Printf("Project is: %s\n", membership.SourceType) + + if membership.SourceType == "Project" { + fmt.Printf("Project name is: %s\n", membership.SourceName) + + if isI2PRelated(membership.SourceName) { + fmt.Printf("%s contained an I2P related term\n", membership.SourceName) + hasI2PProject = true + break + } + } + + if membership.SourceType == "Namespace" { + fmt.Printf("Group name is: %s\n", membership.SourceName) + + if !isI2PRelated(membership.SourceName) { + fmt.Printf("Deleting non-I2P related group %d\n", membership.SourceID) + deleteGroup(membership.SourceID) + } + } + } + + if hasI2PProject { + fmt.Printf("User %d has I2P related project.\n", user.ID) + } else { + fmt.Printf("User %d has no I2P related projects, deleting\n", user.ID) + deleteUser(user.ID) + } + + fmt.Println("") + } +} + +func isI2PRelated(name string) bool { + return regexp.MustCompile(`(?i)i2p`).MatchString(name) +} + +func getAllUsers() ([]User, error) { + var allUsers []User + page := 1 + + for { + url := fmt.Sprintf("%s/users?page=%d&per_page=100", gitlabAPIURL, page) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("PRIVATE-TOKEN", gitlabToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code %d", resp.StatusCode) + } + + var users []User + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + return nil, err + } + + if len(users) == 0 { + break + } + + allUsers = append(allUsers, users...) + page++ + } + + return allUsers, nil +} + +func getUserMemberships(userID int) ([]Membership, error) { + url := fmt.Sprintf("%s/users/%d/memberships", gitlabAPIURL, userID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("PRIVATE-TOKEN", gitlabToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status code %d: %s", resp.StatusCode, string(body)) + } + + var memberships []Membership + if err := json.NewDecoder(resp.Body).Decode(&memberships); err != nil { + return nil, err + } + + return memberships, nil +} + +func deleteUser(userID int) { + url := fmt.Sprintf("%s/users/%d", gitlabAPIURL, userID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + fmt.Printf("Error creating delete request for user %d: %v\n", userID, err) + return + } + + req.Header.Set("PRIVATE-TOKEN", gitlabToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Error deleting user %d: %v\n", userID, err) + return + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + fmt.Printf("Successfully deleted user %d\n", userID) + } else { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Failed to delete user %d. Status: %d, Response: %s\n", userID, resp.StatusCode, string(body)) + } +} + +func deleteGroup(groupID int) { + url := fmt.Sprintf("%s/groups/%d", gitlabAPIURL, groupID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + fmt.Printf("Error creating delete request for group %d: %v\n", groupID, err) + return + } + + req.Header.Set("PRIVATE-TOKEN", gitlabToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Error deleting group %d: %v\n", groupID, err) + return + } + + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + fmt.Printf("Successfully deleted group %d\n", groupID) + } else { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Failed to delete group %d. Status: %d, Response: %s\n", groupID, resp.StatusCode, string(body)) + } +}