socialcli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
File without changes
@@ -0,0 +1,33 @@
1
+ """Run the separately configured connection service, never a publication worker."""
2
+ import logging
3
+ import os
4
+ import sys
5
+ import time
6
+
7
+
8
+ def main():
9
+ import uvicorn
10
+ from socialctl.connection_service.app import create_app
11
+ from socialctl.connection_service.settings import Settings
12
+
13
+ # Callback query strings and outbound OAuth payloads must not enter logs.
14
+ for name in ('httpx', 'httpcore'):
15
+ logging.getLogger(name).disabled = True
16
+ settings = Settings.from_environment()
17
+ if sys.argv[1:] == ['--prune']:
18
+ from socialctl.connection_service.store import Store
19
+ vault = Store(settings.database, settings.encryption_keys)
20
+ with vault.transaction() as db:
21
+ vault.purge(db, now=time.time())
22
+ print('Expired connection records pruned.')
23
+ return
24
+ if sys.argv[1:]:
25
+ raise SystemExit('Usage: python -m socialctl.connection_service [--prune]')
26
+ app = create_app(settings)
27
+ uvicorn.run(app, host=os.environ.get('SOCIALCLI_BIND', '127.0.0.1'),
28
+ port=int(os.environ.get('SOCIALCLI_PORT', '8787')), workers=1,
29
+ access_log=False, proxy_headers=False)
30
+
31
+
32
+ if __name__ == '__main__':
33
+ main()
@@ -0,0 +1,496 @@
1
+ """Browser-paired account connections, scoped capabilities and server-side renewal."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import asyncio
6
+ import hmac
7
+ import secrets
8
+ import re
9
+ import time
10
+ import logging
11
+ import traceback
12
+ from contextlib import asynccontextmanager
13
+ from pathlib import Path
14
+ from urllib.parse import urlsplit, parse_qs
15
+
16
+ import httpx
17
+ from socialctl.connection_service.peer import client_ip
18
+ from fastapi import FastAPI, HTTPException, Request
19
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, PlainTextResponse
20
+ from fastapi.exception_handlers import http_exception_handler
21
+ from fastapi.staticfiles import StaticFiles
22
+ from starlette.exceptions import HTTPException as StarletteHTTPException
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
25
+
26
+ from socialctl.connection_service.landing import LandingTokens, landing_page, result_page, error_page
27
+ from socialctl.connection_service.providers import Providers
28
+ from socialctl.connection_service.settings import Settings
29
+ from socialctl.connection_service.store import Store
30
+
31
+ SESSION_SECONDS = 600
32
+ CONNECTION_SECONDS = 90 * 86400
33
+
34
+
35
+ def digest(value: str) -> str:
36
+ return hashlib.sha256(value.encode()).hexdigest()
37
+
38
+
39
+ def bearer(request: Request) -> str:
40
+ value = request.headers.get('authorization', '')
41
+ if not value.startswith('Bearer ') or not 16 <= len(value[7:]) <= 512:
42
+ # Polling secrets from older clients are not supported; capabilities
43
+ # are generated using token_urlsafe(32) by the official CLI.
44
+ raise HTTPException(404, 'connection or authorization not found')
45
+ return digest(value[7:])
46
+
47
+
48
+ def safe_account(account: dict) -> dict:
49
+ return {key: account[key] for key in ('id', 'name', 'page_id') if key in account}
50
+
51
+
52
+ def browser_page(message: str, *, success=False, cancelled=False, platform=None):
53
+ # The copy is fixed; no provider errors, tokens or account-controlled HTML.
54
+ return HTMLResponse(result_page(message, success=success, cancelled=cancelled, platform=platform))
55
+
56
+
57
+ class StartAuthorization(BaseModel):
58
+ model_config = ConfigDict(extra='forbid')
59
+ platform: str = Field(pattern='^(youtube|facebook|instagram|tiktok)$')
60
+ poll_challenge: str = Field(pattern='^[0-9a-f]{64}$')
61
+ management: bool = False
62
+ analytics: bool = False
63
+
64
+
65
+ class CompleteAuthorization(BaseModel):
66
+ model_config = ConfigDict(extra='forbid')
67
+ account_id: str = Field(min_length=1, max_length=256, pattern='^[A-Za-z0-9_-]+$')
68
+
69
+
70
+ def create_app(settings: Settings, *, store: Store | None = None,
71
+ provider_client: httpx.Client | None = None, identity_client: httpx.Client | None = None, billing_client: httpx.Client | None = None, clock=time.time) -> FastAPI:
72
+ vault = store or Store(settings.database, settings.encryption_keys)
73
+ # Enforce quarantine even if deployment configuration accidentally omits SaaS.
74
+ # A raw legacy service must not release credentials from a mounted backup.
75
+ with vault.read_transaction() as db:
76
+ if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='saas_restore_guard'").fetchone():
77
+ guard=db.execute('SELECT state FROM saas_restore_guard WHERE singleton=1').fetchone()
78
+ if guard is None or guard['state']!='ready':
79
+ raise ValueError('The restored database is quarantined; complete current-authority and identity cleanup before startup.')
80
+ client = provider_client or httpx.Client(timeout=20, follow_redirects=False)
81
+ providers = Providers(settings, client)
82
+ from socialctl.connection_service.legacy import LegacyPolicy
83
+ legacy = LegacyPolicy(vault,clock,close=settings.legacy_cutoff or bool(settings.saas and settings.saas.billing))
84
+ landing_tokens = LandingTokens(settings.encryption_keys)
85
+ auth_client = identity_client or (httpx.Client(timeout=15, follow_redirects=False) if settings.saas else None)
86
+
87
+ stripe_client = billing_client or (httpx.Client(timeout=20, follow_redirects=False) if settings.saas and settings.saas.billing else None)
88
+
89
+ @asynccontextmanager
90
+ async def lifespan(app):
91
+ async def cleanup_media():
92
+ while True:
93
+ try:
94
+ await asyncio.to_thread(app.state.temporary_media.cleanup)
95
+ except OSError:
96
+ logging.getLogger('socialcli.connection_service').error('temporary_media_cleanup_failed')
97
+ await asyncio.sleep(1)
98
+ janitor=asyncio.create_task(cleanup_media()) if hasattr(app.state,'temporary_media') else None
99
+ try:
100
+ yield
101
+ finally:
102
+ if janitor:
103
+ janitor.cancel()
104
+ from contextlib import suppress
105
+ with suppress(asyncio.CancelledError):await janitor
106
+ if identity_client is None and auth_client is not None:
107
+ auth_client.close()
108
+ if provider_client is None:
109
+ client.close()
110
+ if billing_client is None and stripe_client is not None:
111
+ stripe_client.close()
112
+
113
+ app = FastAPI(title='SocialCli connection service', docs_url=None, redoc_url=None,
114
+ openapi_url=None, lifespan=lifespan)
115
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=[urlsplit(settings.public_url).hostname])
116
+ app.mount('/connect/assets', StaticFiles(directory=Path(__file__).parent / 'assets'), name='connection-assets')
117
+
118
+ def is_browser_path(request):
119
+ return request.url.path.startswith(('/connect/','/oauth/','/account/'))
120
+
121
+ @app.exception_handler(StarletteHTTPException)
122
+ async def connection_error(request, exc):
123
+ if settings.saas and request.url.path.startswith('/account/'):
124
+ from socialctl.saas.pages import error
125
+ return HTMLResponse('' if request.method == 'HEAD' else error(str(exc.detail)),status_code=exc.status_code,headers=exc.headers)
126
+ if is_browser_path(request):
127
+ logging.getLogger('socialcli.connection_service').warning(
128
+ 'connection_browser_rejected status=%d', exc.status_code,
129
+ )
130
+ return HTMLResponse('' if request.method == 'HEAD' else error_page(exc.status_code),
131
+ status_code=exc.status_code, headers=exc.headers)
132
+ return await http_exception_handler(request, exc)
133
+
134
+ @app.middleware('http')
135
+ async def secure_headers(request: Request, call_next):
136
+ try:
137
+ upload=(request.method=='PUT' and hasattr(app.state,'temporary_media') and re.fullmatch(r'/api/saas/v1/connections/[A-Za-z0-9_-]{1,256}/media/[A-Za-z0-9_-]{32}',request.url.path))
138
+ limit=512*1024*1024 if upload else (1048576 if request.url.path in {'/api/saas/v1/billing/webhook','/api/saas/v1/meta/webhook'} else 16384)
139
+ if int(request.headers.get('content-length', '0')) > limit:
140
+ response = (HTMLResponse(error_page(413), status_code=413) if is_browser_path(request) else
141
+ JSONResponse({'detail':'request is too large'}, status_code=413))
142
+ else:
143
+ response = await call_next(request)
144
+ except Exception as exc:
145
+ # Never log exception messages, request URLs, headers or locals: they
146
+ # may contain OAuth codes, single-use links or provider credentials.
147
+ frames = traceback.extract_tb(exc.__traceback__)
148
+ locations = ','.join(
149
+ f'{Path(frame.filename).name}:{frame.lineno}'
150
+ for frame in frames if '/socialctl/' in frame.filename
151
+ )
152
+ logging.getLogger('socialcli.connection_service').error(
153
+ 'connection_request_failed type=%s locations=%s',
154
+ type(exc).__name__, locations or 'unavailable',
155
+ )
156
+ response = (HTMLResponse(error_page(503), status_code=503) if is_browser_path(request) else
157
+ JSONResponse({'detail':'connection service unavailable'}, status_code=503))
158
+ response.headers.update({'Cache-Control':'no-store', 'Pragma':'no-cache',
159
+ 'X-Content-Type-Options':'nosniff'})
160
+ # Native HTML form POSTs use Origin:null under no-referrer. Preserve
161
+ # same-origin account submissions while withholding cross-origin referrers.
162
+ response.headers.setdefault('Referrer-Policy',
163
+ 'same-origin' if settings.saas and request.url.path.startswith('/account/') else 'no-referrer')
164
+ response.headers.setdefault('Content-Security-Policy',
165
+ "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
166
+ return response
167
+
168
+ def checked(db, kind, record_id, request):
169
+ record = vault.get(db, kind, record_id)
170
+ if record and record['payload'].get('saas'):
171
+ raise HTTPException(404, 'connection or authorization not found')
172
+ proof = bearer(request)
173
+ if record is None or record['expires'] <= clock() or not hmac.compare_digest(proof, record['credential_hash']):
174
+ raise HTTPException(404, 'connection or authorization not found')
175
+ legacy.require(db,kind,record_id,record)
176
+ return record
177
+
178
+ @app.get('/agents', response_class=PlainTextResponse)
179
+ def agent_instructions():
180
+ return (Path(__file__).parent.parent / 'ui/assets/agents.md').read_text()
181
+
182
+ @app.get('/healthz')
183
+ def health():
184
+ return {'service':'socialcli-connections', 'version':1,
185
+ 'configured_platforms': sorted(settings.providers), 'production_approval_verified':False}
186
+
187
+ @app.post('/v1/authorizations', status_code=201)
188
+ def start(body: StartAuthorization, request: Request):
189
+ try:
190
+ providers.scopes(body.platform, management=body.management, analytics=body.analytics)
191
+ except ValueError:
192
+ raise HTTPException(400, 'requested optional permissions are not supported for this platform') from None
193
+ if body.platform not in settings.providers:
194
+ raise HTTPException(503, 'this platform is not configured; use independent-app auth or contact the operator')
195
+ record_id = secrets.token_urlsafe(24)
196
+ data = {'platform':body.platform, 'status':'pending', 'management':body.management,
197
+ 'analytics':body.analytics}
198
+ with vault.transaction() as db:
199
+ legacy.allow_creation(db)
200
+ vault.purge(db, now=clock())
201
+ if not vault.admit(db, client_ip(request,settings), now=clock()):
202
+ raise HTTPException(429, 'too many connection attempts; try again later', headers={'Retry-After':'60'})
203
+ count = db.execute("SELECT count(*) FROM records WHERE kind='authorization'").fetchone()[0]
204
+ if count >= 1000:
205
+ raise HTTPException(503, 'connection capacity reached; try again later')
206
+ vault.put(db, 'authorization', record_id, data, expires=clock()+SESSION_SECONDS,
207
+ credential_hash=body.poll_challenge)
208
+ return {'version':1, 'authorization_id':record_id, 'browser_url':f'{settings.public_url}/connect/{record_id}',
209
+ 'expires_in':SESSION_SECONDS, 'poll_interval':2}
210
+
211
+ def pending_authorization(db, authorization_id):
212
+ record = vault.get(db, 'authorization', authorization_id)
213
+ if record is None or record['expires'] <= clock():
214
+ raise HTTPException(404, 'authorization expired or unavailable; restart connect in SocialCli')
215
+ legacy.require(db,'authorization',authorization_id,record)
216
+ if record['payload'].get('saas'):
217
+ app.state.saas_connections.authorization(db,authorization_id)
218
+ if record['payload']['status'] != 'pending' or 'state' in record['payload']:
219
+ raise HTTPException(409, 'this authorization has already started; restart connect in SocialCli')
220
+ return record
221
+
222
+ @app.api_route('/connect/{authorization_id}', methods=['GET', 'HEAD'])
223
+ def browser_landing(authorization_id: str, request: Request):
224
+ with vault.transaction() as db:
225
+ record = pending_authorization(db, authorization_id)
226
+ if record['payload'].get('saas'):
227
+ try:
228
+ user = app.state.saas_sessions.require_user(request)
229
+ except HTTPException as exc:
230
+ if exc.status_code != 401:
231
+ raise
232
+ from socialctl.saas.routes import connection_sign_in
233
+ return connection_sign_in('/connect/' + authorization_id)
234
+ if user.id != record['payload']['saas']['user_id']:
235
+ raise HTTPException(403, 'This connection belongs to another account.')
236
+ cookie_name = 'socialcli-form-' + authorization_id
237
+ cookie = request.cookies.get(cookie_name, '')
238
+ if not 16 <= len(cookie) <= 512:
239
+ cookie = secrets.token_urlsafe(32)
240
+ token = landing_tokens.issue(authorization_id, cookie, record['expires'])
241
+ response = HTMLResponse('' if request.method == 'HEAD' else landing_page(
242
+ authorization_id, token, platform=record['payload']['platform'],
243
+ browser_selection=bool(record['payload'].get('saas'))))
244
+ response.headers['X-Robots-Tag'] = 'noindex, nofollow'
245
+ response.headers['Referrer-Policy'] = 'same-origin'
246
+ provider_origin = {'youtube':'https://accounts.google.com', 'facebook':'https://www.facebook.com',
247
+ 'instagram':'https://www.facebook.com', 'tiktok':'https://www.tiktok.com'}[record['payload']['platform']]
248
+ response.headers['Content-Security-Policy'] = (
249
+ "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self' " + provider_origin
250
+ + "; frame-ancestors 'none'; base-uri 'none'")
251
+ response.set_cookie(cookie_name, cookie, httponly=True, secure=not settings.allow_http_local,
252
+ samesite='lax', max_age=SESSION_SECONDS, path='/connect/' + authorization_id)
253
+ return response
254
+
255
+ @app.post('/connect/{authorization_id}')
256
+ async def browser_start(authorization_id: str, request: Request):
257
+ if request.headers.get('origin') != settings.public_url:
258
+ raise HTTPException(403, 'invalid connection form; reload the login page')
259
+ if request.headers.get('content-type', '').split(';')[0].strip() != 'application/x-www-form-urlencoded':
260
+ raise HTTPException(403, 'invalid connection form; reload the login page')
261
+ body = bytearray()
262
+ try:
263
+ async with asyncio.timeout(5):
264
+ async for chunk in request.stream():
265
+ body.extend(chunk)
266
+ if len(body) > 8192:
267
+ raise HTTPException(413, 'connection form is too large')
268
+ values = parse_qs(body.decode('ascii'), strict_parsing=True, max_num_fields=5)
269
+ if any(len(v)!=1 for v in values.values()):
270
+ raise ValueError()
271
+ tokens = values.get('form_token', [])
272
+ except (ValueError, UnicodeError, TimeoutError):
273
+ raise HTTPException(403, 'invalid connection form; reload the login page') from None
274
+ cookie_name = 'socialcli-form-' + authorization_id
275
+ if (len(tokens) != 1 or not landing_tokens.valid(tokens[0], authorization_id,
276
+ request.cookies.get(cookie_name, ''), clock())):
277
+ raise HTTPException(403, 'invalid or expired connection form; restart connect in SocialCli')
278
+ def read_owner_record():
279
+ with vault.transaction() as db:
280
+ return pending_authorization(db, authorization_id)
281
+ owner_record = await asyncio.to_thread(read_owner_record)
282
+ from socialctl.saas import consent
283
+ choice={k:v[0] for k,v in values.items()}
284
+ consent.privacy_choice(choice)
285
+ assistant_consent = None if owner_record['payload'].get('saas') else consent.assistant_choice(choice,clock())
286
+ if owner_record['payload'].get('saas'):
287
+ try:
288
+ user = await asyncio.to_thread(app.state.saas_sessions.require_user, request)
289
+ except HTTPException as exc:
290
+ if exc.status_code != 401:
291
+ raise
292
+ from socialctl.saas.routes import connection_sign_in
293
+ return connection_sign_in('/connect/' + authorization_id)
294
+ if user.id != owner_record['payload']['saas']['user_id']:
295
+ raise HTTPException(403, 'This connection belongs to another account.')
296
+ browser_secret = secrets.token_urlsafe(32)
297
+ def consume_authorization():
298
+ with vault.transaction() as db:
299
+ record = pending_authorization(db, authorization_id)
300
+ data = record['payload']
301
+ data.update(privacy_consent={'version':consent.PRIVACY_VERSION,'accepted_at':clock(),'source':'owner_browser'},
302
+ assistant_consent=assistant_consent,
303
+ state=authorization_id+'.'+secrets.token_urlsafe(32),
304
+ browser_hash=digest(browser_secret), verifier=secrets.token_urlsafe(64))
305
+ vault.put(db,'authorization',authorization_id,data,expires=record['expires'],credential_hash=record['credential_hash'])
306
+ url = providers.authorization_url(data['platform'], data['state'], data['verifier'],
307
+ management=data['management'], analytics=data['analytics'])
308
+ return url
309
+ url = await asyncio.to_thread(consume_authorization)
310
+ response = RedirectResponse(url, status_code=303)
311
+ response.delete_cookie(cookie_name, path='/connect/' + authorization_id,
312
+ secure=not settings.allow_http_local, httponly=True, samesite='lax')
313
+ response.set_cookie('socialcli-'+authorization_id,browser_secret,httponly=True,
314
+ secure=not settings.allow_http_local,samesite='lax',max_age=SESSION_SECONDS,path='/')
315
+ return response
316
+
317
+ @app.get('/oauth/{platform}/callback')
318
+ def callback(platform: str, request: Request):
319
+ query = request.query_params
320
+ state = query.get('state', '')
321
+ if not 16 <= len(state) <= 256 or '.' not in state:
322
+ raise HTTPException(400, 'invalid authorization callback')
323
+ authorization_id = state.split('.',1)[0]
324
+ cookie = request.cookies.get('socialcli-'+authorization_id,'')
325
+ with vault.transaction() as db:
326
+ record = vault.get(db,'authorization',authorization_id)
327
+ if record is None or record['expires'] <= clock():
328
+ raise HTTPException(400, 'invalid authorization callback')
329
+ legacy.require(db,'authorization',authorization_id,record)
330
+ data = record['payload']
331
+ if (data['platform'] != platform or data['status'] != 'pending'
332
+ or not hmac.compare_digest(state,data.get('state',''))
333
+ or not cookie or not hmac.compare_digest(digest(cookie),data.get('browser_hash',''))):
334
+ raise HTTPException(400, 'invalid authorization callback')
335
+ if data.get('saas'):
336
+ app.state.saas_connections.authorization(db,authorization_id)
337
+ else:
338
+ from socialctl.saas import consent
339
+ consent.require_legacy(data)
340
+ # Consume state before contacting the provider. An uncertain exchange
341
+ # is never retried with the same authorization code.
342
+ data.pop('state',None)
343
+ data.pop('browser_hash',None)
344
+ data['status'] = 'denied' if 'error' in query else 'exchanging'
345
+ vault.put(db,'authorization',authorization_id,data,expires=record['expires'],credential_hash=record['credential_hash'])
346
+ if data['status'] == 'denied':
347
+ response = browser_page('Authorization was cancelled. No account was connected.', cancelled=True, platform=platform)
348
+ else:
349
+ code = query.get('code','')
350
+ try:
351
+ if not code or len(code)>4096:
352
+ raise ValueError()
353
+ grant = providers.exchange(platform,code,data['verifier'],management=data['management'],analytics=data['analytics'])
354
+ data['result'], data['status'] = grant, 'ready'
355
+ except Exception:
356
+ data['status'] = 'failed'
357
+ data.pop('verifier',None)
358
+ revoked_during_exchange=False
359
+ with vault.transaction() as db:
360
+ current = vault.get(db,'authorization',authorization_id)
361
+ if data.get('saas') and platform=='youtube' and data['status']=='ready':
362
+ owner_id=data['saas']['user_id']
363
+ revoked_during_exchange=bool(db.execute(
364
+ "SELECT 1 FROM saas_deletion_tombstones WHERE (kind='user' AND id=?) OR (kind='grant' AND id=?)",
365
+ (owner_id,authorization_id)).fetchone())
366
+ if revoked_during_exchange:
367
+ from socialctl.saas import revocation
368
+ revocation.enqueue(app.state.saas,db,authorization_id,
369
+ {'user_id':owner_id,'platform':platform,'grant':data['result']['grant']},clock())
370
+ if not revoked_during_exchange and current is not None and current['expires'] > clock():
371
+ legacy.require(db,'authorization',authorization_id,current)
372
+ if current['payload'].get('saas'):
373
+ app.state.saas_connections.authorization(db,authorization_id)
374
+ vault.put(db,'authorization',authorization_id,data,expires=current['expires'],credential_hash=current['credential_hash'])
375
+ if revoked_during_exchange:
376
+ raise HTTPException(409,'connection_changed: This authorization was revoked while Google was responding. Cleanup is pending; no connection was restored.')
377
+ response = browser_page('Authorization received. No content was published.' if data['status']=='ready'
378
+ else 'Authorization could not be completed. Check permissions and app configuration.', success=data['status']=='ready', platform=platform)
379
+ if data.get('saas') and data['status']=='ready':
380
+ response = RedirectResponse('/account/connect/'+authorization_id,status_code=303)
381
+ response.delete_cookie('socialcli-'+authorization_id,path='/',secure=not settings.allow_http_local,httponly=True,samesite='lax')
382
+ return response
383
+
384
+ @app.post('/v1/authorizations/{authorization_id}/poll')
385
+ def poll(authorization_id: str, request: Request):
386
+ with vault.transaction() as db:
387
+ data = checked(db,'authorization',authorization_id,request)['payload']
388
+ reply = {'version':1,'status':data['status']}
389
+ if data['status']=='ready':
390
+ from socialctl.saas import consent
391
+ consent.require_legacy(data)
392
+ reply['accounts'] = [safe_account(a) for a in data['result']['accounts']]
393
+ return reply
394
+
395
+ @app.delete('/v1/authorizations/{authorization_id}')
396
+ def cancel(authorization_id: str, request: Request):
397
+ with vault.transaction() as db:
398
+ checked(db,'authorization',authorization_id,request)
399
+ vault.delete(db,'authorization',authorization_id)
400
+ return {'version':1,'status':'cancelled'}
401
+
402
+ @app.post('/v1/authorizations/{authorization_id}/complete', status_code=201)
403
+ def finish(authorization_id: str, body: CompleteAuthorization, request: Request):
404
+ with vault.transaction() as db:
405
+ record = checked(db,'authorization',authorization_id,request)
406
+ data = record['payload']
407
+ if data['status'] != 'ready':
408
+ raise HTTPException(409,'authorization is not ready')
409
+ from socialctl.saas import consent
410
+ consent.require_legacy(data)
411
+ result = data['result']
412
+ account = next((a for a in result['accounts'] if a['id']==body.account_id),None)
413
+ if account is None:
414
+ raise HTTPException(400,'account was not authorized')
415
+ grant = result['grant']
416
+ if account.get('access_token'):
417
+ grant['access_token'] = account['access_token']
418
+ connection_id, connection_secret = secrets.token_urlsafe(24), secrets.token_urlsafe(32)
419
+ payload = {'platform':data['platform'],'account':safe_account(account),'grant':grant,'activated':False,
420
+ 'privacy_consent':data.get('privacy_consent'),'assistant_consent':data.get('assistant_consent')}
421
+ expires=legacy.provisional(db,authorization_id,connection_id,clock()+SESSION_SECONDS)
422
+ vault.put(db,'connection',connection_id,payload,expires=expires,credential_hash=digest(connection_secret))
423
+ vault.delete(db,'authorization',authorization_id)
424
+ return {'version':1,'connection_id':connection_id,'connection_secret':connection_secret,
425
+ 'platform':payload['platform'],'account':payload['account'],
426
+ 'granted_scopes':grant['granted_scopes'],'expires_at':grant['expires_at']}
427
+
428
+ @app.get('/v1/connections/{connection_id}')
429
+ def status(connection_id: str, request: Request):
430
+ with vault.transaction() as db:
431
+ record = checked(db,'connection',connection_id,request)
432
+ data = record['payload']
433
+ from socialctl.saas import consent
434
+ consent.require_legacy(data)
435
+ grant = data['grant']
436
+ return {'version':1,'platform':data['platform'],'account':data['account'],
437
+ 'granted_scopes':grant['granted_scopes'],'expires_at':grant['expires_at'],
438
+ 'connection_expires_at':record['expires'],'needs_reconnect':grant['expires_at']<=clock() and data['platform'] not in {'youtube','tiktok'}}
439
+
440
+ @app.post('/v1/connections/{connection_id}/token')
441
+ def access_token(connection_id: str, request: Request, refresh: bool = True):
442
+ with vault.transaction() as db:
443
+ record = checked(db,'connection',connection_id,request)
444
+ data = record['payload']
445
+ from socialctl.saas import consent
446
+ consent.require_legacy(data)
447
+ grant = data['grant']
448
+ if data['platform'] not in settings.providers:
449
+ raise HTTPException(503,'platform temporarily disabled')
450
+ if grant['expires_at'] <= clock()+60:
451
+ if not refresh:
452
+ raise HTTPException(409,'provider token expired; run an explicitly authorized operation to renew, or reconnect')
453
+ try:
454
+ grant = providers.refresh(data['platform'],grant)
455
+ except ValueError:
456
+ raise HTTPException(409,'authorization could not be renewed; reconnect your account') from None
457
+ data['grant'] = grant
458
+ vault.put(db,'connection',connection_id,data,expires=record['expires'],credential_hash=record['credential_hash'])
459
+ if not data.get('activated') and refresh:
460
+ data['activated'] = True
461
+ legacy.activated(db,connection_id,clock()+CONNECTION_SECONDS)
462
+ vault.put(db,'connection',connection_id,data,expires=clock()+CONNECTION_SECONDS,credential_hash=record['credential_hash'])
463
+ return {'version':1,'access_token':grant['access_token'],'expires_at':grant['expires_at'],
464
+ 'granted_scopes':grant['granted_scopes'],'platform':data['platform'],'account':data['account']}
465
+
466
+ @app.delete('/v1/connections/{connection_id}')
467
+ def disconnect(connection_id: str, request: Request):
468
+ with vault.transaction() as db:
469
+ checked(db,'connection',connection_id,request)
470
+ vault.delete(db,'connection',connection_id)
471
+ return {'version':1,'status':'disconnected','provider_authorization_revoked':False}
472
+
473
+ if settings.saas:
474
+ from socialctl.saas.routes import install_account_routes
475
+ install_account_routes(app, settings, vault, auth_client, clock)
476
+ from socialctl.saas.connections import install_connection_routes
477
+ install_connection_routes(app,settings,app.state.saas,app.state.saas_sessions,providers,clock)
478
+ from socialctl.saas.meta_webhooks import install_meta_webhook_routes
479
+ install_meta_webhook_routes(app,app.state.saas_connections,app.state.saas_sessions,settings,clock)
480
+ from socialctl.saas.rotation import install_rotation_routes
481
+ install_rotation_routes(app,app.state.saas,clock)
482
+ app.state.saas_connections.legacy=legacy
483
+ if settings.temporary_media_dir:
484
+ from socialctl.saas.temporary_media import TemporaryMedia,install_media_routes
485
+ install_media_routes(app,TemporaryMedia(app.state.saas,settings.temporary_media_dir,clock),app.state.saas_connections,settings.public_url)
486
+ from socialctl.saas.migration import install_migration_routes
487
+ install_migration_routes(app,settings,app.state.saas,app.state.saas_sessions,clock)
488
+ if settings.saas.billing:
489
+ from socialctl.saas.billing import BillingService
490
+ from socialctl.saas.billing_routes import install_billing_routes
491
+ from socialctl.saas.webhooks import install_webhook_route
492
+ billing=BillingService(app.state.saas,settings.saas.billing,stripe_client,clock,settings.public_url)
493
+ install_billing_routes(app,billing)
494
+ install_webhook_route(app,billing)
495
+
496
+ return app
@@ -0,0 +1,50 @@
1
+ Platform marks identify the service being connected. Respective brand owners
2
+ retain their trademarks. These are packaged locally; no external image request
3
+ is made when the connection UI loads.
4
+
5
+ YouTube:
6
+ Paths from Simple Icons 15.0.0, filled in the service identifying color/white.
7
+ https://github.com/simple-icons/simple-icons/tree/15.0.0/icons
8
+ Licensed under CC0 (full text below).
9
+
10
+ Facebook, Instagram and TikTok:
11
+ Operator-supplied mobile app icons, copied byte for byte on 2026-10-15:
12
+ Facebook_iOS_26.webp -> facebook.webp
13
+ Instagram_2022.svg -> instagram.svg
14
+ tiktok-app.png -> tiktok.png
15
+ Displayed at a bounded CSS size, without cropping or reencoding the source.
16
+ These brand marks remain the property of their respective trademark owners;
17
+ the SocialCli MIT license and the YouTube path's CC0 license do not apply to them.
18
+
19
+ Simple Icons CC0 license:
20
+
21
+ # CC0 1.0 Universal
22
+
23
+ ## Statement of Purpose
24
+
25
+ The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”).
26
+
27
+ Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
28
+
29
+ For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
30
+
31
+ 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following:
32
+ 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
33
+ 2. moral rights retained by the original author(s) and/or performer(s);
34
+ 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work;
35
+ 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below;
36
+ 5. rights protecting the extraction, dissemination, use and reuse of data in a Work;
37
+ 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
38
+ 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
39
+
40
+ 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose.
41
+
42
+ 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose.
43
+
44
+ 4. Limitations and Disclaimers.
45
+ 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
46
+ 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
47
+ 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
48
+ 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
49
+
50
+ For more information, please see <https://creativecommons.org/publicdomain/zero/1.0>.