diff --git a/app/functions/index.js b/app/functions/index.js index 128cade..9a149d4 100644 --- a/app/functions/index.js +++ b/app/functions/index.js @@ -1,1111 +1,12 @@ -const functions = require("firebase-functions") -const axios = require('axios') -var moment = require("moment") -const admin = require("firebase-admin") -const { getStorage } = require('firebase-admin/storage'); -const { getFirestore, Timestamp, FieldValue } = require("firebase-admin/firestore") -const { createCanvas, loadImage } = require("canvas") -const fs = require("fs"); -const { ChartJSNodeCanvas } = require("chartjs-node-canvas") -const cors = require("cors")({ - origin: true -}) -const { - STATUS_LOGIC_VERSION, - get_level, - user_performance, - user_formatted_performance, - raw_user_data_from_status, - latest_activity_created_at, - compute_performance_increment -} = require("./performance") - -// status_cache_is_current は保存済み status キャッシュが現行の計算ロジックで作られたものか判定する。 -// status が無い、または status_version が現行(STATUS_LOGIC_VERSION)より古い場合は -// false(=再計算が必要)。旧キャッシュには status_version フィールドが無く undefined として -// 読まれるため自動的に再計算対象になる。Go版 statusCacheIsCurrent と同一のロジック。 -function status_cache_is_current(userData) { - return !!(userData && userData.status) && userData.status_version >= STATUS_LOGIC_VERSION -} - -const projectID = process.env.GCLOUD_PROJECT -const buggetName = `${projectID}.appspot.com` - -if(process.env.FIREBASE_CONFIG){ - admin.initializeApp() -}else( - admin.initializeApp({ - credential: admin.credential.applicationDefault(), - storageBucket: buggetName - }) -) -// admin.initializeApp() - -const bucket = getStorage().bucket() -const db = getFirestore() - -const client_id = functions.config().github.client_id -const client_secret = functions.config().github.client_secret - -const fontStyle = { - font: '60px "Noto Sans JP"', - fontname: "Noto Sans JP", - fontsize: "60", - lineHeight: 100, - color: "#FFFFFF" -} -const date_now = (moment()).unix() -const date_low = moment("2022-01-01T00:00:00Z").subtract(9, 'hours').unix() -const date_max = moment("2022-01-04T00:00:00Z").subtract(9, 'hours').unix() - -function get_bonus_mag(now) { - return (date_low <= now && now < date_max) ? 3:1 -} - - -const production_id = 'd-shrine' -const dev_id = 'd-shrine-dev' - -const base_url = process.env.FUNCTIONS_EMULATOR ? `http://0.0.0.0:5000/` : functions.config().func.base_url - -const sanpai = { - add_point: 1, - next_time: projectID == 'd-shrine' ? (5 * 60) : 60 // s -} - -// Create and Deploy Your First Cloud Functions -// https://firebase.google.com/docs/functions/write-firebase-functions - -async function get_feed(user, per_page=100) { - functions.logger.info("get_feed") - try { - url = `https://api.github.com/users/${user}/events/public?per_page=${per_page}&client_id=${client_id}&client_secret=${client_secret}` - const res = await axios.get(url); - functions.logger.info([ - `GitHub X-RateLimit-Limit : ${res.headers["x-ratelimit-limit"]}`, - `GitHub X-RateLimit-Rest : ${res.headers["x-ratelimit-reset"]}`, - `GitHub X-RateLimit-Used : ${res.headers["x-ratelimit-used"]}` - ].join("¥n")) - const items = res.data; - return items - } catch (error) { - if(error.response){ - const {status,statusText} = error.response; - functions.logger.error(`Error! HTTP Status: ${status} ${statusText}`, {structuredData: true}) - functions.logger.info([ - `GitHub X-RateLimit-Limit : ${res.headers["x-ratelimit-limit"]}`, - `GitHub X-RateLimit-Rest : ${res.headers["x-ratelimit-reset"]}`, - `GitHub X-RateLimit-Used : ${res.headers["x-ratelimit-used"]}` - ].join("¥n")) - }else { - functions.logger.error(error) - } - } -} - -async function get_user(username) { - functions.logger.info("get_user") - try { - url = `https://api.github.com/users/${username}?client_id=${client_id}&client_secret=${client_secret}` - const res = await axios.get(url); - // functions.logger.info(res.headers) - functions.logger.info([ - `GitHub X-RateLimit-Limit : ${res.headers["x-ratelimit-limit"]}`, - `GitHub X-RateLimit-Rest : ${res.headers["x-ratelimit-reset"]}`, - `GitHub X-RateLimit-Used : ${res.headers["x-ratelimit-used"]}` - ].join("¥n")) - - const items = res.data; - return items - } catch (error) { - if(error.response){ - const {status,statusText} = error.response; - functions.logger.error(`Error! HTTP Status: ${status} ${statusText}`, {structuredData: true}) - functions.logger.info([ - `GitHub X-RateLimit-Limit : ${res.headers["x-ratelimit-limit"]}`, - `GitHub X-RateLimit-Rest : ${res.headers["x-ratelimit-reset"]}`, - `GitHub X-RateLimit-Used : ${res.headers["x-ratelimit-used"]}` - ].join("¥n")) - }else { - functions.logger.error(error) - } - return null - } -} - -async function get_user_doc(db, github_id, screen_name=null) { - if(process.env.FUNCTIONS_EMULATOR) { - //エミュレータの時はgithub_idがダミーなのでscreen_nameの方を見る - const snapshot = await db.collection("users").where('screen_name','==',`${screen_name}`).get() - let userDoc - if (!snapshot.empty) { - snapshot.forEach((postDoc) => { - userDoc = postDoc - }) - return userDoc - } else { - return null - } - } else { - const userRef = db.collection("users").doc(`${github_id}`) - return await userRef.get() - } -} - - -async function get_user_doc_from_username(db, screen_name) { - const snapshot = await db.collection("users").where('screen_name','==',`${screen_name}`).get() - let userDoc - if (!snapshot.empty) { - snapshot.forEach((postDoc) => { - userDoc = postDoc - }) - return userDoc - } else { - return null - } -} - - -async function get_user_ref(db, github_id, screen_name=null) { - if(process.env.FUNCTIONS_EMULATOR) { - //エミュレータの時はgithub_idがダミーだが、ここに入ってくるのは本物のgithubidなのでfirestoreからscreen_nameを使ってgithub_idを取得 - const snapshot = await db.collection("users").where('screen_name','==',`${screen_name}`).get() - let userDoc - if (!snapshot.empty) { - snapshot.forEach((postDoc) => { - userDoc = postDoc - }) - const userRef = db.collection("users").doc(`${userDoc.data().github_id}`) - return userRef - } else { - return null - } - } else { - const userRef = db.collection("users").doc(`${github_id}`) - return userRef - } -} - - -async function get_activity_list(userRef) { - const github_activityRef = userRef.collection("github_activities") - const raw_activities = await github_activityRef.get() - let raw_activities_list = [] - raw_activities.forEach((postDoc) => { - raw_activities_list.push(JSON.parse(postDoc.data().raw)) - }) - return raw_activities_list -} - - -async function get_ranking(db, screen_name) { - const rankingCache = await db.collection("cache_data").doc("ranking_cache").get() - let response = {} - let rankingData = rankingCache.data(); - response.ranking = rankingData.ranking.slice(0, 100); - response.latest_update = rankingData.latest_update; - if(screen_name){ - response.my_rank = rankingData.ranking.find((item) =>{ - return item.screen_name === screen_name - }) - } - return response -} - -exports.rankingUpdate = functions.pubsub.schedule('every 60 minutes').onRun( async (context) => { - functions.logger.info("ranking update", {structuredData: true}) - const snapshot = await db.collection("users").orderBy("status.total","desc").get() - let rankingTable = []; - snapshot.forEach((item) => { - rankingTable.push({ - display_name: item.data().display_name, - screen_name: item.data().screen_name, - image_path: item.data().image_path, - battle_point: item.data().status.total, - }); - }) - let tempRank = 1; - let tempPoint = -1; - rankingTable.sort((a, b) => b.battlePoint - a.battlePoint).forEach((item, index) => { - if(tempPoint !== item.battle_point){ - tempRank = index + 1 - tempPoint = item.battle_point - } - item.rank = tempRank - }) - const rankingRef = db.collection("cache_data").doc('ranking_cache') - rankingRef.set({ - ranking: rankingTable, - latest_update: FieldValue.serverTimestamp(), - }, {merge: true}) -}); - -exports.rankingCache = functions.pubsub.schedule('every 120 minutes').onRun( async (context) => { - functions.logger.info("ranking cache", {structuredData: true}) - const snapshot = await db.collection("users").get() - snapshot.forEach( async (item) => { - const rankingRef = db.collection("point_ranking").doc(`${item.id}`) - const rankingItem = await rankingRef.get() - if(!!rankingItem.data()){ - return; - } - await rankingRef.set({ - display_name: item.data().display_name, - screen_name: item.data().screen_name, - battle_point: item.data().status.total, - rank: 0, - }, {merge: true}) - }) -}); - -exports.statusCacheBackfill = functions.runWith({ - timeoutSeconds: 300, - memory: "512MB" -}).pubsub.schedule('every 30 minutes').onRun( async (context) => { - functions.logger.info("status cache backfill", {structuredData: true}) - const MAX_PER_RUN = 10 - // 直近半年以内に参拝(活動)があるユーザーのみ対象。休眠ユーザーは除外する。 - const active_since = Timestamp.fromDate(moment().subtract(6, "months").toDate()) - const snapshot = await db.collection("users").where("last_sanpai", ">=", active_since).get() - let processed = 0 - for (const userDoc of snapshot.docs) { - if (processed >= MAX_PER_RUN) break - const userData = userDoc.data() - // status が未計算、または status_version が古い(旧ロジックで計算された) - // キャッシュを再計算対象にする。現行バージョンのキャッシュはスキップ。 - if (status_cache_is_current(userData)) continue - - const userRef = userDoc.ref - const raw_activities_list = await get_activity_list(userRef) - let appendData = {} - if (userData.exp) { - appendData.exp = userData.exp - } - appendData.user = { - display_name: userData.display_name, - screen_name: userData.screen_name, - github_image_path: userData.image_path - } - const user_data = user_performance(raw_activities_list, userData.screen_name) - const status = user_formatted_performance(user_data, appendData) - await userRef.update({ - status: status, - status_version: STATUS_LOGIC_VERSION, - last_activity_created_at: latest_activity_created_at(raw_activities_list) - }) - processed++ - functions.logger.info(`backfilled status: ${userData.screen_name}`) - } - functions.logger.info(`status cache backfill done: ${processed} users`) -}); - -exports.status = functions.https.onRequest(async (request, response) => { - cors(request, response, async()=> { - functions.logger.info("status", {structuredData: true}) - functions.logger.info(request.query.user, {structuredData: true}) - - let appendData = {} - - const userDoc = await get_user_doc_from_username(db, request.query.user) - let userData - if(userDoc && userDoc.exists) { - // ユーザーは登録さている - functions.logger.info("user registered") - userData = userDoc.data() - functions.logger.info(`data: ${userData.exp}`) - if(userData.exp) { - appendData.exp = userData.exp - } - // ユーザー情報も付与 - appendData.user = { - display_name: userData.display_name, - screen_name: userData.screen_name, - github_image_path: userData.image_path - } - }else { - // 登録されていない - functions.logger.info("user not registered") - response.status(404).json({ - status: "failed", - message: "user not registered." - }) - return - } - - const userRef = await get_user_ref(db, userData.github_id, request.query.user) - let return_Data - // status_version が現行のキャッシュのみ即返却する。未計算または旧ロジックの - // キャッシュは下でフル再計算し、現行バージョンを刻んで書き戻す。 - if (status_cache_is_current(userData)) { - return_Data = userData.status - // last_sanpai が未設定(未参拝でプロフィールを2回以上表示した場合など)だと - // undefined.toDate() で例外になるため、未参拝時は下のフル計算パスと同じ文言を返す。 - return_Data.last_sanpai = userData.last_sanpai - ? moment(userData.last_sanpai.toDate()).format('YYYY年MM月DD日 HH:mm') - : "参拝していないようです"; - } else { - const raw_activities_list = await get_activity_list(userRef) - const user_data = user_performance(raw_activities_list, request.query.user) - return_Data = user_formatted_performance(user_data, appendData) - // last_sanpai はキャッシュ有無に関わらず last_sanpai(Timestamp)から生成する。 - // status_version 導入によりキャッシュ未計算の既存ユーザーは初回に必ずこのパスを - // 通るため、ここで固定文字列にすると参拝済みでも未参拝表示になってしまう。 - return_Data.last_sanpai = userData.last_sanpai - ? moment(userData.last_sanpai.toDate()).format('YYYY年MM月DD日 HH:mm') - : "参拝していないようです" - await userRef.update({ - status: return_Data, - status_version: STATUS_LOGIC_VERSION - }) - } - - response.json(return_Data) - }) -}) - -exports.ranking = functions.https.onRequest(async (request, response) => { - cors(request, response, async()=> { - functions.logger.info("ranking", {structuredData: true}) - let screen_name = null - if(request.query.screen_name){ - functions.logger.info(request.query.screen_name, {structuredData: true}) - screen_name = request.query.screen_name - } - const ranking = await get_ranking(db, screen_name) - let response_data = ranking - - response.json(response_data) - }) -}) - -exports.userOGP = functions.https.onRequest(async (request, response) => { - cors(request, response, async()=>{ - functions.logger.info(request.query) - if(!request.query.user){ - // 404で返す - response.status(404).send("user not found.") - return - } - - const username = request.query.user - const filepath = `ogps/${encodeURIComponent(username)}.png` - - fileExists = await isStorageExists(filepath) - functions.logger.info(`file ${filepath}: ${fileExists}`) - - let url - if(fileExists){ - // 既存 - url = getOgpUrl(username) - }else{ - // 作成 - url = await createOgp(username, request, response) - } - - if(process.env.FUNCTIONS_EMULATOR) { - // エミュレーター上は検証がめんどいからリダイレクトしない - // できればその場で画像出てくれたら良いのになぁ... - response.send(url) - }else { - if(url) { - response.redirect(url) - } - } - }) -}) - -// strageに指定ファイル名のものが存在するか -async function isStorageExists(filepath) { - data = await bucket.file(filepath).exists() - return data[0] -} - -function getOgpUrl(username) { - // ファイルチェックしてURL返したい - url = `https://firebasestorage.googleapis.com/v0/b/${buggetName}/o/ogps%2F${encodeURIComponent(username)}.png?alt=media` - if(process.env.FUNCTIONS_EMULATOR){ - url = `http://${process.env.FIREBASE_STORAGE_EMULATOR_HOST}/download/storage/v1/b/${buggetName}/o/ogps%2F${encodeURIComponent(username)}.png?alt=media` - } - - return url -} - -async function createOgp(username, request, response) { - const basePath = "base.png" - const localBasePath = "/tmp/base.png" - const targetPath = `ogps/${encodeURIComponent(username)}.png` - const localTargetPath = "/tmp/target.png" - - baseexists = await isStorageExists(basePath) - - functions.logger.info(`${basePath} is ${baseexists}`) - if(!baseexists){ - functions.logger.warn(`missing base.png!: ${baseexists}`) - response.status(500).send("server missing.") - return - } - await bucket.file(basePath).download({ - destination: localBasePath, - validation: !process.env.FUNCTIONS_EMULATOR // エミュレーター時必要 - }) - - // init image - const baseImage = await loadImage(localBasePath) - const canvas = createCanvas(baseImage.width, baseImage.height) - const ctx = canvas.getContext("2d") - ctx.drawImage(baseImage, 0, 0, baseImage.width, baseImage.height) - - const userData = await get_user(username) - if(userData == null) { - response.status(404).send("user not found.") - return - } - const imageURL = userData.avatar_url - const userDisplayName = userData.name ? userData.name : userData.login - let appendData = {} - const userDoc = await get_user_doc(db, userData.id, username) - if(userDoc && userDoc.exists) { - functions.logger.info("user registered") - const userData = userDoc.data() - functions.logger.info(`data: ${userData.exp}`) - if(userData.exp) { - appendData.exp = userData.exp - } - // ユーザー情報も付与 - appendData.user = { - display_name: userData.display_name, - screen_name: userData.screen_name, - github_image_path: userData.image_path - } - }else { - response.status(404).send("user not found.") - return - } - - const userRef = await get_user_ref(db, userData.id, username) - let userFeedData - // status_version が現行のキャッシュのみ再利用する。未計算または旧ロジックの - // キャッシュはフル再計算し、現行バージョンを刻んで書き戻す。 - if (status_cache_is_current(userData)) { - return_Data = userData.status - } else { - const raw_activities_list = await get_activity_list(userRef) - userFeedData = user_formatted_performance(user_performance(raw_activities_list, username), appendData) - await userRef.update({ - status: userFeedData, - status_version: STATUS_LOGIC_VERSION - }) - } - - // generate - ctx.font = fontStyle.font - ctx.fillStyle = fontStyle.color - ctx.textBaseline = "top" - - // 名前 - const userPos = { - x: 700, - y: 310, - max: 1280 - } - ctx.fillText(userDisplayName, userPos.x, userPos.y, (userPos.max-userPos.x)) - - // アイコン - const userIcon = await loadImage(imageURL) - functions.logger.info(`icon w: ${userIcon.width}, h:${userIcon.height}`) - const iconPos = { - x: 680, - y: 431, - range: 893-784, - iconSize: 215 // アイコンの大きさ - } - const userIconCanvas = createCanvas(userIcon.width, userIcon.height) - const userCtx = userIconCanvas.getContext("2d") - // 切り取られてないアイコンがあるので切り取り - userCtx.beginPath() - wi = userIcon.width/2 - yi = userIcon.height/2 - ri = userIcon.width/2*0.9 - rr = Math.PI*360/180 - userCtx.arc(wi, yi, ri, 0, rr, false) - userCtx.clip() - userCtx.drawImage(userIcon, 0, 0, userIcon.width, userIcon.height) - - ctx.drawImage(userIconCanvas, iconPos.x, iconPos.y, iconPos.iconSize, iconPos.iconSize) - - // レベル - const userDataStr = [ - "れべる:" + userFeedData.level, - "ポイント:" + userFeedData.points, - "せんとうりょく:" + userFeedData.total - ] - - for (let idx=0; idx < userDataStr.length; idx++) { - ctx.fillText( - userDataStr[idx], - 680, - 740 + fontStyle.lineHeight * idx - ) - } - // チャート - const chartPost = { - x: 1325, - y: 300 - } - const chartWidth = 550 - const chartHeight = 550 - const chartbackColor = "rgba(255,255,255,0)"//"rgba(0,0,0,0)" - const userChatData = [ - userFeedData.hp, - userFeedData.power, - userFeedData.intelligence, - userFeedData.defence, - userFeedData.agility, - ] - const chartLabels = [ - "たいりょく", // hp - "ちから", // power - "かしこさ", // intelligence - "しゅびりょく", // defence - "すばやさ", // agility - ] - - const chartGrafLineColor = "rgb(242,242,242)" // グラフの線,文字 - const chartconfig = { - type: "radar", - data: { - labels: chartLabels, - datasets: [ - { - // データ - data: userChatData, - fill: true, - backgroundColor: "rgba(0, 168, 228,0.6)", - borderColor: "rgb(0, 117, 159)", - borderWidth: 2 - } - ] - }, - options: { - plugins: { - title: { - // タイトル - display: false - }, - legend: { - // 凡例 - display: false, - fontSize: 30 - }, - }, - - scale: { - ticks: { - // 線の間隔 - stepSize: 10, - } - }, - elements: { - point: { - radius: 0 // 点は非表示 - } - }, - scales: { - r: { - min: 0, - max: 150, - grid: { - // メモリ - display: true, - color: chartGrafLineColor, - lineWidth: 3, // (データ幅)線の幅 - }, - angleLines: { - // 伸びてる方のめもり - color: chartGrafLineColor, - lineWidth: 3 - }, - pointLabels: { - // れべるとか - color: chartGrafLineColor, - font: { - size: 25 - } - }, - ticks: { - // メモリの数字 - display: false, - } - } - } - } - } - - const chartJSNodeCanvas = new ChartJSNodeCanvas({ - width: chartWidth, - height: chartHeight, - chartCallback: (ChartJS) => { - // ChartJS.defaults.global.font.size = "rgb(255,255,255)" - } - }) - const chart = await chartJSNodeCanvas.renderToBuffer(chartconfig, "image/png") - const chartfile = "/tmp/chart.png" - fs.writeFileSync(chartfile, chart) - const chartimage = await loadImage(chartfile) - // ctx.drawImage(chartimage, 0, 0, chartimage.width, chartimage.height) - ctx.drawImage(chartimage, chartPost.x,chartPost.y, chartimage.width, chartimage.height) - - // // upload - const buf = canvas.toBuffer() - fs.writeFileSync(localTargetPath, buf) - - await bucket.upload(localTargetPath, { - destination: targetPath - }) - - fs.unlinkSync(localBasePath) - fs.unlinkSync(localTargetPath) - - return getOgpUrl(username) -} - -exports.register = functions.https.onRequest(async (request, response)=>{ - cors(request, response, async()=>{ - functions.logger.info(request.method) - functions.logger.info(request.body) - if(request.method != "POST"){ - response.status(400).json({ - status: "missing request" - }) - return - } - - if(!request.headers.authorization) { - // 認証情報付与してない - response.status(401).json({ - status: "authorization missing." - }) - return - } - const token_match = request.headers.authorization.match(/^Bearer (.*)$/) - if(!token_match) { - // やっぱり認証情報付与してない - response.status(401).json({ - status: "authorization missing." - }) - return - } - const token = token_match[1] // firebase auth token - - // トークンを検証 - const decodetToken = await admin.auth().verifyIdToken(token) - .catch(e => { - // 認証できない - functions.logger.error(e) - response.status(403).json({ - status: "authorization missing." - }) - return - }) - if(!decodetToken){ - functions.logger.info("decodetToken non") - return - } - - // firestore に投げられたデータを保存 - // { - // github_id, display_name, screen_name, image_path - // } - // firestoreに書き込み - // key: github_id - if( - !request.body.github_id || - !request.body.display_name || - !request.body.screen_name || - !request.body.image_path - ){ - // functions.logger.info(request.body) - response.json({ - status: "failed parameter" - }) - return - } - - const userRef = db.collection("users").doc(`${request.body.github_id}`) - const userDoc = await get_user_doc(db, request.body.github_id, request.body.screen_name) - if(!userDoc || !userDoc.exists) { - await userRef.set({ - github_id: request.body.github_id, - display_name: request.body.display_name, - screen_name: request.body.screen_name, - image_path: request.body.image_path, - create_at: FieldValue.serverTimestamp(), - exp: 10, - auth_user_uid: decodetToken.uid - }) - response.json({ - status: "success" - }) - return - }else { - const userData = userDoc.data() - if(!userData.auth_user_uid) { - await userRef.update({ - auth_user_uid: decodetToken.uid - }) - response.json({ - status: "updated", - message: "auth_user_uid" - }) - return - }else { - response.json({ - status: "registered" - }) - return - } - } - }) -}) - -const bonus_branchs = [ - "428lab/.*", - "nostr-jp/.*", - "penpenpng/rx-nostr", - "akiomik/nosvelte", - // "template/.*", -] - -exports.sanpai = functions.https.onRequest(async(request, response) => { - cors(request, response, async()=>{ - if(request.method != "POST") { - functions.logger.info("failed connection") - response.json({ - status: "failed" - }) - return - } - - if(!request.headers.authorization) { - // 認証情報付与してない - response.status(401).json({ - status: "authorization missing." - }) - return - } - const token_match = request.headers.authorization.match(/^Bearer (.*)$/) - if(!token_match) { - // やっぱり認証情報付与してない - response.status(401).json({ - status: "authorization missing." - }) - return - } - const token = token_match[1] // firebase auth token - - // トークンを検証 - const decodetToken = await admin.auth().verifyIdToken(token) - .catch(e => { - // 認証できない - functions.logger.error(e) - response.status(403).json({ - status: "authorization missing." - }) - return - }) - if(!decodetToken){ - functions.logger.info("decodetToken non") - return - } - if(!request.body.github_id || !request.body.screen_name) { - response.json({ - status: "failed parameter" - }) - return - } - const github_id = request.body.github_id - const userRef = db.collection("users").doc(`${github_id}`) - - functions.logger.info("load") - try { - - functions.logger.info("get 1") - const userDoc = await get_user_doc(db, github_id, request.body.screen_name) - functions.logger.info("get 2" ) - - if(!userDoc || !userDoc.exists) { - // 登録されてない - response.json({ - "status": "failed", - "message": "not registered" - }) - return - } - functions.logger.info("registered") - let userStatusFeed = null - let userStatusData = null - let userAppendData = {} - let add_exp = sanpai.add_point // 最終的に得られるポイント - - const userData = userDoc.data() - functions.logger.info(userData) - if(userData.exp) { - userAppendData.exp = userData.exp - } - const last_sanpai = userData.last_sanpai - - if(last_sanpai) { - //参拝してる - - functions.logger.info(last_sanpai) - functions.logger.info(last_sanpai.seconds) - // 前回の時間指定時間足して、期限がすぎる時間 今の時間 - if(last_sanpai.seconds + sanpai.next_time > Timestamp.now().seconds) { - // 参拝可能時間を過ぎてない - // functions.logger.info("expire") - response.json({ - status: "expire", - add_exp: 0 - }) - return - } - } - - // アクティビティ取得 - userStatusFeed = await get_feed(userData.screen_name) - const feed_items = userStatusFeed - date = last_sanpai ? last_sanpai.seconds: moment("2008-04-01T00:00:00Z").unix() // github - let splited_items = feed_items.filter(item => (moment(item.created_at).unix()) > date) //前回の参拝からのアクティビティ(初回は取れるだけ) - functions.logger.info(`activities: ${splited_items.length}`) - - add_exp += Math.floor(splited_items.length/5) // 取得できたアクティビティ5件につき1件 - - // ボーナスブランチに変更があった場合は1件を1ポイントとする - add_exp += splited_items.filter(item => bonus_branchs.some(master => new RegExp('^' + master + '$').test(item.repo.name))).length; - - if(splited_items.length == 0) { - // なんかアクションしてこい - functions.logger.info("user not actions") - response.json({ - status: "noaction", - add_exp: 0 - }) - return - } - - // アクティビティ反映 - const dbBatch = db.batch() - const github_activityRef = userRef.collection("github_activities") - for(i=0;i1) { - //2022年の三が日はポイント3倍 - add_exp *= bonus_mag - msg = "2022/1/1〜2022/1/3はポイント3倍!" - } - - // 参拝可能時間のロックのため last_sanpai を先に確定させる - // (exp/status は計算後の下の update でまとめて反映する) - await userRef.update({ - last_sanpai: FieldValue.serverTimestamp() - }) - const sanpai_logsRef = userRef.collection("sanpai_logs") - const sanpaiRes = await sanpai_logsRef.add({ - add_point: add_exp, - timestamp: FieldValue.serverTimestamp() - }) - // 最新状態を取得 - if(userData.exp) { - userAppendData.exp = userData.exp + add_exp - }else { - userAppendData.exp = add_exp - } - // ユーザー情報も付与 - userAppendData.user = { - display_name: userData.display_name, - screen_name: userData.screen_name, - github_image_path: userData.image_path - } - - let raw_user_data - let last_activity_created_at - if (status_cache_is_current(userData) && userData.last_activity_created_at) { - // 保存済みステータスに新着分だけを加算(全件再集計しない)。 - // splited_items は「created_at > last_sanpai」で抽出した未集計イベントのみ、 - // last_activity_created_at は累積済みイベントの最大時刻であり、 - // compute_performance_increment の不変条件(新着は累積分より後)を満たす。 - // - // status_version が古いキャッシュ(旧ロジックで計算)を基準に増分すると - // 過去分の誤りを現行バージョンとして固定化してしまうため、その場合は - // この分岐に入らず下の全件再計算パスに落として基準ごと作り直す。 - const base_user_data = raw_user_data_from_status(userData.status, userData.screen_name) - const increment = compute_performance_increment(base_user_data, splited_items, userData.last_activity_created_at) - raw_user_data = increment.user_data - last_activity_created_at = increment.last_created_at - } else { - // 初回(status未保存)は全アクティビティから計算し、増分計算の基準を初期化する - const raw_activities_list = await get_activity_list(userRef) - raw_user_data = user_performance(raw_activities_list, userData.screen_name) - last_activity_created_at = latest_activity_created_at(raw_activities_list) - } - - userStatusData = user_formatted_performance(raw_user_data, userAppendData) - - await userRef.update({ - last_sanpai: FieldValue.serverTimestamp(), - exp: FieldValue.increment(add_exp), - status: userStatusData, - status_version: STATUS_LOGIC_VERSION, - last_activity_created_at: last_activity_created_at - }) - - // 参拝による変化(before/after)を算出してフロントの変化表示に渡す - const power_before = userData.status ? userData.status.total : 0 - const points_before = userData.exp ? userData.exp : 0 - const level_before = get_level(power_before) - // 今回の参拝で更新したリポジトリ数とアクション数。 - // アクション数は今回取得した新着アクティビティの総件数(ポイント算出の元と同じ)。 - // GitHub Events API は 2025-10-07 に PushEvent payload から commits/size を - // 削除しコミット数が取得できないため、event 種別を問わない件数を用いる。 - const updated_repo_count = new Set( - splited_items.map(item => item.repo && item.repo.name).filter(Boolean) - ).size - const action_count = splited_items.length - - let return_data = { - status: "success", - add_exp: add_exp, - level: userStatusData.level, - exp: userStatusData.points, - next_exp: userStatusData.next_exp, - msg: msg, - points_before: points_before, - points_after: userStatusData.points, - power_before: power_before, - power_after: userStatusData.total, - level_before: level_before, - level_after: userStatusData.level, - updated_repo_count: updated_repo_count, - action_count: action_count - } - if(splited_items.length == 0) { - // アクティビティがないっぽい - return_data.status = "noaction" - } - response.json(return_data) - }catch(e) { - functions.logger.error("transaction failure", e) - response.json({ - status: "missing server error." - }) - return - } - }) -}) - -exports.scheduledOgpDelete = functions.pubsub - .schedule("0 */1 * * *") - .timeZone("Asia/Tokyo") - .onRun((context) => { - bucket.deleteFiles({ - prefix: `ogps/` - }) - }) - -exports.ogpRewrite = functions.https.onRequest(async (request, response) => { - // ogp用HTMLに書き換える - const req_path = request.url - functions.logger.info(`request url: ${req_path}`) - const user_match = req_path.match("/u/(.+)") - if(!user_match && user_match.length < 1) { - functions.logger.info(`mismatch query: ${req_path}`) - response.status(404).send("not found") - return - } - const username = user_match[1] - - // let url - // if(process.env.FUNCTIONS_EMULATOR) { - // url = `http://0.0.0.0:5000/` // firebase emulators - // }else { - // url = `https://${projectID}.web.app/` - // } - const time = moment().unix() - - const ogpURL = `https://us-central1-${projectID}.cloudfunctions.net/userOGP?user=${username}&t=${time}` - const description = `これが${username}の でばっぐのうりょくだ!` - const title = `${username}の でばっぐのうりょく - でばっぐ神社` - - try { - functions.logger.info(`username: ${username} base_url:${base_url}`) - const res = await axios.get(base_url) - let data = res.data - - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - // - // ? - - // - data = data.replace( - ``, - `` - ) - // - data = data.replace( - ``, - `` - ) - - functions.logger.info("rewrite data") - response.set('Cache-Control', 'public, max-age=300, s-maxage=300') - response.send(data) - }catch (error) { - if(error.response) { - const {status,statusText} = error.response; - functions.logger.error(`Error! HTTP Status: ${status} ${statusText}`, {structuredData: true}) - } - response.status(404).send("failed") - } -}) +// でばっぐ神社の Cloud Functions は functions-go (Go / gen2 Cloud Run) へ全面移植済み。 +// +// 旧 Node 実装(status / sanpai / register / ranking / userOGP / ogpRewrite と、 +// スケジュール4本 rankingUpdate / rankingCache / statusCacheBackfill / scheduledOgpDelete)は +// すべて Go 版(statusGo など)へ置き換わり、フロントエンドの $axios 呼び出しおよび +// firebase.json の hosting rewrite(/u/* -> ogpRewriteGo)も Go 版のみを参照する。 +// +// この codebase は関数を一切 export しないため、`firebase deploy` 実行時に +// 既存の旧 Node 関数は prune(削除)される。Go 版(gcloud で個別デプロイした gen2 関数)は +// firebase の管理対象外のため prune されない。 +// +// 旧 Node 実装のソースは git 履歴に保全されており、必要になれば復元できる。 diff --git a/docs/backend.md b/docs/backend.md index db3077e..a51426a 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -98,6 +98,19 @@ Issue 活動のあるユーザーは intelligence/defence/total(戦闘力・ラ `status`・`status_version`・`last_activity_created_at` を追記更新する(既存データは削除しない) - 冪等。全ユーザーが現行バージョンで埋まった後はスキップのみで何もしない +## Node版 Cloud Functions の撤去(Go全面移行の完了) + +すべてのエンドポイント/スケジュール関数が Go(gen2 Cloud Run functions)へ移植され、 +フロントエンドの `$axios` 呼び出しと `firebase.json` の hosting rewrite(`/u/*` → `ogpRewriteGo`)も +Go 版のみを参照するようになったため、**旧 Node 実装(`app/functions/index.js`)を撤去した**。 + +- `app/functions/index.js` は関数を一切 export しない(移植済みを示すコメントのみ)。 + これにより `firebase deploy` 実行時、既存の旧 Node 関数(status/sanpai/register/ranking/ + userOGP/ogpRewrite とスケジュール4本)は prune(削除)される。 +- Go 版(gcloud で個別デプロイした gen2 関数)は firebase の管理対象外のため prune されない。 +- 旧 Node 実装のソースは git 履歴に保全されており、必要になれば復元できる。 +- 撤去は「dev で prune → 動作確認 → 本番」の順で反映する。 + ## `status` エンドポイントのGo移植 (`statusGo`) マイページ表示のレイテンシーをさらに削減するため、`status` エンドポイントを