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.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- 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>.
|