From 98b41bea7595cef9bc50aa3dda0c424714ea46c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:35:07 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E8=A1=A8=E3=83=AA=E3=83=9D=E3=82=B8?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=82=92=E6=9C=AC=E4=BA=BA=E3=81=8C=E9=81=B8?= =?UTF-8?q?=E3=81=B9=E3=82=8B=E3=83=94=E3=83=B3=E7=95=99=E3=82=81=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0(pinnedReposGo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公開プロフィールの代表リポジトリ(スター上位の自動選出)を、 本人が指定した最大6件(選択順)に置き換えられるようにする。 - 保存: pinnedReposGo(POST)。IDトークンUIDとauth_user_uidの一致を 必須にする厳格な本人確認(403)。各リポジトリはGitHub APIで 存在+owner一致を検証し、実値(stars等)を保存(詐称防止) - 表示: githubStatsGoがpinned_reposをtop_reposに反映し top_repos_source(pinned/stars)を返す。6hキャッシュとは独立 - UI: マイページのGithubStatsに「📌 選ぶ」→RepoPicker(候補は ブラウザからGitHub公開API取得、絞り込み+選択順番号+上限6、 おまかせに戻すボタン)。保存成功は即時反映、公開ページは最大1時間 - テスト: バリデーション純関数+エミュレータ統合(保存/認可403/ owner不一致/ピン反映/解除) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwkB6Hj2JggoKcy7FoxNcU --- .github/workflows/dev-deploy.yml | 4 + .github/workflows/prod-deploy.yml | 4 + app/functions-go/github_stats.go | 40 +++- app/functions-go/pinned_repos.go | 215 +++++++++++++++++++++ app/functions-go/pinned_repos_test.go | 220 ++++++++++++++++++++++ docs/backend.md | 22 +++ web/components/GithubStats.vue | 60 +++++- web/components/RepoPicker.vue | 256 ++++++++++++++++++++++++++ web/pages/dashboard/index.vue | 2 + 9 files changed, 813 insertions(+), 10 deletions(-) create mode 100644 app/functions-go/pinned_repos.go create mode 100644 app/functions-go/pinned_repos_test.go create mode 100644 web/components/RepoPicker.vue 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 />