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
4 changes: 4 additions & 0 deletions .github/workflows/dev-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/prod-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 35 additions & 5 deletions app/functions-go/github_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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 {
Expand Down
215 changes: 215 additions & 0 deletions app/functions-go/pinned_repos.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading