Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,15 @@ SUPABASE_KEY=your_supabase_key_here
# Optional: ntfy base URL for anonymous PR notifications (default: https://ntfy.sh)
# Set this to your self-hosted ntfy instance if desired
NTFY_BASE_URL=https://ntfy.sh

# Optional: public-facing service URL used in ntfy admin action buttons (default: https://gitgost.leapcell.app)
SERVICE_URL=https://gitgost.leapcell.app

# Required for panic button: password to activate/deactivate service suspension
# POST /admin/panic {"password": "...", "active": true}
# Set PANIC_PASSWORD to a strong, unique password in production — never leave it blank or use a default value.
PANIC_PASSWORD=

# Optional: ntfy topic for admin alerts (rate limit exceeded notifications)
# Example: gitgost-admin-alerts
NTFY_ADMIN_TOPIC=
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ Zero accounts • Zero tokens • Zero metadata • Designed for strong anonymit
## One-liner demo

```bash
# Add as remote → fix → push → done. Fully anonymous.
# Add as remote → fix → push → done. Designed to minimize identifiable traces.
git remote add gost https://gitgost.leapcell.app/v1/gh/torvalds/linux
git checkout -b fix-typo
git commit -am "fix: obvious typo in README"
git push gost fix-typo:main
# → PR opened as @gitgost-anonymous with zero trace to you
# → PR opened as @gitgost-anonymous with no direct trace to you; note that gitGost provides strong anonymity features, but not perfect anonymity — see the Threat Model
```

