diff --git a/.env b/.env index 5654ccd..094a439 100644 --- a/.env +++ b/.env @@ -1,3 +1,21 @@ APP_ENV=prod APP_DEBUG=0 APP_SECRET=changeme_replace_with_random_32char_string + +# Comma-separated list of allowed hostnames. Leave empty to allow all hosts. +# Example: ALLOWED_HOSTS=example.com,www.example.com +ALLOWED_HOSTS= + +# GitHub +GITHUB_USER= +GITHUB_TOKEN= + +# GitLab (leave GITLAB_URL empty to use https://gitlab.com) +GITLAB_USER= +GITLAB_TOKEN= +GITLAB_URL= + +# Gitea +GITEA_USER= +GITEA_TOKEN= +GITEA_URL= diff --git a/README.md b/README.md index 8871827..491c424 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A self-hosted Symfony service that merges contribution data from **GitHub**, **GitLab** and **Gitea** into a single GitHub-style heatmap SVG you can embed anywhere. ``` -https://your-host/graph.svg?github_user=you&github_token=ghp_…&gitea_user=you&gitea_token=…&gitea_url=https://git.example.com +https://your-host/graph.svg?theme=dark ``` ![Example graph](https://img.shields.io/badge/output-SVG%20heatmap-39d353?style=flat-square) @@ -35,14 +35,31 @@ cd git-contribution-graph cp .env .env.local ``` -Edit `.env.local`: +Edit `.env.local` with your credentials: ```dotenv -APP_ENV=prod -APP_DEBUG=0 APP_SECRET= + +# GitHub +GITHUB_USER=your-github-username +GITHUB_TOKEN=ghp_… + +# GitLab (omit GITLAB_URL to use gitlab.com) +GITLAB_USER=your-gitlab-username +GITLAB_TOKEN=glpat-… +GITLAB_URL= + +# Gitea +GITEA_USER=your-gitea-username +GITEA_TOKEN=… +GITEA_URL=https://git.example.com + +# Optional: restrict to specific hostnames (comma-separated), leave empty to allow all +ALLOWED_HOSTS= ``` +Only configure the platforms you use — unused ones are silently skipped. + ### 2. Start ```bash @@ -65,52 +82,22 @@ GET /health → {"status":"ok"} | Parameter | Required | Description | |---|---|---| -| `github_user` | ✗ | GitHub username | -| `github_token` | ✗ | GitHub PAT — scope: `read:user` | -| `gitlab_user` | ✗ | GitLab username | -| `gitlab_token` | ✗ | GitLab PAT — scope: `read_user`, `read_api` | -| `gitlab_url` | ✗ | GitLab base URL (default: `https://gitlab.com`) | -| `gitea_user` | ✗ | Gitea username | -| `gitea_token` | ✗ | Gitea PAT — scope: `read:user` | -| `gitea_url` | ✗ | Gitea instance URL, e.g. `https://git.example.com` | | `theme` | ✗ | `dark` (default) or `light` | -All platform parameters are optional — include only the ones you use. At least one platform must be configured for a non-empty graph. +All credentials are configured via environment variables — see [Deploy](#deploy). --- ## Embedding in a README -### GitHub-only - ```markdown -![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_USER&github_token=YOUR_TOKEN) + +![Contribution Graph](https://your-host/graph.svg) + + +![Contribution Graph](https://your-host/graph.svg?theme=light) ``` -### GitHub + Gitea - -```markdown -![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_GH_USER&github_token=GH_TOKEN&gitea_user=YOUR_GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de) -``` - -### All three platforms - -```markdown -![Contribution Graph](https://your-host/graph.svg?github_user=GH_USER&github_token=GH_TOKEN&gitlab_user=GL_USER&gitlab_token=GL_TOKEN&gitlab_url=https://gitlab.com&gitea_user=GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de) -``` - -### Dark vs Light theme - -```markdown - -![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=dark) - - -![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=light) -``` - -> **Security note:** API tokens embedded in public Markdown URLs are visible to anyone. Use read-only fine-grained tokens with minimal scopes. For private repos or sensitive setups, proxy the request server-side and keep tokens in environment variables. - --- ## Token setup @@ -146,7 +133,7 @@ composer install APP_ENV=dev php -S localhost:8080 -t public # Test endpoint -curl "http://localhost:8080/graph.svg?gitea_user=haylan&gitea_token=YOUR_TOKEN&gitea_url=https://git.arthurerlich.de" -o graph.svg +curl "http://localhost:8080/graph.svg" -o graph.svg ``` --- diff --git a/docker-compose.yml b/docker-compose.yml index 8c44917..7ad3f87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,15 @@ services: APP_ENV: prod APP_DEBUG: "0" APP_SECRET: "${APP_SECRET}" + ALLOWED_HOSTS: "${ALLOWED_HOSTS:-}" + GITHUB_USER: "${GITHUB_USER:-}" + GITHUB_TOKEN: "${GITHUB_TOKEN:-}" + GITLAB_USER: "${GITLAB_USER:-}" + GITLAB_TOKEN: "${GITLAB_TOKEN:-}" + GITLAB_URL: "${GITLAB_URL:-}" + GITEA_USER: "${GITEA_USER:-}" + GITEA_TOKEN: "${GITEA_TOKEN:-}" + GITEA_URL: "${GITEA_URL:-}" volumes: - cache:/app/var/cache - logs:/app/var/log diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php index 5bbbf59..abbbac5 100644 --- a/src/Controller/GraphController.php +++ b/src/Controller/GraphController.php @@ -7,6 +7,7 @@ use App\Service\GitHubProvider; use App\Service\GitLabProvider; use App\Service\SvgRenderer; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -15,6 +16,9 @@ use Symfony\Contracts\Cache\ItemInterface; class GraphController { + /** @var list */ + private readonly array $allowedHosts; + public function __construct( private readonly GitHubProvider $github, private readonly GitLabProvider $gitlab, @@ -22,76 +26,47 @@ class GraphController private readonly SvgRenderer $renderer, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, - ) {} + #[Autowire(env: 'ALLOWED_HOSTS')] + string $allowedHosts = '', + #[Autowire(env: 'GITHUB_USER')] + private readonly string $githubUser = '', + #[Autowire(env: 'GITHUB_TOKEN')] + private readonly string $githubToken = '', + #[Autowire(env: 'GITLAB_USER')] + private readonly string $gitlabUser = '', + #[Autowire(env: 'GITLAB_TOKEN')] + private readonly string $gitlabToken = '', + #[Autowire(env: 'GITLAB_URL')] + private readonly string $gitlabUrl = '', + #[Autowire(env: 'GITEA_USER')] + private readonly string $giteaUser = '', + #[Autowire(env: 'GITEA_TOKEN')] + private readonly string $giteaToken = '', + #[Autowire(env: 'GITEA_URL')] + private readonly string $giteaUrl = '', + ) { + $this->allowedHosts = array_values(array_filter(array_map('trim', explode(',', $allowedHosts)))); + } /** - * Returns a GitHub-style contribution graph SVG merging GitHub, GitLab and Gitea. - * * Query parameters: - * github_user string GitHub username - * github_token string GitHub personal access token (needs read:user scope) - * gitlab_user string GitLab username - * gitlab_token string GitLab personal access token (needs read_user scope) - * gitlab_url string GitLab base URL (default: https://gitlab.com) - * gitea_user string Gitea username - * gitea_token string Gitea personal access token - * gitea_url string Gitea instance base URL (e.g. https://git.example.com) - * theme string dark|light (default: dark) + * theme string dark|light (default: dark) */ #[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])] public function graph(Request $request): Response { - $params = $request->query->all(); - $theme = $params['theme'] ?? 'dark'; + if ($this->allowedHosts !== [] && !in_array($request->getHost(), $this->allowedHosts, true)) { + return new Response('Forbidden', 403); + } - // Use a cache key based on the query params (minus sensitive tokens — we hash them) - $cacheKey = 'graph_' . md5(serialize($params)); + $theme = $request->query->get('theme', 'dark'); - $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($params, $theme): string { - $item->expiresAfter(3600); // cache for 1 hour + $cacheKey = 'graph_' . $theme; - $contributions = []; + $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme): string { + $item->expiresAfter(3600); - if (!empty($params['github_user']) && !empty($params['github_token'])) { - try { - $contributions = $this->merge( - $contributions, - $this->github->fetch($params['github_user'], $params['github_token']) - ); - } catch (\Throwable $e) { - $this->logger->warning('GitHub fetch failed: ' . $e->getMessage()); - } - } - - if (!empty($params['gitlab_user']) && !empty($params['gitlab_token'])) { - try { - $contributions = $this->merge( - $contributions, - $this->gitlab->fetch( - $params['gitlab_user'], - $params['gitlab_token'], - $params['gitlab_url'] ?? 'https://gitlab.com' - ) - ); - } catch (\Throwable $e) { - $this->logger->warning('GitLab fetch failed: ' . $e->getMessage()); - } - } - - if (!empty($params['gitea_user']) && !empty($params['gitea_token']) && !empty($params['gitea_url'])) { - try { - $contributions = $this->merge( - $contributions, - $this->gitea->fetch( - $params['gitea_user'], - $params['gitea_token'], - rtrim($params['gitea_url'], '/') - ) - ); - } catch (\Throwable $e) { - $this->logger->warning('Gitea fetch failed: ' . $e->getMessage()); - } - } + $contributions = $this->fetchAllContributions(); return $this->renderer->render($contributions, $theme); }); @@ -108,6 +83,45 @@ class GraphController return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']); } + private function fetchAllContributions(): array + { + $contributions = []; + + if ($this->githubUser !== '' && $this->githubToken !== '') { + try { + $contributions = $this->merge($contributions, $this->github->fetch($this->githubUser, $this->githubToken)); + } catch (\Throwable $e) { + $this->logger->warning('GitHub fetch failed: ' . $e->getMessage()); + } + } + + if ($this->gitlabUser !== '' && $this->gitlabToken !== '') { + try { + $contributions = $this->merge($contributions, $this->gitlab->fetch( + $this->gitlabUser, + $this->gitlabToken, + $this->gitlabUrl !== '' ? $this->gitlabUrl : 'https://gitlab.com' + )); + } catch (\Throwable $e) { + $this->logger->warning('GitLab fetch failed: ' . $e->getMessage()); + } + } + + if ($this->giteaUser !== '' && $this->giteaToken !== '' && $this->giteaUrl !== '') { + try { + $contributions = $this->merge($contributions, $this->gitea->fetch( + $this->giteaUser, + $this->giteaToken, + rtrim($this->giteaUrl, '/') + )); + } catch (\Throwable $e) { + $this->logger->warning('Gitea fetch failed: ' . $e->getMessage()); + } + } + + return $contributions; + } + /** Sum contributions by date from two maps. */ private function merge(array $base, array $new): array {