diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml
index 95361a6..30203b1 100644
--- a/.github/workflows/dev-deploy.yml
+++ b/.github/workflows/dev-deploy.yml
@@ -275,6 +275,10 @@ jobs:
deploy githubStatsGo GithubStatsGo \
--trigger-http --allow-unauthenticated --memory=256Mi --timeout=60s \
--set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET"
+ # ピン留め保存(GitHubでリポジトリを検証するため同じ資格情報を使う)
+ deploy pinnedReposGo PinnedReposGo \
+ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \
+ --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET"
deploy omikujiGo OmikujiGo \
--trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \
--set-env-vars="OMIKUJI_COOLDOWN_SECONDS=60,KUDA_BASE_URL=https://kuda.kojiran.workers.dev"
diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml
index c2b02e0..2acb9ea 100644
--- a/.github/workflows/prod-deploy.yml
+++ b/.github/workflows/prod-deploy.yml
@@ -223,6 +223,10 @@ jobs:
deploy githubStatsGo GithubStatsGo \
--trigger-http --allow-unauthenticated --memory=256Mi --timeout=60s \
--set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET"
+ # ピン留め保存(GitHubでリポジトリを検証するため同じ資格情報を使う)
+ deploy pinnedReposGo PinnedReposGo \
+ --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \
+ --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET"
deploy omikujiGo OmikujiGo \
--trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \
--set-env-vars="OMIKUJI_COOLDOWN_SECONDS=28800,KUDA_BASE_URL=https://kuda.kojiran.workers.dev"
diff --git a/app/functions-go/github_stats.go b/app/functions-go/github_stats.go
index 93b779f..0ca9be7 100644
--- a/app/functions-go/github_stats.go
+++ b/app/functions-go/github_stats.go
@@ -81,7 +81,9 @@ type githubStatsData struct {
type githubStatsResponse struct {
githubStatsData
- FetchedAt string `json:"fetched_at"`
+ // TopRepos の選出元: "pinned"(本人指定) or "stars"(スター上位の自動選出)
+ TopReposSource string `json:"top_repos_source"`
+ FetchedAt string `json:"fetched_at"`
}
func githubStatsHandler(w http.ResponseWriter, r *http.Request) {
@@ -123,9 +125,14 @@ func runGithubStats(ctx context.Context, w http.ResponseWriter, client *firestor
return nil
}
+ // 本人がピン留めしていれば top_repos をそれで置き換える(pinnedReposGoが
+ // GitHub検証済みの実データを保存している)。github_stats キャッシュとは
+ // 独立に毎リクエスト反映されるので、ピン変更に6hのTTLは掛からない。
+ pinned := readPinnedRepos(userDoc)
+
cached, fetchedAt := readGithubStatsCache(userDoc)
if cached != nil && now.Sub(fetchedAt) < githubStatsTTL {
- writeGithubStatsResponse(w, *cached, fetchedAt)
+ writeGithubStatsResponse(w, *cached, pinned, fetchedAt)
return nil
}
@@ -134,7 +141,7 @@ func runGithubStats(ctx context.Context, w http.ResponseWriter, client *firestor
// GitHub側の失敗。古いキャッシュがあればそれで凌ぐ(可用性優先)。
if cached != nil {
log.Printf("githubStats: fetch failed, serving stale cache: %v", err)
- writeGithubStatsResponse(w, *cached, fetchedAt)
+ writeGithubStatsResponse(w, *cached, pinned, fetchedAt)
return nil
}
log.Printf("githubStats: fetch failed with no cache: %v", err)
@@ -149,19 +156,42 @@ func runGithubStats(ctx context.Context, w http.ResponseWriter, client *firestor
return err
}
- writeGithubStatsResponse(w, stats, now)
+ writeGithubStatsResponse(w, stats, pinned, now)
return nil
}
-func writeGithubStatsResponse(w http.ResponseWriter, stats githubStatsData, fetchedAt time.Time) {
+func writeGithubStatsResponse(w http.ResponseWriter, stats githubStatsData, pinned []githubTopRepo, fetchedAt time.Time) {
+ source := "stars"
+ if len(pinned) > 0 {
+ stats.TopRepos = pinned
+ source = "pinned"
+ }
// 公開データ・userでキー分離。Firestore側で6hキャッシュするためCDNも長めで良い。
w.Header().Set("Cache-Control", "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400")
writeJSON(w, http.StatusOK, githubStatsResponse{
githubStatsData: stats,
+ TopReposSource: source,
FetchedAt: fetchedAt.UTC().Format(time.RFC3339),
})
}
+// readPinnedRepos はユーザードキュメントの pinned_repos を読む(無ければnil)。
+func readPinnedRepos(userDoc *firestore.DocumentSnapshot) []githubTopRepo {
+ raw, err := userDoc.DataAt("pinned_repos")
+ if err != nil || raw == nil {
+ return nil
+ }
+ b, err := json.Marshal(raw)
+ if err != nil {
+ return nil
+ }
+ var pinned []githubTopRepo
+ if err := json.Unmarshal(b, &pinned); err != nil {
+ return nil
+ }
+ return pinned
+}
+
func readGithubStatsCache(userDoc *firestore.DocumentSnapshot) (*githubStatsData, time.Time) {
v, err := userDoc.DataAt("github_stats_fetched_at")
if err != nil {
diff --git a/app/functions-go/pinned_repos.go b/app/functions-go/pinned_repos.go
new file mode 100644
index 0000000..f16501e
--- /dev/null
+++ b/app/functions-go/pinned_repos.go
@@ -0,0 +1,215 @@
+// ピン留めリポジトリ(代表リポジトリの本人指定)保存エンドポイント。
+//
+// 公開プロフィールの「GitHubの実績」内の代表リポジトリは、通常はスター上位の
+// 自動選出(github_stats.go)だが、本人がリポジトリを指定(ピン留め)できる。
+//
+// POST {github_id, repos: ["repo-name", ...]}(Bearer必須、最大6件)
+//
+// セキュリティ上のポイント:
+// - 書き込み系設定のため、IDトークンのUIDとユーザードキュメントの
+// auth_user_uid(registerGoがログイン毎に維持)の一致を必須にする
+// (他人のピンを書き換えられない)。
+// - 保存するメタデータ(スター数等)はクライアント申告ではなく、サーバーが
+// GitHub API(GET /repos/{owner}/{repo})で検証・取得した実値
+// (プロフィール上のスター数の自称詐称を防ぐ)。owner不一致は拒否。
+package gofunctions
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "regexp"
+ "time"
+
+ "cloud.google.com/go/firestore"
+ "github.com/GoogleCloudPlatform/functions-framework-go/functions"
+ "google.golang.org/grpc/codes"
+ grpcstatus "google.golang.org/grpc/status"
+)
+
+func init() {
+ functions.HTTP("PinnedReposGo", pinnedReposHandler)
+}
+
+// maxPinnedRepos はピン留めの上限。GitHub本家のピン留め(6件)に合わせる。
+const maxPinnedRepos = 6
+
+// githubRepoNameRe はGitHubのリポジトリ名として妥当な形式
+// (英数・ハイフン・アンダースコア・ドット、100文字以内)。
+var githubRepoNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`)
+
+type pinnedReposRequestBody struct {
+ GithubID string `json:"github_id"`
+ Repos []string `json:"repos"`
+}
+
+func pinnedReposHandler(w http.ResponseWriter, r *http.Request) {
+ setCORSHeaders(w, r)
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Access-Control-Allow-Methods", "POST,OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if r.Method != http.MethodPost {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "failed"})
+ return
+ }
+
+ ctx := r.Context()
+
+ var body pinnedReposRequestBody
+ if err := decodeJSONBody(r, &body); err != nil {
+ log.Printf("pinnedRepos: decodeJSONBody error: %v", err)
+ writeJSON(w, http.StatusBadRequest, map[string]string{"status": "failed"})
+ return
+ }
+ if body.GithubID == "" {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "failed parameter"})
+ return
+ }
+
+ token, ok := extractBearerToken(r)
+ if !ok {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"status": "authorization missing."})
+ return
+ }
+ authClient, err := getFirebaseAuthClient(ctx)
+ if err != nil {
+ log.Printf("pinnedRepos: getFirebaseAuthClient error: %v", err)
+ writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ decoded, err := authClient.VerifyIDToken(ctx, token)
+ if err != nil {
+ log.Printf("pinnedRepos: VerifyIDToken error: %v", err)
+ writeJSON(w, http.StatusForbidden, map[string]string{"status": "authorization missing."})
+ return
+ }
+
+ client, err := getFirestoreClient(ctx)
+ if err != nil {
+ log.Printf("pinnedRepos: getFirestoreClient error: %v", err)
+ writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+
+ if err := runPinnedRepos(ctx, w, client, body, decoded.UID); err != nil {
+ log.Printf("pinnedRepos: %v", err)
+ writeError(w, http.StatusInternalServerError, "internal error")
+ }
+}
+
+func runPinnedRepos(ctx context.Context, w http.ResponseWriter, client *firestore.Client, body pinnedReposRequestBody, authUID string) error {
+ userRef := client.Collection("users").Doc(body.GithubID)
+ userSnap, err := userRef.Get(ctx)
+ if err != nil {
+ if grpcstatus.Code(err) == codes.NotFound {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "failed", "message": "not registered"})
+ return nil
+ }
+ return err
+ }
+
+ // 本人確認: トークンUIDとドキュメントの auth_user_uid の一致を必須にする。
+ // auth_user_uid 未設定(registerGo を通っていない旧セッション)は再ログインを促す。
+ var userData struct {
+ AuthUserUID string `firestore:"auth_user_uid"`
+ ScreenName string `firestore:"screen_name"`
+ }
+ if err := userSnap.DataTo(&userData); err != nil {
+ return err
+ }
+ if userData.AuthUserUID == "" || userData.AuthUserUID != authUID {
+ writeJSON(w, http.StatusForbidden, map[string]string{"status": "forbidden"})
+ return nil
+ }
+
+ names, err := normalizePinnedRepoNames(body.Repos)
+ if err != nil {
+ writeJSON(w, http.StatusOK, map[string]interface{}{"status": "failed", "message": err.Error()})
+ return nil
+ }
+
+ // 各リポジトリをGitHubで検証し、実データでメタデータを組み立てる。
+ pinned := make([]githubTopRepo, 0, len(names))
+ for _, name := range names {
+ repo, err := fetchGithubRepo(ctx, userData.ScreenName, name)
+ if err != nil {
+ log.Printf("pinnedRepos: verify %s/%s failed: %v", userData.ScreenName, name, err)
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "status": "failed",
+ "message": fmt.Sprintf("repository not found: %s", name),
+ })
+ return nil
+ }
+ pinned = append(pinned, repo)
+ }
+
+ if _, err := userRef.Update(ctx, []firestore.Update{
+ {Path: "pinned_repos", Value: pinned},
+ {Path: "pinned_repos_updated_at", Value: time.Now()},
+ }); err != nil {
+ return err
+ }
+
+ writeJSON(w, http.StatusOK, map[string]interface{}{
+ "status": "success",
+ "pinned_repos": pinned,
+ })
+ return nil
+}
+
+// normalizePinnedRepoNames は名前の形式チェック・重複除去・件数制限を行う(純関数)。
+func normalizePinnedRepoNames(repos []string) ([]string, error) {
+ seen := map[string]bool{}
+ names := make([]string, 0, len(repos))
+ for _, name := range repos {
+ if !githubRepoNameRe.MatchString(name) {
+ return nil, fmt.Errorf("invalid repository name")
+ }
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ names = append(names, name)
+ }
+ if len(names) > maxPinnedRepos {
+ return nil, fmt.Errorf("too many repositories (max %d)", maxPinnedRepos)
+ }
+ return names, nil
+}
+
+// fetchGithubRepo は GET /repos/{owner}/{repo} で1件検証・取得する。
+// owner がログイン本人でない場合(リダイレクト等で他人のリポジトリに解決された
+// 場合を含む)はエラー。
+func fetchGithubRepo(ctx context.Context, owner, name string) (githubTopRepo, error) {
+ var repo struct {
+ Name string `json:"name"`
+ HTMLURL string `json:"html_url"`
+ Description string `json:"description"`
+ Stars int64 `json:"stargazers_count"`
+ Forks int64 `json:"forks_count"`
+ Language string `json:"language"`
+ Owner struct {
+ Login string `json:"login"`
+ } `json:"owner"`
+ }
+ reqURL := fmt.Sprintf("%s/repos/%s/%s", githubAPIBaseURL, url.PathEscape(owner), url.PathEscape(name))
+ if err := githubGetJSON(ctx, reqURL, &repo); err != nil {
+ return githubTopRepo{}, err
+ }
+ if repo.Owner.Login != owner {
+ return githubTopRepo{}, fmt.Errorf("owner mismatch: %s", repo.Owner.Login)
+ }
+ return githubTopRepo{
+ Name: repo.Name,
+ HTMLURL: repo.HTMLURL,
+ Description: repo.Description,
+ Stars: repo.Stars,
+ Forks: repo.Forks,
+ Language: repo.Language,
+ }, nil
+}
diff --git a/app/functions-go/pinned_repos_test.go b/app/functions-go/pinned_repos_test.go
new file mode 100644
index 0000000..963f183
--- /dev/null
+++ b/app/functions-go/pinned_repos_test.go
@@ -0,0 +1,220 @@
+package gofunctions
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "cloud.google.com/go/firestore"
+)
+
+func TestNormalizePinnedRepoNames(t *testing.T) {
+ // 正常系: 重複除去・順序保持
+ names, err := normalizePinnedRepoNames([]string{"repo-a", "repo.b", "repo_a", "repo-a"})
+ if err != nil || len(names) != 3 || names[0] != "repo-a" || names[2] != "repo_a" {
+ t.Errorf("normalize = %v, %v", names, err)
+ }
+
+ // 形式違反
+ for _, bad := range []string{"", "has space", "own/er", "日本語", "a
+
+
diff --git a/web/pages/dashboard/index.vue b/web/pages/dashboard/index.vue
index f6cf2a3..f3f0bf2 100644
--- a/web/pages/dashboard/index.vue
+++ b/web/pages/dashboard/index.vue
@@ -110,6 +110,8 @@
v-if="user && user.screen_name"
class="mt-4"
:screen-name="user.screen_name"
+ :github-id="user.github_id"
+ editable
/>