That’s it. No login. No token. No name. No email. No history.
That’s it. No login, token, name, or email required — gitGost provides strong anonymity features, but not perfect anonymity — see the [Threat Model](#threat-model).

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/livrasand/gitGost)
[![Desplegado](https://gitgost.leapcell.app/badges/deployed.svg)](https://gitgost.leapcell.app/health)
Expand Down Expand Up @@ -343,6 +343,61 @@ WSL2 has its own network stack separate from Windows, so anonymity is preserved

## Made with ❤️ for privacy

## Service Administration

### Panic button — suspend and restore the service

If abusive activity is detected (bot submissions, coordinated spam), you can suspend the service immediately. While suspended, all pushes are rejected with an explanatory message and the site shows a banner.

**Suspend the service:**

```bash
curl -X POST https://gitgost.leapcell.app/admin/panic \
-H "Content-Type: application/json" \
-d '{"password":"<PANIC_PASSWORD>","active":true}'
```

**Restore the service:**

```bash
curl -X POST https://gitgost.leapcell.app/admin/panic \
-H "Content-Type: application/json" \
-d '{"password":"<PANIC_PASSWORD>","active":false}'
```

> **Note:** If you receive a ntfy alert with action buttons (Activate Panic / Deactivate Panic), those buttons use single-use tokens valid for **10 minutes**. If the tokens expire before you tap them, use the `curl` commands above with your `PANIC_PASSWORD` — those always work.

**Handy shell aliases** (add to your `~/.zshrc` or `~/.bashrc`):

```bash
export PANIC_PASSWORD="your-password-here"

alias gitgost-suspend='curl -s -X POST https://gitgost.leapcell.app/admin/panic \
-H "Content-Type: application/json" \
-d "{\"password\":\"$PANIC_PASSWORD\",\"active\":true}"'

alias gitgost-restore='curl -s -X POST https://gitgost.leapcell.app/admin/panic \
-H "Content-Type: application/json" \
-d "{\"password\":\"$PANIC_PASSWORD\",\"active\":false}"'
```

Then simply run `gitgost-restore` to bring the service back online.

### Close abusive PRs (rollback burst)

After a burst attack, close all PRs created during the attack window:

```bash
curl -X POST https://gitgost.leapcell.app/admin/rollback \
-H "Content-Type: application/json" \
-d '{"password":"<PANIC_PASSWORD>"}'
# → {"closed": 12, "failed": 0, "closed_urls": [...]}
```

This closes up to 2 hours of recorded PRs in parallel via the GitHub API. PRs older than 2 hours are not affected.

---

Star this repo if you believe developers deserve the right to contribute anonymously.

[![Share](https://img.shields.io/badge/share-000000?logo=x&logoColor=white)](https://x.com/intent/tweet?text=Check%20out%20this%20project%20on%20GitHub:%20https://github.com/livrasand/gitGost%20%23gitGost%20%23anonymous%20%23privacy)
Expand Down
3 changes: 3 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func main() {
utils.Log("Warning: Supabase not configured, stats will not be persisted")
}

// Initialize panic button
handler.InitPanicConfig(cfg.PanicPassword, cfg.NtfyAdminTopic)

// Setup router
router := handler.SetupRouter(cfg)

Expand Down
36 changes: 20 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,31 @@ import (

// Config holds all configuration for the application
type Config struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
APIKey string
GitHubToken string
LogFormat string // "text" or "json"
SupabaseURL string
SupabaseKey string
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
APIKey string
GitHubToken string
LogFormat string // "text" or "json"
SupabaseURL string
SupabaseKey string
PanicPassword string
NtfyAdminTopic string
}

// Load reads configuration from environment variables with defaults
func Load() *Config {
cfg := &Config{
Port: getEnv("PORT", "8080"),
ReadTimeout: getDurationEnv("READ_TIMEOUT", 30*time.Second),
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 30*time.Second),
APIKey: getEnv("GITGOST_API_KEY", ""),
GitHubToken: getEnv("GITHUB_TOKEN", ""),
LogFormat: getEnv("LOG_FORMAT", "text"), // "text" or "json"
SupabaseURL: getEnv("SUPABASE_URL", ""),
SupabaseKey: getEnv("SUPABASE_KEY", ""),
Port: getEnv("PORT", "8080"),
ReadTimeout: getDurationEnv("READ_TIMEOUT", 30*time.Second),
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 30*time.Second),
APIKey: getEnv("GITGOST_API_KEY", ""),
GitHubToken: getEnv("GITHUB_TOKEN", ""),
LogFormat: getEnv("LOG_FORMAT", "text"), // "text" or "json"
SupabaseURL: getEnv("SUPABASE_URL", ""),
SupabaseKey: getEnv("SUPABASE_KEY", ""),
PanicPassword: getEnv("PANIC_PASSWORD", ""),
NtfyAdminTopic: getEnv("NTFY_ADMIN_TOPIC", ""),
}

return cfg
Expand Down
50 changes: 43 additions & 7 deletions internal/github/ntfy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,29 @@ import (

var ntfyClient = &http.Client{Timeout: 10 * time.Second}

// NtfyTopicForPR retorna el topic ntfy para un PR hash dado.
// Formato: gitgost-{hash}
// NtfyTopicForPR returns the ntfy topic for a given PR hash.
func NtfyTopicForPR(prHash string) string {
return fmt.Sprintf("gitgost-%s", prHash)
}

// NtfyBaseURL retorna la base URL de ntfy (configurable vía NTFY_BASE_URL, default ntfy.sh).
// NtfyBaseURL returns the ntfy base URL (configurable via NTFY_BASE_URL, default ntfy.sh).
func NtfyBaseURL() string {
if base := os.Getenv("NTFY_BASE_URL"); base != "" {
return base
}
return "https://ntfy.sh"
}

// PublishNtfyEvent publica un evento al topic ntfy correspondiente al PR hash.
// title: título de la notificación
// message: cuerpo del mensaje
// prHash: hash del PR (8 chars)
// NtfyServiceURL returns the public-facing service URL used in admin action buttons.
// Configurable via SERVICE_URL env var; falls back to the default deployed URL.
func NtfyServiceURL() string {
if u := os.Getenv("SERVICE_URL"); u != "" {
return u
}
return "https://gitgost.leapcell.app"
}

// PublishNtfyEvent publishes an event to the ntfy topic corresponding to a PR hash.
func PublishNtfyEvent(prHash, title, message string) error {
topic := NtfyTopicForPR(prHash)
url := fmt.Sprintf("%s/%s", NtfyBaseURL(), topic)
Expand All @@ -52,3 +57,34 @@ func PublishNtfyEvent(prHash, title, message string) error {

return nil
}

// PublishNtfyAdmin publishes an admin alert with an optional ntfy action button.
// actions: ntfy Actions header value (e.g. HTTP POST button to activate panic mode).
// Pass empty string to send without action buttons.
func PublishNtfyAdmin(topic, title, message, actions string) error {
url := fmt.Sprintf("%s/%s", NtfyBaseURL(), topic)

req, err := http.NewRequest("POST", url, bytes.NewBufferString(message))
if err != nil {
return err
}
req.Header.Set("Title", title)
req.Header.Set("Tags", "rotating_light")
req.Header.Set("Priority", "high")
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
if actions != "" {
req.Header.Set("Actions", actions)
}

resp, err := ntfyClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy admin publish failed: status %s", resp.Status)
}

return nil
}
45 changes: 45 additions & 0 deletions internal/github/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,51 @@ func ForkRepo(owner, repo string) (string, error) {
return forkOwner, nil
}

// ClosePRByURL closes an open PR given its GitHub html_url.
// The URL format is: https://github.com/{owner}/{repo}/pull/{number}
func ClosePRByURL(prURL string) error {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return fmt.Errorf("GITHUB_TOKEN not set")
}

// Parse owner, repo, number from the PR URL
// Expected: https://github.com/<owner>/<repo>/pull/<number>
parts := strings.Split(strings.TrimPrefix(prURL, "https://github.com/"), "/")
if len(parts) < 4 || parts[2] != "pull" {
return fmt.Errorf("invalid PR URL: %s", prURL)
}
owner := parts[0]
repo := parts[1]
number := parts[3]

apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%s", owner, repo, number)
payload, err := json.Marshal(map[string]string{"state": "closed"})
if err != nil {
return err
}

req, err := http.NewRequest("PATCH", apiURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "gitGost")

resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to close PR %s: status %s", prURL, resp.Status)
}
return nil
}

func CreatePR(owner, repo, branch, forkOwner, commitMessage string) (string, error) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
Expand Down
Loading