feat: add secure announcement reactions with session token validation

- Add announcement-reaction Edge Function for secure like/dislike
- Update telegram-auth to sync users to tg_users table
- Update useAnnouncementReaction hook to use Edge Function
- Add bridge announcement script and migration
This commit is contained in:
2026-02-07 03:19:15 +03:00
parent 321081f620
commit cd5ef71505
8 changed files with 365 additions and 92 deletions
+1 -1
View File
@@ -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",
+49
View File
@@ -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();
+15 -84
View File
@@ -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<AnnouncementCounters> | 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<AnnouncementCounters> = {};
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<AnnouncementCounters> | 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)
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -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
}
@@ -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<string, string> {
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<string, number> = {};
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' },
});
}
});
+39 -2
View File
@@ -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,
@@ -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;