diff --git a/package.json b/package.json index 6422962..a0f514c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pezkuwi-telegram-miniapp", - "version": "1.0.134", + "version": "1.0.137", "type": "module", "description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards", "author": "Pezkuwichain Team", diff --git a/scripts/add-bridge-announcement.mjs b/scripts/add-bridge-announcement.mjs new file mode 100644 index 0000000..d54a7ca --- /dev/null +++ b/scripts/add-bridge-announcement.mjs @@ -0,0 +1,49 @@ +import { createClient } from '@supabase/supabase-js'; + +// Load environment variables +const SUPABASE_URL = process.env.VITE_SUPABASE_URL || 'https://imxxpzevsnomqxqrbokh.supabase.co'; +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || process.env.VITE_SUPABASE_ANON_KEY; + +if (!SUPABASE_KEY) { + console.error('SUPABASE_SERVICE_KEY or VITE_SUPABASE_ANON_KEY required'); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + +const announcement = { + title: '🌉 Polkadot Bridge Tê!', + content: `🇰🇼 Kurmancî: +Xebata bridge bi Polkadot re dest pê kir! Di demeke pir nêzîk de, hûn ê bikaribin DOT'ên xwe yên li ser Pezkuwi Asset Hub veguhezînin Polkadot Asset Hub. Ev pira dê rêyek nû ya veguheztina hebûnan di navbera her du ekosistemanê de veke. Li bendê bin! + +🇮🇶 سۆرانی: +کاری پردەکان لەگەڵ پۆڵکادۆت دەستی پێکرد! لە کاتێکی زۆر نزیکدا، تۆ دەتوانیت DOTەکانت لە سەر Pezkuwi Asset Hub بگوازیتەوە بۆ Polkadot Asset Hub. ئەم پردە رێگایەکی نوێ بۆ گواستنەوەی سامانەکان لە نێوان هەردوو ئیکۆسیستەمەکەدا دەکاتەوە. چاوەڕوان بن! + +🇬🇧 English: +Bridge work with Polkadot has started! Very soon, you will be able to transfer your DOT from Pezkuwi Asset Hub to Polkadot Asset Hub. This bridge will open a new way to move assets between both ecosystems. Stay tuned!`, + is_published: true, + views: 0, + likes: 0, + dislikes: 0, +}; + +async function main() { + console.log('Adding bridge announcement...'); + + const { data, error } = await supabase + .from('tg_announcements') + .insert(announcement) + .select() + .single(); + + if (error) { + console.error('Error adding announcement:', error); + process.exit(1); + } + + console.log('Announcement added successfully!'); + console.log('ID:', data.id); + console.log('Title:', data.title); +} + +main(); diff --git a/src/hooks/useSupabase.ts b/src/hooks/useSupabase.ts index a1eb83f..fc24761 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/useSupabase.ts @@ -3,10 +3,8 @@ import { supabase } from '@/lib/supabase'; import type { DbUser, DbAnnouncementWithAuthor, - DbAnnouncementReaction, DbThreadWithAuthor, DbReplyWithAuthor, - AnnouncementCounters, ThreadCounters, ReplyCounters, } from '@/types/database'; @@ -146,9 +144,8 @@ export function useAnnouncements() { }); } -export function useAnnouncementReaction() { +export function useAnnouncementReaction(sessionToken: string | null) { const queryClient = useQueryClient(); - const { data: currentUser } = useCurrentUser(); return useMutation({ mutationFn: async ({ @@ -158,89 +155,23 @@ export function useAnnouncementReaction() { announcementId: string; reaction: 'like' | 'dislike'; }) => { - if (!currentUser) throw new Error('Not authenticated'); + if (!sessionToken) throw new Error('Not authenticated'); - // Check existing reaction - const { data: existing } = await supabase - .from('tg_announcement_reactions') - .select('*') - .eq('announcement_id', announcementId) - .eq('user_id', currentUser.id) - .single(); + // Call Edge Function for secure reaction handling + const { data, error } = await supabase.functions.invoke('announcement-reaction', { + body: { sessionToken, announcementId, reaction }, + }); - const existingReaction = existing as DbAnnouncementReaction | null; - - if (existingReaction) { - if (existingReaction.reaction === reaction) { - // Remove reaction - await supabase.from('tg_announcement_reactions').delete().eq('id', existingReaction.id); - - // Decrement counter - const { data: ann } = await supabase - .from('tg_announcements') - .select(reaction === 'like' ? 'likes' : 'dislikes') - .eq('id', announcementId) - .single(); - - const counters = ann as Partial | null; - const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0; - await supabase - .from('tg_announcements') - .update({ [reaction === 'like' ? 'likes' : 'dislikes']: Math.max(0, currentCount - 1) }) - .eq('id', announcementId); - } else { - // Change reaction - const oldReaction = existingReaction.reaction; - await supabase - .from('tg_announcement_reactions') - .update({ reaction }) - .eq('id', existingReaction.id); - - // Update counters - const { data: ann } = await supabase - .from('tg_announcements') - .select('likes, dislikes') - .eq('id', announcementId) - .single(); - - const counters = ann as AnnouncementCounters | null; - const updates: Partial = {}; - - if (oldReaction === 'like') { - updates.likes = Math.max(0, (counters?.likes ?? 0) - 1); - } else { - updates.dislikes = Math.max(0, (counters?.dislikes ?? 0) - 1); - } - if (reaction === 'like') { - updates.likes = (counters?.likes ?? 0) + (oldReaction === 'like' ? 0 : 1); - } else { - updates.dislikes = (counters?.dislikes ?? 0) + (oldReaction === 'dislike' ? 0 : 1); - } - - await supabase.from('tg_announcements').update(updates).eq('id', announcementId); - } - } else { - // Add new reaction - await supabase.from('tg_announcement_reactions').insert({ - announcement_id: announcementId, - user_id: currentUser.id, - reaction, - }); - - // Increment counter - const { data: ann } = await supabase - .from('tg_announcements') - .select(reaction === 'like' ? 'likes' : 'dislikes') - .eq('id', announcementId) - .single(); - - const counters = ann as Partial | null; - const currentCount = counters?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0; - await supabase - .from('tg_announcements') - .update({ [reaction === 'like' ? 'likes' : 'dislikes']: currentCount + 1 }) - .eq('id', announcementId); + if (error) { + console.error('[useAnnouncementReaction] Edge function error:', error); + throw new Error(error.message || 'Failed to process reaction'); } + + if (data?.error) { + throw new Error(data.error); + } + + return data; }, onMutate: async ({ announcementId, reaction }) => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) diff --git a/src/sections/Announcements.tsx b/src/sections/Announcements.tsx index a2f14af..c3efa77 100644 --- a/src/sections/Announcements.tsx +++ b/src/sections/Announcements.tsx @@ -14,10 +14,10 @@ import { useAuth } from '@/contexts/AuthContext'; export function AnnouncementsSection() { const { hapticImpact, hapticNotification, openLink } = useTelegram(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, sessionToken } = useAuth(); const { data: announcements, isLoading, refetch, isRefetching } = useAnnouncements(); - const reactionMutation = useAnnouncementReaction(); + const reactionMutation = useAnnouncementReaction(sessionToken); const handleReaction = (id: string, reaction: 'like' | 'dislike') => { if (!isAuthenticated) { diff --git a/src/version.json b/src/version.json index dbe43b7..7c82046 100644 --- a/src/version.json +++ b/src/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.134", - "buildTime": "2026-02-06T23:59:17.262Z", - "buildNumber": 1770422357263 + "version": "1.0.137", + "buildTime": "2026-02-07T00:19:16.003Z", + "buildNumber": 1770423556003 } diff --git a/supabase/functions/announcement-reaction/index.ts b/supabase/functions/announcement-reaction/index.ts new file mode 100644 index 0000000..1af8878 --- /dev/null +++ b/supabase/functions/announcement-reaction/index.ts @@ -0,0 +1,240 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { createHmac } from 'https://deno.land/std@0.177.0/node/crypto.ts'; + +// CORS - Only allow our Telegram MiniApp domain +const ALLOWED_ORIGIN = 'https://telegram.pezkuwichain.io'; + +function getCorsHeaders(): Record { + return { + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Headers': + 'authorization, x-client-info, apikey, content-type, x-supabase-client-platform', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + }; +} + +// Session token secret (must match telegram-auth function) +function getSessionSecret(botToken: string): Uint8Array { + return createHmac('sha256', 'SessionTokenSecret').update(botToken).digest(); +} + +// Verify HMAC-signed session token +function verifySessionToken(token: string, botToken: string): number | null { + try { + const parts = token.split('.'); + if (parts.length !== 2) { + return null; + } + + const [payloadB64, signature] = parts; + + // Verify signature + const secret = getSessionSecret(botToken); + const expectedSig = createHmac('sha256', secret).update(payloadB64).digest('hex'); + + if (signature !== expectedSig) { + console.error('[announcement-reaction] Invalid signature'); + return null; + } + + // Parse payload + const payload = JSON.parse(atob(payloadB64)); + + // Check expiration + if (Date.now() > payload.exp) { + console.error('[announcement-reaction] Token expired'); + return null; + } + + return payload.tgId; + } catch (e) { + console.error('[announcement-reaction] Token verification error:', e); + return null; + } +} + +serve(async (req) => { + const corsHeaders = getCorsHeaders(); + + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const body = await req.json(); + const { sessionToken, announcementId, reaction } = body; + + // Validate input + if (!sessionToken || !announcementId || !reaction) { + return new Response(JSON.stringify({ error: 'Missing required fields' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + if (reaction !== 'like' && reaction !== 'dislike') { + return new Response(JSON.stringify({ error: 'Invalid reaction type' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Get environment variables + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const botToken = Deno.env.get('TELEGRAM_BOT_TOKEN'); + + if (!botToken) { + return new Response(JSON.stringify({ error: 'Server configuration error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Verify session token + const telegramId = verifySessionToken(sessionToken, botToken); + if (!telegramId) { + return new Response(JSON.stringify({ error: 'Invalid or expired session' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Create Supabase admin client + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + // Get user by telegram_id from tg_users table + const { data: userData, error: userError } = await supabase + .from('tg_users') + .select('id') + .eq('telegram_id', telegramId) + .single(); + + if (userError || !userData) { + console.error('[announcement-reaction] User not found for tgId:', telegramId); + return new Response(JSON.stringify({ error: 'User not found' }), { + status: 404, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const userId = userData.id; + + // Check existing reaction + const { data: existing } = await supabase + .from('tg_announcement_reactions') + .select('*') + .eq('announcement_id', announcementId) + .eq('user_id', userId) + .single(); + + let resultAction = ''; + + if (existing) { + if (existing.reaction === reaction) { + // Remove reaction (toggle off) + await supabase.from('tg_announcement_reactions').delete().eq('id', existing.id); + + // Decrement counter + const { data: ann } = await supabase + .from('tg_announcements') + .select(reaction === 'like' ? 'likes' : 'dislikes') + .eq('id', announcementId) + .single(); + + const currentCount = ann?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0; + await supabase + .from('tg_announcements') + .update({ [reaction === 'like' ? 'likes' : 'dislikes']: Math.max(0, currentCount - 1) }) + .eq('id', announcementId); + + resultAction = 'removed'; + } else { + // Change reaction + const oldReaction = existing.reaction; + await supabase.from('tg_announcement_reactions').update({ reaction }).eq('id', existing.id); + + // Update counters + const { data: ann } = await supabase + .from('tg_announcements') + .select('likes, dislikes') + .eq('id', announcementId) + .single(); + + const updates: Record = {}; + if (oldReaction === 'like') { + updates.likes = Math.max(0, (ann?.likes ?? 0) - 1); + } else { + updates.dislikes = Math.max(0, (ann?.dislikes ?? 0) - 1); + } + if (reaction === 'like') { + updates.likes = (updates.likes ?? ann?.likes ?? 0) + 1; + } else { + updates.dislikes = (updates.dislikes ?? ann?.dislikes ?? 0) + 1; + } + + await supabase.from('tg_announcements').update(updates).eq('id', announcementId); + + resultAction = 'changed'; + } + } else { + // Add new reaction + await supabase.from('tg_announcement_reactions').insert({ + announcement_id: announcementId, + user_id: userId, + reaction, + }); + + // Increment counter + const { data: ann } = await supabase + .from('tg_announcements') + .select(reaction === 'like' ? 'likes' : 'dislikes') + .eq('id', announcementId) + .single(); + + const currentCount = ann?.[reaction === 'like' ? 'likes' : 'dislikes'] ?? 0; + await supabase + .from('tg_announcements') + .update({ [reaction === 'like' ? 'likes' : 'dislikes']: currentCount + 1 }) + .eq('id', announcementId); + + resultAction = 'added'; + } + + // Get updated announcement data + const { data: updatedAnn } = await supabase + .from('tg_announcements') + .select('likes, dislikes') + .eq('id', announcementId) + .single(); + + // Get user's current reaction + const { data: userReaction } = await supabase + .from('tg_announcement_reactions') + .select('reaction') + .eq('announcement_id', announcementId) + .eq('user_id', userId) + .single(); + + return new Response( + JSON.stringify({ + success: true, + action: resultAction, + likes: updatedAnn?.likes ?? 0, + dislikes: updatedAnn?.dislikes ?? 0, + user_reaction: userReaction?.reaction ?? null, + }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('[announcement-reaction] Error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/supabase/functions/telegram-auth/index.ts b/supabase/functions/telegram-auth/index.ts index 5cf712b..449ced8 100644 --- a/supabase/functions/telegram-auth/index.ts +++ b/supabase/functions/telegram-auth/index.ts @@ -163,8 +163,13 @@ serve(async (req) => { }); } - // Create Supabase admin client - const supabase = createClient(supabaseUrl, supabaseServiceKey); + // Create Supabase admin client with auth admin capabilities + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); // ======================================== // Method 1: Session token verification @@ -285,6 +290,38 @@ serve(async (req) => { // Get the full user data const { data: userData } = await supabase.from('users').select('*').eq('id', userId).single(); + // Also sync to tg_users table for forum/announcements + const { data: existingTgUser } = await supabase + .from('tg_users') + .select('id') + .eq('telegram_id', telegramUser.id) + .single(); + + if (!existingTgUser) { + // Create tg_user record with same ID as users table for consistency + await supabase.from('tg_users').insert({ + id: userId, + telegram_id: telegramUser.id, + username: telegramUser.username || null, + first_name: telegramUser.first_name, + last_name: telegramUser.last_name || null, + photo_url: telegramUser.photo_url || null, + is_admin: false, + }); + console.log('[telegram-auth] Created tg_user record for:', userId); + } else { + // Update tg_user record + await supabase + .from('tg_users') + .update({ + username: telegramUser.username || null, + first_name: telegramUser.first_name, + last_name: telegramUser.last_name || null, + photo_url: telegramUser.photo_url || null, + }) + .eq('id', existingTgUser.id); + } + return new Response( JSON.stringify({ user: userData, diff --git a/supabase/migrations/20260207_add_bridge_announcement.sql b/supabase/migrations/20260207_add_bridge_announcement.sql new file mode 100644 index 0000000..a02a817 --- /dev/null +++ b/supabase/migrations/20260207_add_bridge_announcement.sql @@ -0,0 +1,16 @@ +-- Add Polkadot Bridge announcement +-- Note: This was already applied manually on 2026-02-07 +-- author_id is pezkuwichain_admin +INSERT INTO tg_announcements (title, content, author_id, is_published, views, likes, dislikes) VALUES +('🌉 Polkadot Bridge Tê!', +'🇰🇼 Kurmancî: +Xebata bridge bi Polkadot re dest pê kir! Di demeke pir nêzîk de, hûn ê bikaribin DOT''ên xwe yên li ser Pezkuwi Asset Hub veguhezînin Polkadot Asset Hub. Ev pira dê rêyek nû ya veguheztina hebûnan di navbera her du ekosistemanê de veke. Li bendê bin! + +🇮🇶 سۆرانی: +کاری پردەکان لەگەڵ پۆڵکادۆت دەستی پێکرد! لە کاتێکی زۆر نزیکدا، تۆ دەتوانیت DOTەکانت لە سەر Pezkuwi Asset Hub بگوازیتەوە بۆ Polkadot Asset Hub. ئەم پردە رێگایەکی نوێ بۆ گواستنەوەی سامانەکان لە نێوان هەردوو ئیکۆسیستەمەکەدا دەکاتەوە. چاوەڕوان بن! + +🇬🇧 English: +Bridge work with Polkadot has started! Very soon, you will be able to transfer your DOT from Pezkuwi Asset Hub to Polkadot Asset Hub. This bridge will open a new way to move assets between both ecosystems. Stay tuned!', +'450523d5-b34d-483f-9e12-56bb69dc7f4a', +true, 0, 0, 0) +ON CONFLICT DO NOTHING;