mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-04-21 23:37:55 +00:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user