diff --git a/index.html b/index.html
index 1143a96..4e452bf 100644
--- a/index.html
+++ b/index.html
@@ -151,6 +151,83 @@
background: #4a5a7a;
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 {
position: fixed;
bottom: 30px;
@@ -178,7 +255,15 @@
+
+
@@ -194,12 +279,18 @@
let currentChannel = 'general';
let pollInterval = null;
+ let selectedGifUrl = null;
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('msg-input');
const sendBtn = document.getElementById('send-btn');
const toastEl = document.getElementById('toast');
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) {
const d = document.createElement('div');
@@ -207,6 +298,59 @@
return d.innerHTML;
}
+ // --- Tenor GIF integration ---
+ async function searchGifs(query) {
+ gifGrid.innerHTML = '⏳
';
+ 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 = 'Failed to load GIFs
';
+ } catch (e) {
+ gifGrid.innerHTML = 'Network error
';
+ }
+ }
+
+ function renderGifs(results) {
+ if (!results.length) {
+ gifGrid.innerHTML = 'No GIFs found
';
+ return;
+ }
+ gifGrid.innerHTML = results.map(gif => {
+ const url = gif.media_formats?.tinygif?.url || gif.url;
+ return `
`;
+ }).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') {
toastEl.textContent = msg;
toastEl.className = 'toast visible ' + type;
@@ -251,9 +395,14 @@
let html = '';
msgs.forEach(msg => {
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
tags
+ contentHtml = contentHtml.replace(/<img class="inline-gif" src="(.*?)">/g, (_, url) => {
+ return `
`;
+ });
html += `
🔑 ${escapeHtml(msg.ip)}
-
${escapeHtml(msg.content)}
+
${contentHtml}
${time}
`;
});
@@ -263,18 +412,27 @@
async function sendMessage() {
const text = inputEl.value.trim();
- if (!text) return;
+ if (!text && !selectedGifUrl) return;
+
+ let payload = text;
+ if (selectedGifUrl) {
+ payload = text
+ ? `${text}\n
`
+ : `
`;
+ }
sendBtn.disabled = true;
try {
const r = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ channel: currentChannel, message: text })
+ body: JSON.stringify({ channel: currentChannel, message: payload })
});
const data = await r.json();
if (data.success) {
inputEl.value = '';
+ selectedGifUrl = null;
+ inputEl.placeholder = 'Type a message...';
loadMessages();
} else {
showToast(data.error || 'Failed to send', 'error');
diff --git a/index.py b/index.py
index 0fe3068..3bbb1b6 100644
--- a/index.py
+++ b/index.py
@@ -4,6 +4,8 @@ import time
import json
import os
import re
+import urllib.request
+import urllib.parse
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
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
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'))
+TENOR_API_KEY = os.environ.get('TENOR_API_KEY', '')
# --- Rate limiting (in-memory) ---
rate_store = {}
@@ -163,8 +166,14 @@ def post_message():
response.status = 400
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'
'
+ # Save allowed GIF tags before stripping
+ saved_gifs = re.findall(allowed_gif, 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]
msg_id = db.add_message(channel, ip_hash, content)
@@ -193,6 +202,42 @@ def get_messages():
response.status = 500
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/', method='DELETE')
def delete_message(message_id):
# Admin token required for deletion