package app import ( "database/sql" "errors" "flag" "fmt" "net/http" "os" "path/filepath" "strings" "gitocean/internal/dbutil" ) // ---------------- Server boot and migrations ---------------- func runServer(args []string) error { fs := flag.NewFlagSet("server", flag.ExitOnError) configFile := fs.String("config", defaultServerConfigPath(), "server config file") addr := fs.String("addr", "", "HTTP listen address") dsn := fs.String("dsn", os.Getenv("GITOOCEAN_DSN"), "MySQL DSN") storage := fs.String("storage", "", "storage directory") publicURL := fs.String("public-url", "", "public server URL") if err := fs.Parse(args); err != nil { return err } cfg, _ := loadServerConfig(*configFile) if *addr != "" { cfg.Addr = *addr } if *dsn != "" { cfg.MySQLDSN = *dsn } if *storage != "" { cfg.Storage = *storage } if *publicURL != "" { cfg.PublicURL = *publicURL } if cfg.Addr == "" { cfg.Addr = ":8080" } if cfg.Storage == "" { cfg.Storage = defaultStorage } if cfg.PublicURL == "" { cfg.PublicURL = defaultServerURL } if cfg.MySQLDSN == "" { return errors.New("--dsn, GITOOCEAN_DSN, or server config mysql_dsn is required; run gitocean init") } db, err := openMySQLAndCreateDatabaseIfMissing(cfg.MySQLDSN) if err != nil { return err } defer db.Close() if err := migrate(db); err != nil { return err } if err := os.MkdirAll(filepath.Join(cfg.Storage, "repos"), 0755); err != nil { return err } s := &Server{db: db, storage: cfg.Storage, publicURL: strings.TrimRight(cfg.PublicURL, "/")} fmt.Printf("gitocean listening on %s, storage=%s, public_url=%s\n", cfg.Addr, cfg.Storage, s.publicURL) return http.ListenAndServe(cfg.Addr, s) } func openMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) { return dbutil.OpenMySQLAndCreateDatabaseIfMissing(dsn) } func quoteMySQLIdentifier(name string) string { return dbutil.QuoteMySQLIdentifier(name) } func migrate(db *sql.DB) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, is_admin BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS auth_tokens ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_auth_tokens_user_id (user_id), INDEX idx_auth_tokens_expires_at (expires_at), CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS repositories ( id BIGINT AUTO_INCREMENT PRIMARY KEY, owner_user_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, visibility ENUM('public','private') NOT NULL, description TEXT NOT NULL, default_branch VARCHAR(200) NOT NULL DEFAULT 'main', archived BOOLEAN NOT NULL DEFAULT false, forked_from_repository_id BIGINT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_owner_name (owner_user_id, name), INDEX idx_repositories_name (name), CONSTRAINT fk_repositories_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_repositories_fork FOREIGN KEY (forked_from_repository_id) REFERENCES repositories(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS repository_collaborators ( id BIGINT AUTO_INCREMENT PRIMARY KEY, repository_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role ENUM('read','write') NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uniq_repo_user (repository_id, user_id), CONSTRAINT fk_collab_repo FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, CONSTRAINT fk_collab_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS pull_requests ( id BIGINT AUTO_INCREMENT PRIMARY KEY, target_repository_id BIGINT NOT NULL, number INT NOT NULL, author_user_id BIGINT NOT NULL, source_repository_id BIGINT NOT NULL, source_branch VARCHAR(200) NOT NULL, target_branch VARCHAR(200) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, status ENUM('open','closed','merged') NOT NULL DEFAULT 'open', closed_at TIMESTAMP NULL, merged_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_target_number (target_repository_id, number), INDEX idx_pr_target (target_repository_id), INDEX idx_pr_source (source_repository_id), CONSTRAINT fk_pr_target FOREIGN KEY (target_repository_id) REFERENCES repositories(id) ON DELETE CASCADE, CONSTRAINT fk_pr_source FOREIGN KEY (source_repository_id) REFERENCES repositories(id) ON DELETE CASCADE, CONSTRAINT fk_pr_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS pull_request_comments ( id BIGINT AUTO_INCREMENT PRIMARY KEY, pull_request_id BIGINT NOT NULL, author_user_id BIGINT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_pr_comments_pr (pull_request_id), CONSTRAINT fk_pr_comments_pr FOREIGN KEY (pull_request_id) REFERENCES pull_requests(id) ON DELETE CASCADE, CONSTRAINT fk_pr_comments_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { return err } } alterations := []string{ `ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false`, `ALTER TABLE repositories ADD COLUMN description TEXT NULL`, `ALTER TABLE repositories ADD COLUMN default_branch VARCHAR(200) NOT NULL DEFAULT 'main'`, `ALTER TABLE repositories ADD COLUMN archived BOOLEAN NOT NULL DEFAULT false`, } for _, stmt := range alterations { if _, err := db.Exec(stmt); err != nil && !isDuplicateColumnError(err) { return err } } return nil }