Coverage for integrations / social / api_sharing.py: 27.8%
295 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Sharing API Blueprint
3Short URL token management, OG metadata resolution, view tracking, consent-gated private sharing.
4"""
5import hashlib
6import json
7import logging
8import secrets
9from datetime import datetime
11from flask import Blueprint, request, jsonify, g
12from sqlalchemy import func
14from .auth import require_auth, optional_auth
15from .models import (
16 get_db, User, Post, Comment, Community, ShareableLink, ShareEvent,
17)
19logger = logging.getLogger('hevolve_social')
21sharing_bp = Blueprint('sharing', __name__, url_prefix='/api/social')
24def _ok(data=None, meta=None, status=200):
25 r = {'success': True}
26 if data is not None:
27 r['data'] = data
28 if meta is not None:
29 r['meta'] = meta
30 return jsonify(r), status
33def _err(msg, status=400):
34 return jsonify({'success': False, 'error': msg}), status
37def _get_json():
38 return request.get_json(force=True, silent=True) or {}
41def _generate_token(length=8):
42 """Generate a URL-safe short token."""
43 return secrets.token_urlsafe(length)[:length]
46def _get_og_metadata(db, resource_type, resource_id):
47 """Fetch OG metadata for a resource. Returns dict with title, description, image, type."""
48 og = {
49 'title': 'Nunba',
50 'description': 'A community-driven social network for humans and AI agents.',
51 'image': '',
52 'type': 'website',
53 }
55 if resource_type == 'post':
56 post = db.query(Post).filter_by(id=resource_id).first()
57 if post:
58 content = post.content or ''
59 og['title'] = content[:60].strip() or 'Thought Experiment'
60 og['description'] = content[:200].strip()
61 og['type'] = 'article'
62 if post.media_urls:
63 urls = post.media_urls if isinstance(post.media_urls, list) else [post.media_urls]
64 if urls:
65 og['image'] = urls[0]
67 elif resource_type == 'comment':
68 comment = db.query(Comment).filter_by(id=resource_id).first()
69 if comment:
70 content = comment.content or ''
71 og['title'] = f'Comment: {content[:50].strip()}'
72 og['description'] = content[:200].strip()
73 og['type'] = 'article'
75 elif resource_type == 'profile':
76 user = db.query(User).filter_by(id=resource_id).first()
77 if user:
78 og['title'] = f'{user.display_name or user.username}'
79 og['description'] = f'{user.bio or "Member of Nunba community"}'[:200]
80 og['type'] = 'profile'
81 if user.avatar_url:
82 og['image'] = user.avatar_url
84 elif resource_type == 'community':
85 comm = db.query(Community).filter_by(id=resource_id).first()
86 if comm:
87 og['title'] = f'h/{comm.name}'
88 og['description'] = (comm.description or f'Join the {comm.name} community')[:200]
89 if comm.banner_url:
90 og['image'] = comm.banner_url
92 elif resource_type in ('agent', 'recipe', 'game', 'kids_game'):
93 og['title'] = f'{resource_type.replace("_", " ").title()}'
94 og['description'] = f'Check out this {resource_type.replace("_", " ")} on Nunba'
96 return og
99def _resource_route(resource_type, resource_id):
100 """Map resource type to its SPA route."""
101 routes = {
102 'post': f'/social/post/{resource_id}',
103 'comment': f'/social/post/{resource_id}',
104 'profile': f'/social/profile/{resource_id}',
105 'community': f'/social/h/{resource_id}',
106 'agent': f'/social/agents/{resource_id}',
107 'recipe': f'/social/recipes/{resource_id}',
108 'game': f'/social/games/{resource_id}',
109 'kids_game': f'/social/kids/game/{resource_id}',
110 'challenge': f'/social/challenges/{resource_id}',
111 'media': f'/api/media/asset?id={resource_id}',
112 }
113 return routes.get(resource_type, f'/social')
116# ═══════════════════════════════════════════════════════════════
117# CREATE / GET SHARE LINK
118# ═══════════════════════════════════════════════════════════════
120@sharing_bp.route('/share/link', methods=['POST'])
121@require_auth
122def create_share_link():
123 """Create or retrieve a shareable short link for a resource."""
124 db = get_db()
125 try:
126 data = _get_json()
127 resource_type = data.get('resource_type', '').strip()
128 resource_id = str(data.get('resource_id', '')).strip()
129 is_private = bool(data.get('is_private', False))
131 if not resource_type or not resource_id:
132 return _err("resource_type and resource_id required")
134 # DLP scan outbound content (best-effort)
135 try:
136 from security.dlp_engine import get_dlp_engine
137 dlp = get_dlp_engine()
138 content_to_check = data.get('title', '') + ' ' + data.get('description', '')
139 allowed, reason = dlp.check_outbound(content_to_check)
140 if not allowed:
141 return _err("Content blocked by DLP policy: contains sensitive data", 403)
142 except (ImportError, Exception):
143 pass
145 valid_types = ('post', 'comment', 'profile', 'community', 'agent',
146 'recipe', 'game', 'kids_game', 'challenge', 'chat', 'media')
147 if resource_type not in valid_types:
148 return _err(f"Invalid resource_type. Must be one of: {', '.join(valid_types)}")
150 # Check for existing canonical link (one per user per resource)
151 existing = db.query(ShareableLink).filter_by(
152 resource_type=resource_type,
153 resource_id=resource_id,
154 created_by=g.user_id,
155 ).first()
157 if existing:
158 og = json.loads(existing.metadata_json) if existing.metadata_json else {}
159 existing.share_count = (existing.share_count or 0) + 1
160 db.commit()
161 return _ok({
162 'token': existing.token,
163 'url': f'/s/{existing.token}',
164 'og': og,
165 'view_count': existing.view_count,
166 'share_count': existing.share_count,
167 'is_private': existing.is_private,
168 })
170 # Get user's referral code
171 user = db.query(User).filter_by(id=g.user_id).first()
172 referral_code = getattr(user, 'referral_code', None) or ''
174 # Generate OG metadata
175 og = _get_og_metadata(db, resource_type, resource_id)
177 # Generate consent token for private links
178 consent_token = secrets.token_urlsafe(24) if is_private else None
180 # Create new link
181 token = _generate_token()
182 # Ensure uniqueness (retry on collision)
183 for _ in range(5):
184 if not db.query(ShareableLink).filter_by(token=token).first():
185 break
186 token = _generate_token()
188 link = ShareableLink(
189 token=token,
190 resource_type=resource_type,
191 resource_id=resource_id,
192 created_by=g.user_id,
193 referral_code=referral_code,
194 is_private=is_private,
195 consent_token=consent_token,
196 metadata_json=json.dumps(og),
197 )
198 db.add(link)
200 # Log share event
201 ip_raw = request.remote_addr or ''
202 ip_hash = hashlib.sha256(ip_raw.encode()).hexdigest()[:16]
203 event = ShareEvent(
204 link_id=link.id,
205 event_type='share',
206 viewer_id=g.user_id,
207 ip_hash=ip_hash,
208 )
209 db.add(event)
211 # Award Resonance for sharing
212 try:
213 from .resonance_engine import ResonanceService
214 ResonanceService.award(db, g.user_id, 'content_shared', 5,
215 reason=f'Shared {resource_type} {resource_id}')
216 except Exception as e:
217 logger.debug(f"Resonance award for share failed: {e}")
219 db.commit()
221 return _ok({
222 'token': token,
223 'url': f'/s/{token}',
224 'og': og,
225 'view_count': 0,
226 'share_count': 1,
227 'is_private': is_private,
228 }, status=201)
229 finally:
230 db.close()
233# ═══════════════════════════════════════════════════════════════
234# RESOLVE SHARE TOKEN
235# ═══════════════════════════════════════════════════════════════
237@sharing_bp.route('/share/<token>', methods=['GET'])
238@optional_auth
239def resolve_share_token(token):
240 """Resolve a share token to resource metadata (OG data + redirect info)."""
241 db = get_db()
242 try:
243 link = db.query(ShareableLink).filter_by(token=token).first()
244 if not link:
245 return _err("Share link not found", 404)
247 # Check expiry
248 if link.expires_at and link.expires_at < datetime.utcnow():
249 return _err("Share link has expired", 410)
251 og = json.loads(link.metadata_json) if link.metadata_json else {}
252 redirect_url = _resource_route(link.resource_type, link.resource_id)
254 # Add referral code to redirect
255 if link.referral_code:
256 separator = '&' if '?' in redirect_url else '?'
257 redirect_url = f'{redirect_url}{separator}ref={link.referral_code}'
259 result = {
260 'token': link.token,
261 'resource_type': link.resource_type,
262 'resource_id': link.resource_id,
263 'og': og,
264 'redirect_url': redirect_url,
265 'is_private': link.is_private,
266 'requires_consent': link.is_private,
267 'view_count': link.view_count,
268 'share_count': link.share_count,
269 }
271 if link.is_private:
272 # Don't include full OG data for private links until consent
273 result['og'] = {
274 'title': 'Private content shared with you',
275 'description': 'You need to grant consent to view this content.',
276 'image': '',
277 'type': 'website',
278 }
279 # Include sharer info
280 if link.created_by:
281 creator = db.query(User).filter_by(id=link.created_by).first()
282 if creator:
283 result['shared_by'] = {
284 'username': creator.username,
285 'display_name': creator.display_name,
286 'avatar_url': creator.avatar_url,
287 }
289 return _ok(result)
290 finally:
291 db.close()
294# ═══════════════════════════════════════════════════════════════
295# VIEW TRACKING
296# ═══════════════════════════════════════════════════════════════
298@sharing_bp.route('/share/<token>/view', methods=['POST'])
299@optional_auth
300def track_share_view(token):
301 """Increment view count for a share link (fire-and-forget)."""
302 db = get_db()
303 try:
304 link = db.query(ShareableLink).filter_by(token=token).first()
305 if not link:
306 return _err("Share link not found", 404)
308 link.view_count = (link.view_count or 0) + 1
310 # Log view event
311 ip_raw = request.remote_addr or ''
312 ip_hash = hashlib.sha256(ip_raw.encode()).hexdigest()[:16]
313 viewer_id = getattr(g, 'user_id', None)
314 event = ShareEvent(
315 link_id=link.id,
316 event_type='view',
317 viewer_id=viewer_id,
318 ip_hash=ip_hash,
319 )
320 db.add(event)
322 # Check viral milestones
323 view_count = link.view_count
324 if link.created_by:
325 try:
326 from .resonance_engine import ResonanceService
327 if view_count == 10:
328 ResonanceService.award(db, link.created_by, 'content_viral_10', 25,
329 reason=f'Share link reached 10 views')
330 elif view_count == 50:
331 ResonanceService.award(db, link.created_by, 'content_viral_50', 100,
332 reason=f'Share link reached 50 views')
333 except Exception as e:
334 logger.debug(f"Resonance viral award failed: {e}")
336 db.commit()
337 return _ok({'view_count': link.view_count})
338 finally:
339 db.close()
342# ═══════════════════════════════════════════════════════════════
343# CONSENT (Private Sharing)
344# ═══════════════════════════════════════════════════════════════
346@sharing_bp.route('/share/<token>/check-consent', methods=['GET'])
347@optional_auth
348def check_consent(token):
349 """Check if a private share link requires consent."""
350 db = get_db()
351 try:
352 link = db.query(ShareableLink).filter_by(token=token).first()
353 if not link:
354 return _err("Share link not found", 404)
356 if not link.is_private:
357 return _ok({'requires_consent': False, 'is_private': False})
359 # Check if user already consented
360 viewer_id = getattr(g, 'user_id', None)
361 already_consented = False
362 if viewer_id:
363 already_consented = db.query(ShareEvent).filter_by(
364 link_id=link.id,
365 event_type='consent',
366 viewer_id=viewer_id,
367 ).first() is not None
369 # Sharer info
370 shared_by = None
371 if link.created_by:
372 creator = db.query(User).filter_by(id=link.created_by).first()
373 if creator:
374 shared_by = {
375 'username': creator.username,
376 'display_name': creator.display_name,
377 }
379 return _ok({
380 'requires_consent': not already_consented,
381 'is_private': True,
382 'already_consented': already_consented,
383 'resource_type': link.resource_type,
384 'shared_by': shared_by,
385 })
386 finally:
387 db.close()
390@sharing_bp.route('/share/<token>/consent', methods=['POST'])
391@require_auth
392def grant_consent(token):
393 """Grant consent to view a private share link."""
394 db = get_db()
395 try:
396 link = db.query(ShareableLink).filter_by(token=token).first()
397 if not link:
398 return _err("Share link not found", 404)
400 if not link.is_private:
401 return _err("This link is not private")
403 # Log consent event
404 ip_raw = request.remote_addr or ''
405 ip_hash = hashlib.sha256(ip_raw.encode()).hexdigest()[:16]
406 event = ShareEvent(
407 link_id=link.id,
408 event_type='consent',
409 viewer_id=g.user_id,
410 ip_hash=ip_hash,
411 )
412 db.add(event)
413 db.commit()
415 # Return full OG data and redirect
416 og = json.loads(link.metadata_json) if link.metadata_json else {}
417 redirect_url = _resource_route(link.resource_type, link.resource_id)
418 if link.referral_code:
419 separator = '&' if '?' in redirect_url else '?'
420 redirect_url = f'{redirect_url}{separator}ref={link.referral_code}'
422 return _ok({
423 'og': og,
424 'redirect_url': redirect_url,
425 'resource_type': link.resource_type,
426 'resource_id': link.resource_id,
427 })
428 finally:
429 db.close()
432# ═══════════════════════════════════════════════════════════════
433# OG IMAGE (dynamic per-resource)
434# ═══════════════════════════════════════════════════════════════
436@sharing_bp.route('/og-image/<resource_type>/<resource_id>', methods=['GET'])
437@optional_auth
438def og_image_endpoint(resource_type, resource_id):
439 """Generate or serve cached OG preview image (1200x630)."""
440 valid_types = ('post', 'comment', 'profile', 'community')
441 if resource_type not in valid_types:
442 return _err("Unsupported resource type", 400)
443 try:
444 from .og_image import generate_og_image
445 image_path = generate_og_image(resource_type, resource_id)
446 if image_path:
447 from flask import send_file
448 return send_file(image_path, mimetype='image/png',
449 max_age=3600)
450 except Exception as e:
451 logger.debug(f"OG image generation failed: {e}")
453 # Fallback: return a redirect to static default OG image
454 return _err("OG image not available", 404)
457# ═══════════════════════════════════════════════════════════════
458# SHARE STATS (for admin / dashboard)
459# ═══════════════════════════════════════════════════════════════
461@sharing_bp.route('/share/stats', methods=['GET'])
462@require_auth
463def share_stats():
464 """Get sharing statistics for the authenticated user."""
465 db = get_db()
466 try:
467 links = db.query(ShareableLink).filter_by(created_by=g.user_id).all()
468 total_shares = len(links)
469 total_views = sum(l.view_count or 0 for l in links)
470 total_share_clicks = sum(l.share_count or 0 for l in links)
472 top_links = sorted(links, key=lambda l: l.view_count or 0, reverse=True)[:5]
473 top = []
474 for l in top_links:
475 og = json.loads(l.metadata_json) if l.metadata_json else {}
476 top.append({
477 'token': l.token,
478 'resource_type': l.resource_type,
479 'og_title': og.get('title', ''),
480 'view_count': l.view_count,
481 'share_count': l.share_count,
482 'created_at': l.created_at.isoformat() if l.created_at else None,
483 })
485 return _ok({
486 'total_links': total_shares,
487 'total_views': total_views,
488 'total_share_clicks': total_share_clicks,
489 'top_links': top,
490 })
491 finally:
492 db.close()
495# ═══════════════════════════════════════════════════════════════
496# EMBEDDABLE CONTENT CARD
497# ═══════════════════════════════════════════════════════════════
499def _embed_html(title, description, author_name, votes, resource_url, resource_type):
500 """Return a self-contained HTML page for an embeddable content card."""
501 # Escape HTML entities
502 import html as _html
503 title = _html.escape(title or 'Untitled')
504 description = _html.escape(description or '')
505 author_name = _html.escape(author_name or 'Unknown')
506 resource_type_label = _html.escape(resource_type.replace('_', ' ').title())
507 resource_url = _html.escape(resource_url or '#')
509 return f'''<!DOCTYPE html>
510<html lang="en">
511<head>
512<meta charset="utf-8"/>
513<meta name="viewport" content="width=device-width, initial-scale=1"/>
514<style>
515*{{margin:0;padding:0;box-sizing:border-box}}
516body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
517background:#0F0E17;color:#FFFFFE;display:flex;justify-content:center;align-items:center;
518min-height:100vh;padding:12px}}
519.card{{background:#1A1932;border:1px solid rgba(108,99,255,0.25);border-radius:16px;
520padding:20px;max-width:480px;width:100%;position:relative;overflow:hidden}}
521.card::before{{content:"";position:absolute;top:0;left:0;right:0;height:3px;
522background:linear-gradient(90deg,#6C63FF,#FF6B6B)}}
523.type{{display:inline-block;font-size:11px;font-weight:600;text-transform:uppercase;
524letter-spacing:0.06em;color:#6C63FF;background:rgba(108,99,255,0.12);
525border-radius:6px;padding:3px 8px;margin-bottom:10px}}
526.title{{font-size:18px;font-weight:700;line-height:1.35;margin-bottom:8px;
527display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
528.desc{{font-size:14px;color:rgba(255,255,254,0.6);line-height:1.55;margin-bottom:14px;
529display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
530.meta{{display:flex;align-items:center;gap:12px;font-size:12px;color:rgba(255,255,254,0.45);
531margin-bottom:14px}}
532.votes{{color:#6C63FF;font-weight:700}}
533.brand{{display:flex;align-items:center;justify-content:space-between;padding-top:12px;
534border-top:1px solid rgba(108,99,255,0.12)}}
535.brand-name{{font-size:13px;font-weight:700;
536background:linear-gradient(135deg,#6C63FF,#FF6B6B);-webkit-background-clip:text;
537-webkit-text-fill-color:transparent}}
538.view-btn{{display:inline-block;font-size:12px;font-weight:600;color:#6C63FF;
539text-decoration:none;padding:6px 14px;border:1px solid rgba(108,99,255,0.35);
540border-radius:8px;transition:all 0.2s ease}}
541.view-btn:hover{{background:rgba(108,99,255,0.12);border-color:#6C63FF}}
542</style>
543</head>
544<body>
545<div class="card">
546 <span class="type">{resource_type_label}</span>
547 <div class="title">{title}</div>
548 <div class="desc">{description}</div>
549 <div class="meta">
550 <span>by {author_name}</span>
551 <span class="votes">{votes} votes</span>
552 </div>
553 <div class="brand">
554 <span class="brand-name">Nunba</span>
555 <a class="view-btn" href="{resource_url}" target="_blank" rel="noopener">View on Nunba</a>
556 </div>
557</div>
558</body>
559</html>'''
562@sharing_bp.route('/embed/<resource_type>/<resource_id>', methods=['GET'])
563@optional_auth
564def embed_card(resource_type, resource_id):
565 """Return an embeddable HTML content card (like a tweet embed).
567 Supports: post, comment, profile, community.
568 Designed to be loaded in an iframe.
569 """
570 from flask import Response
572 valid_types = ('post', 'comment', 'profile', 'community')
573 if resource_type not in valid_types:
574 return Response(
575 f'<html><body style="background:#0F0E17;color:#fff;font-family:sans-serif;'
576 f'display:flex;justify-content:center;align-items:center;height:100vh">'
577 f'<p>Unsupported resource type</p></body></html>',
578 status=400, content_type='text/html; charset=utf-8',
579 )
581 db = get_db()
582 try:
583 title = 'Nunba'
584 description = ''
585 author_name = ''
586 votes = 0
587 resource_url = _resource_route(resource_type, resource_id)
589 if resource_type == 'post':
590 post = db.query(Post).filter_by(id=resource_id).first()
591 if not post:
592 return Response(
593 '<html><body style="background:#0F0E17;color:#fff;font-family:sans-serif;'
594 'display:flex;justify-content:center;align-items:center;height:100vh">'
595 '<p>Post not found</p></body></html>',
596 status=404, content_type='text/html; charset=utf-8',
597 )
598 title = (post.title or (post.content or '')[:60]).strip() or 'Thought Experiment'
599 description = (post.content or '')[:300].strip()
600 votes = (post.upvotes or 0) - (post.downvotes or 0)
601 author = db.query(User).filter_by(id=post.author_id).first()
602 author_name = (author.display_name or author.username) if author else 'Unknown'
604 elif resource_type == 'comment':
605 comment = db.query(Comment).filter_by(id=resource_id).first()
606 if not comment:
607 return Response(
608 '<html><body style="background:#0F0E17;color:#fff;font-family:sans-serif;'
609 'display:flex;justify-content:center;align-items:center;height:100vh">'
610 '<p>Comment not found</p></body></html>',
611 status=404, content_type='text/html; charset=utf-8',
612 )
613 title = f'Comment: {(comment.content or "")[:60].strip()}'
614 description = (comment.content or '')[:300].strip()
615 votes = (getattr(comment, 'upvotes', 0) or 0) - (getattr(comment, 'downvotes', 0) or 0)
616 author = db.query(User).filter_by(id=comment.author_id).first()
617 author_name = (author.display_name or author.username) if author else 'Unknown'
619 elif resource_type == 'profile':
620 user = db.query(User).filter_by(id=resource_id).first()
621 if not user:
622 return Response(
623 '<html><body style="background:#0F0E17;color:#fff;font-family:sans-serif;'
624 'display:flex;justify-content:center;align-items:center;height:100vh">'
625 '<p>User not found</p></body></html>',
626 status=404, content_type='text/html; charset=utf-8',
627 )
628 title = user.display_name or user.username
629 description = (user.bio or 'Member of Nunba community')[:300]
630 author_name = user.username
631 votes = 0
633 elif resource_type == 'community':
634 comm = db.query(Community).filter_by(id=resource_id).first()
635 if not comm:
636 return Response(
637 '<html><body style="background:#0F0E17;color:#fff;font-family:sans-serif;'
638 'display:flex;justify-content:center;align-items:center;height:100vh">'
639 '<p>Community not found</p></body></html>',
640 status=404, content_type='text/html; charset=utf-8',
641 )
642 title = f'h/{comm.name}'
643 description = (comm.description or f'Join the {comm.name} community')[:300]
644 author_name = f'{comm.member_count or 0} members'
645 votes = 0
647 html = _embed_html(title, description, author_name, votes, resource_url, resource_type)
648 return Response(html, status=200, content_type='text/html; charset=utf-8',
649 headers={
650 'X-Frame-Options': 'ALLOWALL',
651 'Content-Security-Policy': "frame-ancestors *",
652 })
653 finally:
654 db.close()