Add Tenor GIF integration: GIF picker in UI + server-side proxy

This commit is contained in:
Your Name 2026-07-22 02:21:29 +03:00
parent b8daea66f2
commit 872052d265
2 changed files with 207 additions and 4 deletions

View File

@ -151,6 +151,83 @@
background: #4a5a7a; background: #4a5a7a;
cursor: not-allowed; cursor: not-allowed;
} }
.input-area .gif-btn {
padding: 8px 12px;
background: transparent;
border: 1px solid #2a4a7a;
border-radius: 8px;
color: #a0c4ff;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
line-height: 1;
}
.input-area .gif-btn:hover {
background: #1a3a5a;
border-color: #e94560;
}
.gif-picker {
display: none;
background: #0d1528;
border-top: 1px solid #1a4a7a;
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
}
.gif-picker.open { display: block; }
.gif-search {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.gif-search input {
flex: 1;
padding: 8px 12px;
border: 1px solid #1a4a7a;
border-radius: 6px;
background: #121e38;
color: #e0e0e0;
font-size: 13px;
outline: none;
}
.gif-search input:focus { border-color: #e94560; }
.gif-search button {
padding: 8px 14px;
background: #e94560;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.gif-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.gif-grid img {
width: 100%;
border-radius: 6px;
cursor: pointer;
transition: transform 0.15s;
}
.gif-grid img:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(233,69,96,0.3);
}
.gif-spinner {
text-align: center;
padding: 20px;
color: #556677;
font-size: 24px;
}
.message .msg-content img.inline-gif {
max-width: 200px;
max-height: 150px;
border-radius: 6px;
display: block;
margin-top: 6px;
}
.toast { .toast {
position: fixed; position: fixed;
bottom: 30px; bottom: 30px;
@ -178,7 +255,15 @@
</div> </div>
<div class="channel-bar" id="channel-bar"></div> <div class="channel-bar" id="channel-bar"></div>
<div class="chat-messages" id="messages"></div> <div class="chat-messages" id="messages"></div>
<div class="gif-picker" id="gif-picker">
<div class="gif-search">
<input type="text" id="gif-search-input" placeholder="Search GIFs...">
<button id="gif-search-btn">Search</button>
</div>
<div class="gif-grid" id="gif-grid"></div>
</div>
<div class="input-area"> <div class="input-area">
<button class="gif-btn" id="gif-btn" title="Add GIF">GIF</button>
<input type="text" id="msg-input" placeholder="Type a message..." maxlength="2000"> <input type="text" id="msg-input" placeholder="Type a message..." maxlength="2000">
<button id="send-btn">Send</button> <button id="send-btn">Send</button>
</div> </div>
@ -194,12 +279,18 @@
let currentChannel = 'general'; let currentChannel = 'general';
let pollInterval = null; let pollInterval = null;
let selectedGifUrl = null;
const messagesEl = document.getElementById('messages'); const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('msg-input'); const inputEl = document.getElementById('msg-input');
const sendBtn = document.getElementById('send-btn'); const sendBtn = document.getElementById('send-btn');
const toastEl = document.getElementById('toast'); const toastEl = document.getElementById('toast');
const channelBar = document.getElementById('channel-bar'); const channelBar = document.getElementById('channel-bar');
const gifPicker = document.getElementById('gif-picker');
const gifBtn = document.getElementById('gif-btn');
const gifGrid = document.getElementById('gif-grid');
const gifSearchInput = document.getElementById('gif-search-input');
const gifSearchBtn = document.getElementById('gif-search-btn');
function escapeHtml(text) { function escapeHtml(text) {
const d = document.createElement('div'); const d = document.createElement('div');
@ -207,6 +298,59 @@
return d.innerHTML; return d.innerHTML;
} }
// --- Tenor GIF integration ---
async function searchGifs(query) {
gifGrid.innerHTML = '<div class="gif-spinner"></div>';
try {
const url = query
? `/api/gifs/search?q=${encodeURIComponent(query)}&limit=20`
: `/api/gifs/trending?limit=20`;
const r = await fetch(url);
const data = await r.json();
if (data.success) renderGifs(data.results || []);
else gifGrid.innerHTML = '<div class="empty-state">Failed to load GIFs</div>';
} catch (e) {
gifGrid.innerHTML = '<div class="empty-state">Network error</div>';
}
}
function renderGifs(results) {
if (!results.length) {
gifGrid.innerHTML = '<div class="empty-state">No GIFs found</div>';
return;
}
gifGrid.innerHTML = results.map(gif => {
const url = gif.media_formats?.tinygif?.url || gif.url;
return `<img src="${escapeHtml(url)}" alt="gif" data-gif-url="${escapeHtml(gif.url || url)}">`;
}).join('');
gifGrid.querySelectorAll('img').forEach(img => {
img.addEventListener('click', () => {
const gifUrl = img.dataset.gifUrl;
selectedGifUrl = gifUrl;
inputEl.placeholder = 'GIF selected! Add a caption or send...';
gifPicker.classList.remove('open');
showToast('GIF selected ✓', 'success');
});
});
}
gifBtn.addEventListener('click', () => {
const isOpen = gifPicker.classList.toggle('open');
if (isOpen) {
searchGifs('');
}
});
gifSearchBtn.addEventListener('click', () => {
const q = gifSearchInput.value.trim();
searchGifs(q || '');
});
gifSearchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') gifSearchBtn.click();
});
function showToast(msg, type = 'info') { function showToast(msg, type = 'info') {
toastEl.textContent = msg; toastEl.textContent = msg;
toastEl.className = 'toast visible ' + type; toastEl.className = 'toast visible ' + type;
@ -251,9 +395,14 @@
let html = ''; let html = '';
msgs.forEach(msg => { msgs.forEach(msg => {
const time = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const time = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
let contentHtml = escapeHtml(msg.content);
// Render inline GIFs from stored <img> tags
contentHtml = contentHtml.replace(/&lt;img class=&quot;inline-gif&quot; src=&quot;(.*?)&quot;&gt;/g, (_, url) => {
return `<img class="inline-gif" src="${escapeHtml(url)}" alt="gif">`;
});
html += `<div class="message"> html += `<div class="message">
<div class="msg-ip">🔑 ${escapeHtml(msg.ip)}</div> <div class="msg-ip">🔑 ${escapeHtml(msg.ip)}</div>
<div class="msg-content">${escapeHtml(msg.content)}</div> <div class="msg-content">${contentHtml}</div>
<div class="msg-time">${time}</div> <div class="msg-time">${time}</div>
</div>`; </div>`;
}); });
@ -263,18 +412,27 @@
async function sendMessage() { async function sendMessage() {
const text = inputEl.value.trim(); const text = inputEl.value.trim();
if (!text) return; if (!text && !selectedGifUrl) return;
let payload = text;
if (selectedGifUrl) {
payload = text
? `${text}\n<img class="inline-gif" src="${selectedGifUrl}">`
: `<img class="inline-gif" src="${selectedGifUrl}">`;
}
sendBtn.disabled = true; sendBtn.disabled = true;
try { try {
const r = await fetch('/api/messages', { const r = await fetch('/api/messages', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: currentChannel, message: text }) body: JSON.stringify({ channel: currentChannel, message: payload })
}); });
const data = await r.json(); const data = await r.json();
if (data.success) { if (data.success) {
inputEl.value = ''; inputEl.value = '';
selectedGifUrl = null;
inputEl.placeholder = 'Type a message...';
loadMessages(); loadMessages();
} else { } else {
showToast(data.error || 'Failed to send', 'error'); showToast(data.error || 'Failed to send', 'error');

View File

@ -4,6 +4,8 @@ import time
import json import json
import os import os
import re import re
import urllib.request
import urllib.parse
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@ -21,6 +23,7 @@ RATE_LIMIT = int(os.environ.get('CHAT_RATE_LIMIT', 5)) # messages per window
RATE_WINDOW = int(os.environ.get('CHAT_RATE_WINDOW', 10)) # seconds RATE_WINDOW = int(os.environ.get('CHAT_RATE_WINDOW', 10)) # seconds
MAX_MSG_LENGTH = int(os.environ.get('CHAT_MAX_MSG', 2000)) MAX_MSG_LENGTH = int(os.environ.get('CHAT_MAX_MSG', 2000))
DB_PATH = os.environ.get('CHAT_DB_PATH', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'chat.db')) DB_PATH = os.environ.get('CHAT_DB_PATH', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'chat.db'))
TENOR_API_KEY = os.environ.get('TENOR_API_KEY', '')
# --- Rate limiting (in-memory) --- # --- Rate limiting (in-memory) ---
rate_store = {} rate_store = {}
@ -163,8 +166,14 @@ def post_message():
response.status = 400 response.status = 400
return {'success': False, 'error': f'Message too long (max {MAX_MSG_LENGTH} chars)'} return {'success': False, 'error': f'Message too long (max {MAX_MSG_LENGTH} chars)'}
# XSS filter - strip HTML tags # XSS filter - strip HTML tags except allowed inline-gif
allowed_gif = r'<img class="inline-gif" src="[^"]+">'
# Save allowed GIF tags before stripping
saved_gifs = re.findall(allowed_gif, content)
content = re.sub(r'<[^>]*>', '', content) content = re.sub(r'<[^>]*>', '', content)
# Re-insert only saved GIFs at the end
if saved_gifs:
content = (content + '\n' + ''.join(saved_gifs)).strip()
ip_hash = hashlib.sha256((os.environ.get('CHAT_SALT', 'salt') + ip).encode()).hexdigest()[:16] ip_hash = hashlib.sha256((os.environ.get('CHAT_SALT', 'salt') + ip).encode()).hexdigest()[:16]
msg_id = db.add_message(channel, ip_hash, content) msg_id = db.add_message(channel, ip_hash, content)
@ -193,6 +202,42 @@ def get_messages():
response.status = 500 response.status = 500
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
@route('/api/gifs/trending', method='GET')
def gifs_trending():
if not TENOR_API_KEY:
response.status = 400
return {'success': False, 'error': 'Tenor API key not configured'}
try:
limit = request.query.get('limit', '20')
url = f'https://tenor.googleapis.com/v2/trending?key={TENOR_API_KEY}&limit={limit}&media_filter=tinygif'
with urllib.request.urlopen(url) as resp:
data = json.loads(resp.read())
response.content_type = 'application/json'
return {'success': True, 'results': data.get('results', [])}
except Exception as e:
response.status = 502
return {'success': False, 'error': str(e)}
@route('/api/gifs/search', method='GET')
def gifs_search():
if not TENOR_API_KEY:
response.status = 400
return {'success': False, 'error': 'Tenor API key not configured'}
try:
q = request.query.get('q', '')
if not q:
response.status = 400
return {'success': False, 'error': 'Missing query'}
limit = request.query.get('limit', '20')
url = f'https://tenor.googleapis.com/v2/search?key={TENOR_API_KEY}&q={urllib.parse.quote(q)}&limit={limit}&media_filter=tinygif'
with urllib.request.urlopen(url) as resp:
data = json.loads(resp.read())
response.content_type = 'application/json'
return {'success': True, 'results': data.get('results', [])}
except Exception as e:
response.status = 502
return {'success': False, 'error': str(e)}
@route('/api/messages/<message_id>', method='DELETE') @route('/api/messages/<message_id>', method='DELETE')
def delete_message(message_id): def delete_message(message_id):
# Admin token required for deletion # Admin token required for deletion