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

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 

10 

11from flask import Blueprint, request, jsonify, g 

12from sqlalchemy import func 

13 

14from .auth import require_auth, optional_auth 

15from .models import ( 

16 get_db, User, Post, Comment, Community, ShareableLink, ShareEvent, 

17) 

18 

19logger = logging.getLogger('hevolve_social') 

20 

21sharing_bp = Blueprint('sharing', __name__, url_prefix='/api/social') 

22 

23 

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 

31 

32 

33def _err(msg, status=400): 

34 return jsonify({'success': False, 'error': msg}), status 

35 

36 

37def _get_json(): 

38 return request.get_json(force=True, silent=True) or {} 

39 

40 

41def _generate_token(length=8): 

42 """Generate a URL-safe short token.""" 

43 return secrets.token_urlsafe(length)[:length] 

44 

45 

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 } 

54 

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] 

66 

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' 

74 

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 

83 

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 

91 

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' 

95 

96 return og 

97 

98 

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') 

114 

115 

116# ═══════════════════════════════════════════════════════════════ 

117# CREATE / GET SHARE LINK 

118# ═══════════════════════════════════════════════════════════════ 

119 

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)) 

130 

131 if not resource_type or not resource_id: 

132 return _err("resource_type and resource_id required") 

133 

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 

144 

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)}") 

149 

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() 

156 

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 }) 

169 

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 '' 

173 

174 # Generate OG metadata 

175 og = _get_og_metadata(db, resource_type, resource_id) 

176 

177 # Generate consent token for private links 

178 consent_token = secrets.token_urlsafe(24) if is_private else None 

179 

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() 

187 

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) 

199 

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) 

210 

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}") 

218 

219 db.commit() 

220 

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() 

231 

232 

233# ═══════════════════════════════════════════════════════════════ 

234# RESOLVE SHARE TOKEN 

235# ═══════════════════════════════════════════════════════════════ 

236 

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) 

246 

247 # Check expiry 

248 if link.expires_at and link.expires_at < datetime.utcnow(): 

249 return _err("Share link has expired", 410) 

250 

251 og = json.loads(link.metadata_json) if link.metadata_json else {} 

252 redirect_url = _resource_route(link.resource_type, link.resource_id) 

253 

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}' 

258 

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 } 

270 

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 } 

288 

289 return _ok(result) 

290 finally: 

291 db.close() 

292 

293 

294# ═══════════════════════════════════════════════════════════════ 

295# VIEW TRACKING 

296# ═══════════════════════════════════════════════════════════════ 

297 

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) 

307 

308 link.view_count = (link.view_count or 0) + 1 

309 

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) 

321 

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}") 

335 

336 db.commit() 

337 return _ok({'view_count': link.view_count}) 

338 finally: 

339 db.close() 

340 

341 

342# ═══════════════════════════════════════════════════════════════ 

343# CONSENT (Private Sharing) 

344# ═══════════════════════════════════════════════════════════════ 

345 

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) 

355 

356 if not link.is_private: 

357 return _ok({'requires_consent': False, 'is_private': False}) 

358 

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 

368 

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 } 

378 

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() 

388 

389 

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) 

399 

400 if not link.is_private: 

401 return _err("This link is not private") 

402 

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() 

414 

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}' 

421 

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() 

430 

431 

432# ═══════════════════════════════════════════════════════════════ 

433# OG IMAGE (dynamic per-resource) 

434# ═══════════════════════════════════════════════════════════════ 

435 

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}") 

452 

453 # Fallback: return a redirect to static default OG image 

454 return _err("OG image not available", 404) 

455 

456 

457# ═══════════════════════════════════════════════════════════════ 

458# SHARE STATS (for admin / dashboard) 

459# ═══════════════════════════════════════════════════════════════ 

460 

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) 

471 

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 }) 

484 

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() 

493 

494 

495# ═══════════════════════════════════════════════════════════════ 

496# EMBEDDABLE CONTENT CARD 

497# ═══════════════════════════════════════════════════════════════ 

498 

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 '#') 

508 

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>''' 

560 

561 

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). 

566 

567 Supports: post, comment, profile, community. 

568 Designed to be loaded in an iframe. 

569 """ 

570 from flask import Response 

571 

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 ) 

580 

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) 

588 

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' 

603 

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' 

618 

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 

632 

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 

646 

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()