splent-feature-github 0.1.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.
@@ -0,0 +1,305 @@
1
+ import secrets
2
+
3
+ from flask import current_app
4
+ from splent_framework.services.BaseService import BaseService
5
+ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
6
+
7
+ from splent_io.splent_feature_auth.models import User
8
+ from splent_io.splent_feature_auth.repositories import UserRepository
9
+ from splent_io.splent_feature_github.repositories import GithubRepository
10
+ from splent_io.splent_feature_identities.oauth import oauth_for
11
+ from splent_io.splent_feature_identities.services import IdentitiesService
12
+ from splent_io.splent_feature_profile.repositories import UserProfileRepository
13
+
14
+ PROVIDER = "github"
15
+ # The name people know the provider by, in the messages they read.
16
+ PROVIDER_NAME = "GitHub"
17
+
18
+ # The refusal in a product without signup, when identities is too old to
19
+ # word it (see new_account_refusal).
20
+ CLOSED_REGISTRATION_FALLBACK = (
21
+ "New accounts are opened by an administrator. Ask an administrator for access."
22
+ )
23
+
24
+
25
+ def opens_accounts(identities=None) -> bool:
26
+ """Whether signing in with GitHub may open a new account here.
27
+
28
+ identities decides, for every provider alike: only a product that
29
+ installs signup opens accounts. registration_is_open is new in the
30
+ identities released with this version of github. Against an older
31
+ identities, which has no such method, the same question is asked here
32
+ (is signup's blueprint registered) instead of raising AttributeError.
33
+ """
34
+ identities = IdentitiesService() if identities is None else identities
35
+ if hasattr(identities, "registration_is_open"):
36
+ return identities.registration_is_open()
37
+ return "signup" in current_app.blueprints
38
+
39
+
40
+ def new_account_refusal(identities=None) -> str | None:
41
+ """None when signing in with GitHub may open an account here, otherwise
42
+ the translated message that refuses it.
43
+
44
+ The message is identities', the same for every provider.
45
+ closed_registration_message is new in the identities released with this
46
+ version of github, so against an older one, which has no such method,
47
+ the refusal is a plain English sentence instead of an AttributeError.
48
+ """
49
+ identities = IdentitiesService() if identities is None else identities
50
+ if opens_accounts(identities):
51
+ return None
52
+ if hasattr(identities, "closed_registration_message"):
53
+ return identities.closed_registration_message(PROVIDER_NAME)
54
+ return CLOSED_REGISTRATION_FALLBACK
55
+
56
+
57
+ def register_github_client(app):
58
+ """Register GitHub on the product's Authlib registry, once per app."""
59
+ oauth_for(app).register(
60
+ name=PROVIDER,
61
+ client_id=app.config.get("GITHUB_CLIENT_ID") or None,
62
+ client_secret=app.config.get("GITHUB_CLIENT_SECRET") or None,
63
+ access_token_url="https://github.com/login/oauth/access_token",
64
+ authorize_url="https://github.com/login/oauth/authorize",
65
+ client_kwargs={"scope": "user:email"},
66
+ )
67
+ if not app.config.get("GITHUB_CLIENT_ID") or not app.config.get(
68
+ "GITHUB_CLIENT_SECRET"
69
+ ):
70
+ app.logger.warning(
71
+ "GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET not configured; GitHub login disabled"
72
+ )
73
+
74
+
75
+ class GithubService(BaseService):
76
+ def __init__(self):
77
+ super().__init__(GithubRepository())
78
+ self.user_repository = UserRepository()
79
+ self.user_profile_repository = UserProfileRepository()
80
+ self.identities = IdentitiesService()
81
+
82
+ @property
83
+ def github_client(self):
84
+ return oauth_for(current_app).create_client(PROVIDER)
85
+
86
+ def new_account_refusal(self) -> str | None:
87
+ return new_account_refusal(self.identities)
88
+
89
+ def get_github_user_info(self, token):
90
+ try:
91
+ resp = self.github_client.get("https://api.github.com/user", token=token)
92
+ except Exception as exc:
93
+ current_app.logger.exception("GitHub userinfo request failed: %s", exc)
94
+ return None, "Could not reach GitHub. Please try again."
95
+
96
+ if not resp:
97
+ current_app.logger.error("GitHub userinfo empty response")
98
+ return None, "GitHub did not return user information. Please try again."
99
+
100
+ if resp.status_code != 200:
101
+ current_app.logger.error(
102
+ "GitHub userinfo failed (%s): %s",
103
+ resp.status_code,
104
+ getattr(resp, "text", None),
105
+ )
106
+ if resp.status_code == 429:
107
+ return (
108
+ None,
109
+ "GitHub is rate-limiting requests. Please try again in a minute.",
110
+ )
111
+ return None, "GitHub user information request failed. Please try again."
112
+
113
+ data = resp.json() or {}
114
+ github_id = data.get("id")
115
+ if not github_id:
116
+ current_app.logger.error("GitHub userinfo missing 'id': %s", data)
117
+ return None, "GitHub did not provide a user ID. Please try again."
118
+
119
+ # A GitHub profile email is private by default, so /user often omits it.
120
+ # Without an email there is nothing to converge an existing account on,
121
+ # so fall back to the verified primary address from /user/emails (the
122
+ # granted ``user:email`` scope covers this).
123
+ if not data.get("email"):
124
+ data["email"] = self.get_primary_verified_email(token)
125
+
126
+ return data, None
127
+
128
+ def get_primary_verified_email(self, token):
129
+ try:
130
+ resp = self.github_client.get(
131
+ "https://api.github.com/user/emails", token=token
132
+ )
133
+ except Exception as exc:
134
+ current_app.logger.warning("GitHub user/emails request failed: %s", exc)
135
+ return None
136
+
137
+ if not resp or resp.status_code != 200:
138
+ current_app.logger.warning(
139
+ "GitHub user/emails failed (%s)", getattr(resp, "status_code", None)
140
+ )
141
+ return None
142
+
143
+ emails = resp.json() or []
144
+ if not isinstance(emails, list):
145
+ return None
146
+ entries = [e for e in emails if isinstance(e, dict)]
147
+ primary = next(
148
+ (e.get("email") for e in entries if e.get("primary") and e.get("verified")),
149
+ None,
150
+ )
151
+ return primary or next(
152
+ (e.get("email") for e in entries if e.get("verified")), None
153
+ )
154
+
155
+ def _existing_user_for_github(self, github_id: int):
156
+ github_record = self.repository.get_by_github_id(github_id)
157
+ if not github_record:
158
+ return None, github_record
159
+ profile = self.user_profile_repository.get_by_id(github_record.profile_id)
160
+ user = self.user_repository.get_by_id(profile.user_id) if profile else None
161
+ return user, github_record
162
+
163
+ def get_or_create_user(self, user_info):
164
+ if not user_info:
165
+ return None, "Missing GitHub user information."
166
+
167
+ github_id = user_info.get("id")
168
+ if not github_id:
169
+ return None, "Missing GitHub ID."
170
+
171
+ github_login = (user_info.get("login") or "").strip()
172
+ name = (user_info.get("name") or "").strip()
173
+ if not name:
174
+ name = github_login
175
+
176
+ email = (
177
+ (user_info.get("email") or "").strip().lower()
178
+ if user_info.get("email")
179
+ else None
180
+ )
181
+
182
+ try:
183
+ # 1. Check if this GitHub ID already exists
184
+ user = self.identities.linked_user(PROVIDER, github_id)
185
+ if user:
186
+ return user, None
187
+
188
+ # 2. A product without signup is closed: an administrator opens
189
+ # its accounts, and GitHub is not a way around that. Asked before
190
+ # the email, so the refusal says nothing about who has an account.
191
+ refusal = self.new_account_refusal()
192
+ if refusal:
193
+ return None, refusal
194
+
195
+ # 3. Never link to an existing account by email. GitHub has verified
196
+ # the address, but nothing verified the account's own email (a
197
+ # password signup or an ORCID sign-in can claim any address), so
198
+ # linking would hand this GitHub user someone else's account, or the
199
+ # other way round. Linking goes through the connect flow instead.
200
+ if self.identities.email_in_use(email):
201
+ return None, (
202
+ "An account with this email already exists. Sign in with your usual method "
203
+ "and connect GitHub from your profile."
204
+ )
205
+
206
+ # 4. Create new user if nothing found
207
+ user = self.user_repository.create(
208
+ commit=False,
209
+ password=secrets.token_urlsafe(24),
210
+ active=True,
211
+ email=email,
212
+ )
213
+ profile = self.user_profile_repository.create(
214
+ commit=False, user_id=user.id, name=name, surname=""
215
+ )
216
+ self.repository.create(
217
+ commit=False,
218
+ github_id=github_id,
219
+ github_login=github_login,
220
+ profile_id=profile.id,
221
+ )
222
+ # The username goes in here too, not only in the connect flow, or the
223
+ # profile would show the numeric id after a GitHub sign-up.
224
+ self.identities.add_identity(
225
+ user.id,
226
+ PROVIDER,
227
+ github_id,
228
+ provider_username=github_login,
229
+ email=email,
230
+ commit=False,
231
+ )
232
+ self.repository.session.commit()
233
+ return user, None
234
+ except IntegrityError as exc:
235
+ current_app.logger.warning(
236
+ "IntegrityError creating GitHub user (%s): %s", github_id, exc
237
+ )
238
+ self.repository.session.rollback()
239
+ user, _ = self._existing_user_for_github(github_id)
240
+ if user:
241
+ return user, None
242
+ return (
243
+ None,
244
+ "Could not create your account due to a concurrency issue. Please try again.",
245
+ )
246
+
247
+ except SQLAlchemyError as exc:
248
+ current_app.logger.exception(
249
+ "Database error creating GitHub user (%s): %s", github_id, exc
250
+ )
251
+ self.repository.session.rollback()
252
+ return (
253
+ None,
254
+ "Could not create your account due to a database error. Please try again.",
255
+ )
256
+
257
+ def link_identity(self, user: User, user_info):
258
+ """Attach a GitHub identity to an already-authenticated user.
259
+
260
+ Writes both the identity row (used to resolve future logins) and the
261
+ ``Github`` domain row (used by ``profile.get_github``), so the connect
262
+ flow leaves the account in the same shape a GitHub login would.
263
+ """
264
+ github_id = user_info.get("id")
265
+ if not github_id:
266
+ return None, "Missing GitHub ID."
267
+
268
+ github_login = (user_info.get("login") or "").strip()
269
+ email = (user_info.get("email") or "").strip().lower() or None
270
+
271
+ existing = self.identities.get_identity(PROVIDER, github_id)
272
+ if existing and existing.user_id and existing.user_id != user.id:
273
+ return None, "This GitHub account is already linked to another user."
274
+
275
+ try:
276
+ if not existing:
277
+ self.identities.add_identity(
278
+ user.id,
279
+ PROVIDER,
280
+ github_id,
281
+ provider_username=github_login,
282
+ email=email,
283
+ commit=False,
284
+ )
285
+ profile = getattr(user, "profile", None)
286
+ if profile and not self.repository.get_by_github_id(github_id):
287
+ self.repository.create(
288
+ commit=False,
289
+ github_id=github_id,
290
+ github_login=github_login,
291
+ profile_id=profile.id,
292
+ )
293
+ self.repository.session.commit()
294
+ return user, None
295
+ except (IntegrityError, SQLAlchemyError) as exc:
296
+ current_app.logger.warning("Could not link GitHub %s: %s", github_id, exc)
297
+ self.repository.session.rollback()
298
+ return None, "Could not connect GitHub. Please try again."
299
+
300
+ def forget(self, user: User, github_id: int) -> None:
301
+ """Drop the profile's GitHub row after the identity was disconnected."""
302
+ record = self.repository.get_by_github_id(github_id)
303
+ profile = getattr(user, "profile", None)
304
+ if record and profile and record.profile_id == profile.id:
305
+ self.repository.delete(record.id)
@@ -0,0 +1,14 @@
1
+ """Signal handlers for splent_feature_github."""
2
+
3
+ from splent_framework.signals.signal_utils import connect_signal
4
+
5
+
6
+ @connect_signal("identity-disconnected", "splent_feature_github")
7
+ def on_identity_disconnected(sender, user, provider, provider_id, **kwargs):
8
+ # Drop the profile's GitHub row too, or a later sign-in with the same
9
+ # account would still resolve this user through it.
10
+ if provider != "github" or not str(provider_id).isdigit():
11
+ return
12
+ from splent_io.splent_feature_github.services import GithubService
13
+
14
+ GithubService().forget(user, int(provider_id))
@@ -0,0 +1,7 @@
1
+ {# In auth.login.providers and signup.providers. GitHub's dark grey comes
2
+ from assets/css/github.css (.btn-github), so the button looks like
3
+ GitHub's in any shell. #}
4
+ <a href="{{ url_for('github.login', next=next_url) if next_url else url_for('github.login') }}"
5
+ class="btn btn-github d-flex align-items-center">
6
+ GitHub
7
+ </a>
@@ -0,0 +1,31 @@
1
+ {# A section of profile's edit page (profile.edit.sections), below the
2
+ profile form. The disconnect button opens identities' modal, which posts
3
+ the provider and account id it carries. The colours are in
4
+ assets/css/github.css, and the mark is drawn in currentColor. #}
5
+ {% if identity %}
6
+ <div class="mb-4 profile-identity" data-identity="github">
7
+ <label class="form-label fw-semibold">GitHub</label>
8
+ <div class="form-control d-flex align-items-center gap-2 github-identity__field">
9
+ <svg class="github-mark" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
10
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
11
+ </svg>
12
+ <span class="github-identity__value">{{ identity.provider_username or identity.provider_id }}</span>
13
+ <button type="button" class="btn btn-sm p-0 github-identity__disconnect" title="{{ _('Disconnect') }}" aria-label="{{ _('Disconnect') }}" data-bs-toggle="modal" data-bs-target="#disconnectModal" data-provider="github" data-provider-id="{{ identity.provider_id }}">
14
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
15
+ <line x1="18" y1="6" x2="6" y2="18"></line>
16
+ <line x1="6" y1="6" x2="18" y2="18"></line>
17
+ </svg>
18
+ </button>
19
+ </div>
20
+ </div>
21
+ {% else %}
22
+ <div class="mb-4 profile-identity" data-identity="github">
23
+ <label class="form-label fw-semibold">GitHub</label>
24
+ <a href="{{ url_for('github.connect_github') }}" class="form-control text-decoration-none d-flex align-items-center gap-2 github-identity__field github-identity__connect">
25
+ <svg class="github-mark" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
26
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
27
+ </svg>
28
+ <span>{{ _('Connect GitHub') }}</span>
29
+ </a>
30
+ </div>
31
+ {% endif %}
@@ -0,0 +1,28 @@
1
+ # Spanish translations for PROJECT.
2
+ # Copyright (C) 2026 ORGANIZATION
3
+ # This file is distributed under the same license as the PROJECT project.
4
+ # FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
5
+ #
6
+ msgid ""
7
+ msgstr ""
8
+ "Project-Id-Version: PROJECT VERSION\n"
9
+ "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10
+ "POT-Creation-Date: 2026-10-21 00:49+0200\n"
11
+ "PO-Revision-Date: 2026-10-21 00:18+0200\n"
12
+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13
+ "Language: es\n"
14
+ "Language-Team: es <LL@li.org>\n"
15
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
16
+ "MIME-Version: 1.0\n"
17
+ "Content-Type: text/plain; charset=utf-8\n"
18
+ "Content-Transfer-Encoding: 8bit\n"
19
+ "Generated-By: Babel 2.18.0\n"
20
+
21
+ #: src/splent_io/splent_feature_github/templates/hooks/github_profile_row.html:12
22
+ msgid "Disconnect"
23
+ msgstr "Desconectar"
24
+
25
+ #: src/splent_io/splent_feature_github/templates/hooks/github_profile_row.html:27
26
+ msgid "Connect GitHub"
27
+ msgstr "Conectar GitHub"
28
+