This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 06845321bc refactor(auth): store the identity provider's full avatar
URL (#7563)
06845321bc is described below
commit 06845321bc90800d3da77dcceebc63a94d1951d5
Author: Neil Ketteringham <[email protected]>
AuthorDate: Tue Aug 11 21:34:42 2026 -0700
refactor(auth): store the identity provider's full avatar URL (#7563)
### What changes were proposed in this PR?
`"user".avatar` holds only the last path segment of Google's `picture`
claim, and the frontend
rebuilds around it. Migration 33 renamed the user column from
`google_avatar` but deliberately kept every value as-is, so the *value*
is still Google-only: no other identity provider can be represented in
it.
This stores the complete URL the provider supplied, and takes the
Google-specific naming off the
wire along with it.
**Avatar value**
- New `common/util/AvatarUtil`: keeps an avatar only when it is an
`http(s)` URL on an allowlisted
host (`googleusercontent.com` today), and drops anything else rather
than failing the login.
Storing a provider-chosen URL is what makes this necessary — the old
fragment-plus-hardcoded-host
scheme gave that guarantee implicitly.
- `GoogleAuthResource.profileOf` keeps `picture` whole instead of
`_.split("/").lastOption`.
- `ExternalProfile.avatar` becomes `Option[String]`, so "no avatar we
would store" is one case that
leaves the column alone. Previously a payload without `picture`
overwrote a stored avatar with
`""`.
- `sql/updates/35.sql` widens the column to `VARCHAR(512)`, normalizes
`''` to `NULL`, and promotes
existing fragments to absolute URLs. Idempotent via a `NOT LIKE 'http%'`
guard.
- The frontend fetches the stored URL verbatim; `getAvatar`'s argument
and cache key are the URL.
**Naming**
- The JWT claim `googleAvatar` is now `avatar`. Tokens live for
`auth.jwt.expiration-in-minutes` (a week by default), so both
`JwtParser` and `auth.service.ts`
read the new name and fall back to the old one — otherwise every
already-signed-in user loses
their avatar until their token is reissued. Both fallbacks are commented
as deletable once
pre-rename tokens have expired, and are pinned by tests.
- The same rename lands on the DTO fields that carry the value to the
browser
(`UserInfo.avatar`, `WorkflowExecutionEntry.avatar`,
`DashboardWorkflowComputingUnit.ownerAvatar`),
the matching TS types, and `UserAvatarComponent`'s `@Input`.
### Any related issues, documentation, discussions?
Closes #7296
### How was this PR tested?
- `sbt scalafmtCheckAll "scalafixAll --check" Test/compile Util/test
Auth/test` — clean;
`Util/test` 24, `Auth/test` 99.
- `WorkflowExecutionService/testOnly *GoogleAuthResourceSpec
*ExternalAuthProvisionerSpec
*AuthResourceSpec *AdminUserResourceSpec *WorkflowExecutionsResourceSpec
*DashboardResourceSpec`
— 116 tests, 0 failures. `ComputingUnitManagingService/test` — 91, 0
failures.
- New coverage: `AvatarUtilSpec` (allowlist, subdomains, lookalike
hosts, non-`http(s)` schemes,
blank/absent); avatar cases in `GoogleAuthResourceSpec` (full URL
stored, rotation, absent
picture, keep-on-absent, disallowed host) and
`ExternalAuthProvisionerSpec` (`None` leaves a
stored avatar alone); claim back-compat in `JwtParserSpec` and
`auth.service.spec.ts`.
- Frontend: `yarn ng test` — 201 files / 4419 tests, 0 failures; `yarn
format:ci` clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
---
.../resource/auth/ExternalAuthProvisioner.scala | 12 ++--
.../web/resource/auth/GoogleAuthResource.scala | 10 +--
.../web/resource/dashboard/DashboardResource.scala | 6 +-
.../dashboard/admin/user/AdminUserResource.scala | 6 +-
.../user/workflow/WorkflowExecutionsResource.scala | 2 +-
.../texera/web/auth/UserAuthenticatorSpec.scala | 2 +-
.../auth/ExternalAuthProvisionerSpec.scala | 41 ++++++++---
.../web/resource/auth/GoogleAuthResourceSpec.scala | 45 ++++++++++--
.../admin/user/AdminUserResourceSpec.scala | 2 +-
.../workflow/WorkflowExecutionsResourceSpec.scala | 4 +-
.../scala/org/apache/texera/auth/JwtAuth.scala | 9 +--
.../scala/org/apache/texera/auth/JwtParser.scala | 9 ++-
.../org/apache/texera/auth/JwtAuthFilterSpec.scala | 2 +-
.../scala/org/apache/texera/auth/JwtAuthSpec.scala | 2 +-
.../org/apache/texera/auth/JwtParserSpec.scala | 22 +++++-
.../org/apache/texera/common/util/AvatarUtil.scala | 65 +++++++++++++++++
.../apache/texera/common/util/AvatarUtilSpec.scala | 81 ++++++++++++++++++++++
.../resource/ComputingUnitManagingResource.scala | 10 +--
.../texera/service/util/ComputingUnitHelpers.scala | 2 +-
.../resource/AdminComputingUnitResourceSpec.scala | 4 +-
.../service/util/ComputingUnitHelpersSpec.scala | 4 +-
.../app/common/service/user/auth.service.spec.ts | 12 +++-
.../src/app/common/service/user/auth.service.ts | 5 +-
.../app/common/service/user/stub-user.service.ts | 2 +-
.../app/common/service/user/user.service.spec.ts | 27 ++++++--
.../src/app/common/service/user/user.service.ts | 22 ++++--
frontend/src/app/common/type/user.ts | 2 +-
.../src/app/common/type/workflow-computing-unit.ts | 2 +-
.../app/common/util/computing-unit.util.spec.ts | 2 +-
.../component/admin/user/admin-user.component.html | 2 +-
.../component/user-dashboard-test-fixtures.ts | 6 +-
.../list-item/card-item/card-item.component.html | 2 +-
.../user/list-item/list-item.component.html | 2 +-
.../user/user-avatar/user-avatar.component.ts | 11 +--
...user-computing-unit-list-item.component.spec.ts | 2 +-
.../user-computing-unit.component.spec.ts | 2 +-
.../user/user-icon/user-icon.component.html | 2 +-
.../workflow-execution-history.component.html | 2 +-
.../workflow-execution-history.component.spec.ts | 2 +-
.../user-workflow/user-workflow.component.spec.ts | 4 +-
.../user/user-workflow/user-workflow.component.ts | 2 +-
.../app/dashboard/service/user/search.service.ts | 2 +-
.../dashboard/service/user/stub-search.service.ts | 2 +-
.../src/app/dashboard/type/dashboard-entry.spec.ts | 12 ++--
frontend/src/app/dashboard/type/dashboard-entry.ts | 18 ++---
.../src/app/dashboard/type/type-predicates.spec.ts | 2 +-
.../dashboard/type/workflow-executions-entry.ts | 2 +-
.../browse-section/browse-section.component.html | 2 +-
.../coeditor-user-icon.component.html | 2 +-
.../computing-unit-selection.component.html | 4 +-
.../computing-unit-selection.component.spec.ts | 2 +-
sql/changelog.xml | 5 ++
sql/texera_ddl.sql | 2 +-
sql/updates/35.sql | 43 ++++++++++++
54 files changed, 431 insertions(+), 120 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
index c89d5b72ba..f5bb43cbf7 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala
@@ -37,13 +37,17 @@ import scala.util.chaining.scalaUtilChainingOps
* `email` must be non-blank and provider-verified: `loginOrProvision` links
the identity to the
* account owning that address and claims its placeholder, so an unverified
address is a
* takeover. Each provider checks this in its own mapping function (Google:
`email_verified`).
+ *
+ * `avatar` is the complete URL the provider supplied, already sanitized by
`AvatarUtil`.
+ * `None` means the provider offered no avatar we would store, in which case
the account keeps
+ * whatever is on file rather than having it blanked.
*/
final case class ExternalProfile(
providerType: ProviderTypeEnum,
providerId: String,
name: String,
email: String,
- avatar: String
+ avatar: Option[String]
)
object ExternalAuthProvisioner extends LazyLogging {
@@ -106,7 +110,7 @@ object ExternalAuthProvisioner extends LazyLogging {
val created = new User()
created.setName(profile.name)
created.setEmail(profile.email)
- created.setAvatar(profile.avatar)
+ profile.avatar.foreach(created.setAvatar)
created.setRole(UserRoleEnum.INACTIVE)
txUserDao.insert(created)
created
@@ -144,8 +148,8 @@ object ExternalAuthProvisioner extends LazyLogging {
user.setEmail(profile.email)
changed = true
}
- if (user.getAvatar != profile.avatar) {
- user.setAvatar(profile.avatar)
+ profile.avatar.filter(_ != user.getAvatar).foreach { url =>
+ user.setAvatar(url)
changed = true
}
changed
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
index 6dd81c8cb0..89f1cc5000 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala
@@ -24,6 +24,7 @@ import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
import org.apache.texera.common.config.UserSystemConfig
+import org.apache.texera.common.util.AvatarUtil
import org.apache.texera.dao.jooq.generated.enums.ProviderTypeEnum
import org.apache.texera.web.model.http.response.TokenIssueResponse
@@ -38,8 +39,9 @@ object GoogleAuthResource {
/**
* Reduce a verified Google id-token payload to the fields we persist.
Google omits `name`
* for accounts with no profile name, and the provisioner writes `name`
straight to a NOT
- * NULL column, so the address stands in for it. Only the last path segment
of `picture` is
- * kept — the frontend rebuilds the full `lh3.googleusercontent.com` URL
around it.
+ * NULL column, so the address stands in for it. `picture` is kept as the
complete URL Google
+ * supplied — see [[AvatarUtil]] for why the column no longer holds a
Google-only fragment,
+ * and for the host allowlist that bounds what may be stored.
*
* A payload with no address, or whose `email_verified` is not true, is
refused rather than
* mapped — see [[ExternalProfile]] for why. Absent is not true: Google may
omit the claim, and
@@ -58,9 +60,7 @@ object GoogleAuthResource {
payload.getSubject,
Option(payload.get("name").asInstanceOf[String]).filter(_.nonEmpty).getOrElse(googleEmail),
googleEmail,
- avatar = Option(payload.get("picture").asInstanceOf[String])
- .flatMap(_.split("/").lastOption)
- .getOrElse("")
+ avatar =
AvatarUtil.sanitize(Option(payload.get("picture").asInstanceOf[String]))
)
}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
index 795a19ff10..1dbb80f545 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
@@ -42,7 +42,7 @@ object DashboardResource {
dataset: Option[DashboardDataset] = None
)
- case class UserInfo(userId: Integer, userName: String, googleAvatar:
Option[String])
+ case class UserInfo(userId: Integer, userName: String, avatar:
Option[String])
case class DashboardSearchResult(
results: List[DashboardClickableFileEntry],
@@ -230,8 +230,8 @@ class DashboardResource {
.map { record =>
val userId = record.get(USER.UID)
val userName = record.get(USER.NAME)
- val googleAvatar = Option(record.get(USER.AVATAR))
- userId -> UserInfo(userId, userName, googleAvatar)
+ val avatar = Option(record.get(USER.AVATAR))
+ userId -> UserInfo(userId, userName, avatar)
}
.toMap
.asJava
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
index b550ac46e0..645a371d74 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala
@@ -45,9 +45,7 @@ case class UserInfo(
email: String,
googleId: String,
role: UserRoleEnum,
- // `"user".avatar` is no longer Google-specific, but this is the JSON key
- // `admin-user.component.html` binds, so the wire name stays until the
frontend migrates.
- googleAvatar: String,
+ avatar: String,
comment: String,
lastLogin: java.time.OffsetDateTime, // will be null if never logged in
accountCreation: java.time.OffsetDateTime,
@@ -93,7 +91,7 @@ class AdminUserResource {
// documentation; they do not drive the mapping.
googleProvider.PROVIDER_ID.as("googleId"),
USER.ROLE,
- USER.AVATAR.as("googleAvatar"),
+ USER.AVATAR,
USER.COMMENT,
USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME,
USER.ACCOUNT_CREATION_TIME,
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
index 7e847c43c0..cab6bc1203 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
@@ -528,7 +528,7 @@ object WorkflowExecutionsResource {
cuId: Integer,
whId: Integer,
userName: String,
- googleAvatar: String,
+ avatar: String,
status: Byte,
result: String,
startingTime: Timestamp,
diff --git
a/amber/src/test/scala/org/apache/texera/web/auth/UserAuthenticatorSpec.scala
b/amber/src/test/scala/org/apache/texera/web/auth/UserAuthenticatorSpec.scala
index 6c62f9fa2d..a435999aa1 100644
---
a/amber/src/test/scala/org/apache/texera/web/auth/UserAuthenticatorSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/auth/UserAuthenticatorSpec.scala
@@ -37,7 +37,7 @@ class UserAuthenticatorSpec extends AnyFlatSpec with Matchers
{
claims.setClaim("userId", 42)
claims.setClaim("email", "[email protected]")
claims.setClaim("role", UserRoleEnum.ADMIN.name)
- claims.setClaim("googleAvatar", "avatar-blob")
+ claims.setClaim("avatar", "avatar-blob")
claims.setExpirationTimeMinutesInTheFuture(10f)
claims
}
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
index 44b2efe683..9db28a2bed 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisionerSpec.scala
@@ -66,11 +66,14 @@ class ExternalAuthProvisionerSpec
// ---- helpers -------------------------------------------------------------
+ /** Avatars are complete provider URLs now, so tests that assert on one have
to use a URL. */
+ private def avatarUrl(id: String): String =
s"https://lh3.googleusercontent.com/a/$id"
+
private def profile(
providerId: String,
name: String,
email: String,
- avatar: String = "pic"
+ avatar: Option[String] = Some(avatarUrl("pic"))
): ExternalProfile =
ExternalProfile(ProviderTypeEnum.GOOGLE, providerId, name, email, avatar)
@@ -117,13 +120,13 @@ class ExternalAuthProvisionerSpec
"ExternalAuthProvisioner.loginOrProvision" should "create an INACTIVE user
and provider row for a brand-new Google identity" in {
val user = ExternalAuthProvisioner.loginOrProvision(
- profile("google-sub-1", "New User", "new" + emailDomain, avatar =
"avatar1")
+ profile("google-sub-1", "New User", "new" + emailDomain, avatar =
Some(avatarUrl("a1")))
)
user.getUid should not be null
user.getName shouldBe "New User"
user.getEmail shouldBe "new" + emailDomain
- user.getAvatar shouldBe "avatar1"
+ user.getAvatar shouldBe avatarUrl("a1")
user.getRole shouldBe UserRoleEnum.INACTIVE
providerRowCount(user.getUid) shouldBe 1
@@ -133,7 +136,7 @@ class ExternalAuthProvisionerSpec
// ---- returning known identity --------------------------------------------
it should "be idempotent for a returning identity (same uid, no duplicate
provider row or user)" in {
- val p = profile("google-sub-return", "Ret", "ret" + emailDomain, avatar =
"a")
+ val p = profile("google-sub-return", "Ret", "ret" + emailDomain, avatar =
Some(avatarUrl("a")))
val first = ExternalAuthProvisioner.loginOrProvision(p)
val second = ExternalAuthProvisioner.loginOrProvision(p)
@@ -145,17 +148,39 @@ class ExternalAuthProvisionerSpec
it should "refresh drifted profile fields for a known identity" in {
ExternalAuthProvisioner.loginOrProvision(
- profile("sub-drift", "Old Name", "drift" + emailDomain, avatar =
"oldpic")
+ profile("sub-drift", "Old Name", "drift" + emailDomain, avatar =
Some(avatarUrl("oldpic")))
)
val updated = ExternalAuthProvisioner.loginOrProvision(
- profile("sub-drift", "New Name", "drift" + emailDomain, avatar =
"newpic")
+ profile("sub-drift", "New Name", "drift" + emailDomain, avatar =
Some(avatarUrl("newpic")))
)
updated.getName shouldBe "New Name"
- updated.getAvatar shouldBe "newpic"
+ updated.getAvatar shouldBe avatarUrl("newpic")
// confirm it persisted, not just mutated in memory
userDao.fetchOneByUid(updated.getUid).getName shouldBe "New Name"
- userDao.fetchOneByUid(updated.getUid).getAvatar shouldBe "newpic"
+ userDao.fetchOneByUid(updated.getUid).getAvatar shouldBe
avatarUrl("newpic")
+ }
+
+ // `None` is "the provider offered no avatar we would store", which must not
blank a working one:
+ // otherwise a payload that omits `picture`, or names a host the allowlist
rejects, wipes it.
+ it should "leave the stored avatar untouched when the provider supplies
none" in {
+ val created = ExternalAuthProvisioner.loginOrProvision(
+ profile("sub-keeppic", "Keeper", "keeppic" + emailDomain, avatar =
Some(avatarUrl("kept")))
+ )
+
+ ExternalAuthProvisioner.loginOrProvision(
+ profile("sub-keeppic", "Keeper", "keeppic" + emailDomain, avatar = None)
+ )
+
+ userDao.fetchOneByUid(created.getUid).getAvatar shouldBe avatarUrl("kept")
+ }
+
+ it should "leave a new account's avatar unset when the provider supplies
none" in {
+ val created = ExternalAuthProvisioner.loginOrProvision(
+ profile("sub-nopic", "Pictureless", "nopic" + emailDomain, avatar = None)
+ )
+
+ userDao.fetchOneByUid(created.getUid).getAvatar shouldBe null
}
it should "adopt the provider's new email address for a known identity" in {
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/auth/GoogleAuthResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/auth/GoogleAuthResourceSpec.scala
index 02aed67951..78eb974eba 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/auth/GoogleAuthResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/auth/GoogleAuthResourceSpec.scala
@@ -38,8 +38,8 @@ import javax.ws.rs.NotAuthorizedException
* Token verification is the one part that cannot run here — it needs a
Google-signed JWT and a
* network round trip — so the suite overrides `verifiedPayload` and drives
the resource with
* payloads built by hand. What it pins down is everything downstream of
verification: how a
- * Google payload becomes an [[ExternalProfile]] (the name fallback and the
avatar reduced to
- * its last path segment) and that a credential Google does not verify is a
401.
+ * Google payload becomes an [[ExternalProfile]] (the name fallback, and the
avatar kept as the
+ * complete URL the provider supplied) and that a credential Google does not
verify is a 401.
*/
class GoogleAuthResourceSpec
extends AnyFlatSpec
@@ -49,6 +49,7 @@ class GoogleAuthResourceSpec
with MockTexeraDB {
private val emailDomain = "@google-auth-test.com"
+ private val avatarUrl = "https://lh3.googleusercontent.com/a/AVATAR-ID"
private var userDao: UserDao = _
@@ -84,7 +85,7 @@ class GoogleAuthResourceSpec
subject: String,
email: String,
name: String = "Given Name",
- picture: String = "https://lh3.googleusercontent.com/a/AVATAR-ID",
+ picture: String = avatarUrl,
emailVerified: java.lang.Boolean = true
): GoogleIdToken.Payload = {
val p = new GoogleIdToken.Payload()
@@ -150,16 +151,46 @@ class GoogleAuthResourceSpec
userByEmail("blank").getName shouldBe "blank" + emailDomain
}
- it should "store only the last path segment of the picture URL" in {
+ it should "store the picture URL in full rather than its last path segment"
in {
loginWith(payload("google-sub-avatar", "avatar" + emailDomain))
- userByEmail("avatar").getAvatar shouldBe "AVATAR-ID"
+ userByEmail("avatar").getAvatar shouldBe avatarUrl
}
- it should "store an empty avatar when the payload carries no picture" in {
+ it should "refresh the stored avatar when the provider's URL changes" in {
+ loginWith(payload("google-sub-newpic", "newpic" + emailDomain))
+ val rotated = "https://lh3.googleusercontent.com/a/ROTATED-ID"
+
+ loginWith(payload("google-sub-newpic", "newpic" + emailDomain, picture =
rotated))
+
+ userByEmail("newpic").getAvatar shouldBe rotated
+ }
+
+ it should "leave the avatar unset when the payload carries no picture" in {
loginWith(payload("google-sub-nopic", "nopic" + emailDomain, picture =
null))
- userByEmail("nopic").getAvatar shouldBe ""
+ userByEmail("nopic").getAvatar shouldBe null
+ }
+
+ // "No avatar" is one case rather than two: an absent picture leaves what is
on file instead of
+ // blanking it, so a payload that omits the claim does not wipe a working
avatar.
+ it should "keep an already-stored avatar when a later login carries no
picture" in {
+ loginWith(payload("google-sub-keeppic", "keeppic" + emailDomain))
+ userByEmail("keeppic").getAvatar shouldBe avatarUrl
+
+ loginWith(payload("google-sub-keeppic", "keeppic" + emailDomain, picture =
null))
+
+ userByEmail("keeppic").getAvatar shouldBe avatarUrl
+ }
+
+ // Storing the provider's URL verbatim means the browser fetches whatever it
names, so the host
+ // is allowlisted on write; a rejected avatar is dropped, not a failed login.
+ it should "not store an avatar served from a host outside the allowlist" in {
+ loginWith(
+ payload("google-sub-badhost", "badhost" + emailDomain, picture =
"https://evil.test/a/ID")
+ )
+
+ userByEmail("badhost").getAvatar shouldBe null
}
// ---- verification failure ------------------------------------------------
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala
index b88fe2a870..c839d29364 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala
@@ -220,7 +220,7 @@ class AdminUserResourceSpec
val listed = resource.list().asScala.find(_.uid == primaryUid)
- listed.map(u => (u.name, u.googleId, u.googleAvatar)) shouldBe Some(
+ listed.map(u => (u.name, u.googleId, u.avatar)) shouldBe Some(
("dual", "google-sub-dual", "avatar-blob")
)
}
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
index 48973699cf..af8c24f05a 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResourceSpec.scala
@@ -949,7 +949,7 @@ class WorkflowExecutionsResourceSpec
// fetchInto maps onto WorkflowExecutionEntry POSITIONALLY (a case class has
no no-arg
// constructor, and jOOQ's mapConstructorParameterNames defaults to false),
so `USER.AVATAR`
- // at projection position 5 lands on `googleAvatar` despite the names
differing — exactly as
+ // at projection position 5 lands on `avatar` despite the names differing —
exactly as
// `last_update_time` at position 9 lands on `completionTime`. Neither
mapping was asserted
// anywhere before, which is what makes an accidental column reorder silent.
Pin both here.
it should "map the owner's avatar and completion time onto the entry despite
the name mismatch" in {
@@ -957,7 +957,7 @@ class WorkflowExecutionsResourceSpec
insertExecution(lastUpdateOffsetMillis = Some(0L))
val entry = resource.retrieveExecutionsOfWorkflow(testWorkflowWid,
session(testUser), null).head
assert(entry.userName == testUser.getName)
- assert(entry.googleAvatar == "avatar_url")
+ assert(entry.avatar == "avatar_url")
// `last_update_time` is populated, so a null here would mean position 9
never reached
// `completionTime` — i.e. the mapping had silently become name-based.
assert(entry.completionTime != null)
diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala
b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala
index d3270bfa0a..bfdf7cf08b 100644
--- a/common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala
+++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala
@@ -54,9 +54,10 @@ object JwtAuth {
/**
* Build the claim set for `user`. The claim names are a contract with the
hand-written
* TypeScript reader in
`frontend/src/app/common/service/user/auth.service.ts`, which is not
- * compiled against this file — so renaming one here silently breaks the
frontend. `avatar`
- * now lives on `"user"` rather than a Google-specific column, but the
claim keeps its
- * `googleAvatar` name until the frontend is migrated in lockstep.
+ * compiled against this file — so renaming one here silently breaks the
frontend. The `avatar`
+ * claim was `googleAvatar` until the column and the value stopped being
Google-specific; the
+ * TypeScript reader was renamed in the same change, and both sides still
accept the old name
+ * so tokens issued before it keep working (see [[JwtParser]] for when that
can go).
*
* `googleId` is passed in rather than read off `user`, because the GOOGLE
provider id lives
* in `auth_provider` and this module must stay DB-free: the specs in
@@ -78,7 +79,7 @@ object JwtAuth {
claims.setClaim("userId", user.getUid)
claims.setClaim("email", user.getEmail)
claims.setClaim("role", user.getRole)
- claims.setClaim("googleAvatar", user.getAvatar)
+ claims.setClaim("avatar", user.getAvatar)
googleId.foreach(claims.setClaim("googleId", _))
claims.setExpirationTimeMinutesInTheFuture(TOKEN_EXPIRE_TIME_IN_MINUTES.toFloat)
claims
diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
index 6cd540c285..157dfb9bf9 100644
--- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
+++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala
@@ -62,7 +62,12 @@ object JwtParser extends LazyLogging {
// call writes Integer; widen via Number to handle both cases.
val userId = claims.getClaimValue("userId", classOf[Number]).intValue()
val role =
UserRoleEnum.valueOf(claims.getClaimValue("role").asInstanceOf[String])
- val googleAvatar = claims.getClaimValue("googleAvatar", classOf[String])
+ // This claim was named `googleAvatar` until the column and the value
stopped being
+ // Google-specific. Tokens live for `auth.jwt.expiration-in-minutes` (a
week by default), so
+ // the old name is still read; the fallback can go once every token
predating the rename has
+ // expired.
+ val avatar = Option(claims.getClaimValue("avatar", classOf[String]))
+ .getOrElse(claims.getClaimValue("googleAvatar", classOf[String]))
// The `googleId` claim is deliberately written but not read back: nothing
server-side
// needs it (credentials live in auth_provider), and the only consumer is
the frontend,
// which reads it straight off the raw token.
@@ -73,7 +78,7 @@ object JwtParser extends LazyLogging {
user.setName(userName)
user.setEmail(email)
user.setRole(role)
- user.setAvatar(googleAvatar)
+ user.setAvatar(avatar)
}
)
}
diff --git
a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
index dfab579d48..09087cd0fe 100644
--- a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
+++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthFilterSpec.scala
@@ -96,7 +96,7 @@ class JwtAuthFilterSpec extends AnyFlatSpec with Matchers {
c.setClaim("googleId", "g-123")
c.setClaim("email", "[email protected]")
c.setClaim("role", UserRoleEnum.ADMIN.name)
- c.setClaim("googleAvatar", "avatar")
+ c.setClaim("avatar", "avatar")
c.setExpirationTimeMinutesInTheFuture(10f)
c
}
diff --git
a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala
b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala
index 6ae84a4200..894f49e661 100644
--- a/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala
+++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtAuthSpec.scala
@@ -43,7 +43,7 @@ class JwtAuthSpec extends AnyFlatSpec with Matchers {
claims.getSubject shouldBe "alice"
claims.getClaimValueAsString("userId") shouldBe "42"
claims.getClaimValueAsString("email") shouldBe "[email protected]"
- claims.getClaimValueAsString("googleAvatar") shouldBe "avatar-blob"
+ claims.getClaimValueAsString("avatar") shouldBe "avatar-blob"
claims.getClaimValueAsString("role") shouldBe UserRoleEnum.ADMIN.name
}
diff --git
a/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala
b/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala
index f18185aad8..1784ce8e9c 100644
--- a/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala
+++ b/common/auth/src/test/scala/org/apache/texera/auth/JwtParserSpec.scala
@@ -40,7 +40,7 @@ class JwtParserSpec extends AnyFlatSpec with Matchers {
claims.setClaim("userId", 42)
claims.setClaim("email", "[email protected]")
claims.setClaim("role", UserRoleEnum.ADMIN.name)
- claims.setClaim("googleAvatar", "avatar-blob")
+ claims.setClaim("avatar", "avatar-blob")
claims.setExpirationTimeMinutesInTheFuture(10f)
claims
}
@@ -54,6 +54,24 @@ class JwtParserSpec extends AnyFlatSpec with Matchers {
user.getRole shouldBe UserRoleEnum.ADMIN
}
+ // The claim was `googleAvatar` before the avatar stopped being
Google-specific. Tokens minted
+ // before the rename stay valid for a week, so a session built from one must
still carry its
+ // avatar rather than silently losing it.
+ it should "still read the avatar from a pre-rename googleAvatar claim" in {
+ val claims = buildClaims()
+ claims.unsetClaim("avatar")
+ claims.setClaim("googleAvatar", "legacy-blob")
+
+ JwtParser.claimsToSessionUser(claims).getUser.getAvatar shouldBe
"legacy-blob"
+ }
+
+ it should "prefer the avatar claim when a token carries both names" in {
+ val claims = buildClaims()
+ claims.setClaim("googleAvatar", "legacy-blob")
+
+ JwtParser.claimsToSessionUser(claims).getUser.getAvatar shouldBe
"avatar-blob"
+ }
+
it should "leave non-issued slots null (comment, accountCreation,
affiliation, joiningReason)" in {
val user: User = JwtParser.claimsToSessionUser(buildClaims()).getUser
user.getComment shouldBe null
@@ -158,7 +176,7 @@ class JwtParserSpec extends AnyFlatSpec with Matchers {
bob.setClaim("userId", 7)
bob.setClaim("email", "[email protected]")
bob.setClaim("role", UserRoleEnum.REGULAR.name)
- bob.setClaim("googleAvatar", "bob-avatar")
+ bob.setClaim("avatar", "bob-avatar")
bob.setExpirationTimeMinutesInTheFuture(10f)
val aliceUser = JwtParser.parseToken(JwtAuth.jwtToken(alice)).get().getUser
diff --git
a/common/util/src/main/scala/org/apache/texera/common/util/AvatarUtil.scala
b/common/util/src/main/scala/org/apache/texera/common/util/AvatarUtil.scala
new file mode 100644
index 0000000000..0518584942
--- /dev/null
+++ b/common/util/src/main/scala/org/apache/texera/common/util/AvatarUtil.scala
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.common.util
+
+import java.net.URI
+import scala.util.Try
+
+/**
+ * Profile-picture URLs as an identity provider supplies them.
+ *
+ * `"user".avatar` used to hold only the last path segment of Google's
`picture` claim, with the
+ * frontend rebuilding `https://lh3.googleusercontent.com/a/<fragment>`
around it — which made
+ * the stored value meaningless for any other provider. The complete URL is
stored instead, so
+ * the column is provider-neutral; the cost is that a provider now picks a
URL the browser will
+ * fetch, which is what the allowlist below bounds.
+ */
+object AvatarUtil {
+
+ /** An exact host or any subdomain of one of these may serve an avatar. */
+ private val ALLOWED_HOST_SUFFIXES: Set[String] = Set(
+ "googleusercontent.com"
+ )
+
+ private[util] def isAllowedHost(host: String): Boolean = {
+ if (host == null || host.isEmpty) return false
+ val lower = host.toLowerCase
+ ALLOWED_HOST_SUFFIXES.exists(suffix => lower == suffix ||
lower.endsWith("." + suffix))
+ }
+
+ /**
+ * The avatar URL to persist, or `None` to leave the stored value alone.
`None` also covers a
+ * provider that supplies no picture, so "no avatar" is a single case for
callers: the user
+ * keeps whatever is on file and falls back to the initials avatar if that
is nothing.
+ *
+ * Anything that is not an http(s) URL on an allowlisted host is dropped
rather than rejected —
+ * a surprising avatar is not a reason to deny someone a login.
+ */
+ def sanitize(url: Option[String]): Option[String] =
+ url.map(_.trim).filter(_.nonEmpty).filter { candidate =>
+ Try(URI.create(candidate)).toOption
+ .filter { uri =>
+ val scheme = Option(uri.getScheme).map(_.toLowerCase)
+ scheme.contains("http") || scheme.contains("https")
+ }
+ .flatMap(uri => Option(uri.getHost))
+ .exists(isAllowedHost)
+ }
+}
diff --git
a/common/util/src/test/scala/org/apache/texera/common/util/AvatarUtilSpec.scala
b/common/util/src/test/scala/org/apache/texera/common/util/AvatarUtilSpec.scala
new file mode 100644
index 0000000000..a3a3df6631
--- /dev/null
+++
b/common/util/src/test/scala/org/apache/texera/common/util/AvatarUtilSpec.scala
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.common.util
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class AvatarUtilSpec extends AnyFlatSpec with Matchers {
+
+ private val avatar = "https://lh3.googleusercontent.com/a/AVATAR-ID"
+
+ "sanitize" should "keep a complete https URL on an allowlisted host" in {
+ AvatarUtil.sanitize(Some(avatar)) shouldBe Some(avatar)
+ }
+
+ it should "keep an avatar on a subdomain of an allowlisted host" in {
+ val url = "https://lh5.googleusercontent.com/a/OTHER-ID"
+ AvatarUtil.sanitize(Some(url)) shouldBe Some(url)
+ }
+
+ it should "accept http as well as https" in {
+ val url = "http://lh3.googleusercontent.com/a/AVATAR-ID"
+ AvatarUtil.sanitize(Some(url)) shouldBe Some(url)
+ }
+
+ it should "trim surrounding whitespace" in {
+ AvatarUtil.sanitize(Some(s" $avatar ")) shouldBe Some(avatar)
+ }
+
+ // Dropping rather than rejecting: the caller treats None as "the provider
supplied no avatar",
+ // which leaves the stored value alone instead of failing the login.
+ it should "drop a URL on a host outside the allowlist" in {
+ AvatarUtil.sanitize(Some("https://evil.example.com/a/AVATAR-ID")) shouldBe
None
+ }
+
+ // A suffix match must not let a lookalike domain through.
+ it should "drop a host that merely ends with the allowlisted string" in {
+ AvatarUtil.sanitize(Some("https://notgoogleusercontent.com/a/ID"))
shouldBe None
+ }
+
+ it should "drop a non-http(s) URL" in {
+ AvatarUtil.sanitize(Some("javascript:alert(1)")) shouldBe None
+ AvatarUtil.sanitize(Some("data:image/png;base64,AAAA")) shouldBe None
+ }
+
+ it should "drop a value that is not a URL at all" in {
+ AvatarUtil.sanitize(Some("AVATAR-ID")) shouldBe None
+ AvatarUtil.sanitize(Some("not a uri at all")) shouldBe None
+ }
+
+ it should "map an absent, empty, or blank picture to None" in {
+ AvatarUtil.sanitize(None) shouldBe None
+ AvatarUtil.sanitize(Some("")) shouldBe None
+ AvatarUtil.sanitize(Some(" ")) shouldBe None
+ }
+
+ "isAllowedHost" should "match the bare host and its subdomains,
case-insensitively" in {
+ AvatarUtil.isAllowedHost("googleusercontent.com") shouldBe true
+ AvatarUtil.isAllowedHost("LH3.GoogleUserContent.com") shouldBe true
+ AvatarUtil.isAllowedHost("example.com") shouldBe false
+ AvatarUtil.isAllowedHost("") shouldBe false
+ AvatarUtil.isAllowedHost(null) shouldBe false
+ }
+}
diff --git
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala
index 6e8a165e3b..effd9a4499 100644
---
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala
+++
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/ComputingUnitManagingResource.scala
@@ -159,7 +159,7 @@ object ComputingUnitManagingResource {
metrics: WorkflowComputingUnitMetrics,
isOwner: Boolean,
accessPrivilege: EnumType,
- ownerGoogleAvatar: String,
+ ownerAvatar: String,
ownerName: String
)
@@ -398,7 +398,7 @@ class ComputingUnitManagingResource {
val userDao = new UserDao(ctx.configuration())
val ownerUser = Option(userDao.fetchOneByUid(user.getUid))
- val ownerGoogleAvatar: String =
+ val ownerAvatar: String =
ownerUser.flatMap(u => Option(u.getAvatar).filter(_.nonEmpty)).orNull
val ownerUsername: String =
ownerUser.flatMap(u => Option(u.getName).filter(_.nonEmpty)).orNull
@@ -449,7 +449,7 @@ class ComputingUnitManagingResource {
ComputingUnitHelpers.getComputingUnitMetrics(insertedUnit),
isOwner = true,
accessPrivilege = PrivilegeEnum.WRITE,
- ownerGoogleAvatar,
+ ownerAvatar,
ownerUsername
)
}
@@ -558,7 +558,7 @@ class ComputingUnitManagingResource {
val unit = getComputingUnitByCuid(context, cuid)
val userDao = new UserDao(context.configuration())
val ownerUser = Option(userDao.fetchOneByUid(unit.getUid))
- val ownerGoogleAvatar: String =
+ val ownerAvatar: String =
ownerUser.flatMap(u => Option(u.getAvatar).filter(_.nonEmpty)).orNull
val ownerUsername: String =
ownerUser.flatMap(u => Option(u.getName).filter(_.nonEmpty)).orNull
@@ -584,7 +584,7 @@ class ComputingUnitManagingResource {
PrivilegeEnum.NONE
}
},
- ownerGoogleAvatar,
+ ownerAvatar,
ownerUsername
)
}
diff --git
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/ComputingUnitHelpers.scala
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/ComputingUnitHelpers.scala
index aaff9943d9..509603c243 100644
---
a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/ComputingUnitHelpers.scala
+++
b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/ComputingUnitHelpers.scala
@@ -225,7 +225,7 @@ object ComputingUnitHelpers {
metrics = getComputingUnitMetrics(unit, podMetrics),
isOwner = isOwner,
accessPrivilege = accessPrivilege,
- ownerGoogleAvatar = owner._1,
+ ownerAvatar = owner._1,
ownerName = owner._2
)
}
diff --git
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala
index f3d2f9e498..f1735935c1 100644
---
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala
+++
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala
@@ -69,7 +69,7 @@ class AdminComputingUnitResourceSpec
u.setRole(UserRoleEnum.ADMIN)
// Credentials live in auth_provider now, and this spec exercises the
listing rather than
// login, so the user needs none. The avatar column is provider-neutral;
the DTO field it
- // feeds is still named ownerGoogleAvatar.
+ // feeds is still named ownerAvatar.
u.setAvatar(s"avatar-$uid")
u
}
@@ -117,7 +117,7 @@ class AdminComputingUnitResourceSpec
// isOwner tracks the caller; owner name/avatar are joined from the user
table.
byCuid(700).isOwner shouldBe true
byCuid(700).ownerName shouldBe "admin"
- byCuid(700).ownerGoogleAvatar shouldBe "avatar-700"
+ byCuid(700).ownerAvatar shouldBe "avatar-700"
byCuid(701).isOwner shouldBe false
byCuid(701).ownerName shouldBe "other"
}
diff --git
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/ComputingUnitHelpersSpec.scala
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/ComputingUnitHelpersSpec.scala
index 05e6fe88bb..5cf6fae214 100644
---
a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/ComputingUnitHelpersSpec.scala
+++
b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/ComputingUnitHelpersSpec.scala
@@ -223,7 +223,7 @@ class ComputingUnitHelpersSpec
row.accessPrivilege shouldBe PrivilegeEnum.READ
row.status shouldBe "Running"
row.metrics shouldBe WorkflowComputingUnitMetrics("100m", "64Mi")
- row.ownerGoogleAvatar shouldBe "avatar"
+ row.ownerAvatar shouldBe "avatar"
row.ownerName shouldBe "owner"
}
@@ -237,7 +237,7 @@ class ComputingUnitHelpersSpec
podMetrics = Map.empty
)
- row.ownerGoogleAvatar shouldBe null
+ row.ownerAvatar shouldBe null
row.ownerName shouldBe null
row.status shouldBe "Running"
row.metrics shouldBe WorkflowComputingUnitMetrics("NaN", "NaN")
diff --git a/frontend/src/app/common/service/user/auth.service.spec.ts
b/frontend/src/app/common/service/user/auth.service.spec.ts
index 2901a1a084..d359143a53 100644
--- a/frontend/src/app/common/service/user/auth.service.spec.ts
+++ b/frontend/src/app/common/service/user/auth.service.spec.ts
@@ -47,7 +47,7 @@ describe("AuthService", () => {
email: "[email protected]",
sub: "Ursula",
googleId: "g",
- googleAvatar: "a",
+ avatar: "a",
comment: "c",
joiningReason: "r",
};
@@ -187,13 +187,21 @@ describe("AuthService", () => {
name: "Ursula",
email: "[email protected]",
googleId: "g",
- googleAvatar: "a",
+ avatar: "a",
role: Role.REGULAR,
comment: "c",
joiningReason: "r",
});
});
+ it("reads the avatar from a pre-rename googleAvatar claim", () => {
+ AuthService.setAccessToken("tok");
+ const { avatar, ...legacyClaims } = claims;
+ jwt.decodeToken.mockReturnValue({ ...legacyClaims, googleAvatar:
"legacy-a" });
+
+ expect(service.loginWithExistingToken()?.avatar).toEqual("legacy-a");
+ });
+
it("in invite-only mode, an inactive user is logged out and registration
is checked", () => {
AuthService.setAccessToken("tok");
config.env.inviteOnly = true;
diff --git a/frontend/src/app/common/service/user/auth.service.ts
b/frontend/src/app/common/service/user/auth.service.ts
index a99508394e..15baa8a923 100644
--- a/frontend/src/app/common/service/user/auth.service.ts
+++ b/frontend/src/app/common/service/user/auth.service.ts
@@ -165,7 +165,10 @@ export class AuthService {
name: this.jwtHelperService.decodeToken(token).sub,
email: email,
googleId: this.jwtHelperService.decodeToken(token).googleId,
- googleAvatar: this.jwtHelperService.decodeToken(token).googleAvatar,
+ // The claim was `googleAvatar` until the avatar stopped being
Google-specific. Tokens
+ // predating the rename stay valid for a week, so either name is
accepted; the fallback
+ // can go once they have all expired.
+ avatar: this.jwtHelperService.decodeToken(token).avatar ??
this.jwtHelperService.decodeToken(token).googleAvatar,
role: role,
comment: this.jwtHelperService.decodeToken(token).comment,
joiningReason: this.jwtHelperService.decodeToken(token).joiningReason,
diff --git a/frontend/src/app/common/service/user/stub-user.service.ts
b/frontend/src/app/common/service/user/stub-user.service.ts
index 3f2fc82a23..af9be1c0c6 100644
--- a/frontend/src/app/common/service/user/stub-user.service.ts
+++ b/frontend/src/app/common/service/user/stub-user.service.ts
@@ -83,7 +83,7 @@ export class StubUserService implements
PublicInterfaceOf<UserService> {
return this.user;
}
- getAvatar(googleAvatar: string): Observable<string | undefined> {
+ getAvatar(avatarUrl: string): Observable<string | undefined> {
return of(undefined);
}
diff --git a/frontend/src/app/common/service/user/user.service.spec.ts
b/frontend/src/app/common/service/user/user.service.spec.ts
index f4792d4e65..bee3facd6d 100644
--- a/frontend/src/app/common/service/user/user.service.spec.ts
+++ b/frontend/src/app/common/service/user/user.service.spec.ts
@@ -207,13 +207,17 @@ describe("UserService", () => {
// ─── avatar fetching ──────────────────────────────────────────────────────
- it("getAvatar returns undefined for an empty avatar id", async () => {
+ // The stored value is the provider's complete URL, not a Google-specific
fragment, so it is
+ // fetched as-is and is also the cache key.
+ const AVATAR_URL = "https://lh3.googleusercontent.com/a/AVATAR-ID";
+
+ it("getAvatar returns undefined for an empty avatar url", async () => {
expect(await firstValueFrom(service.getAvatar(""))).toBeUndefined();
});
it("getAvatar returns the cached object URL while the entry is still fresh",
async () => {
- (service as any).cache.set("cached-id", { url: "blob:cached", expiry:
Date.now() + 60_000 });
- expect(await
firstValueFrom(service.getAvatar("cached-id"))).toBe("blob:cached");
+ (service as any).cache.set(AVATAR_URL, { url: "blob:cached", expiry:
Date.now() + 60_000 });
+ expect(await
firstValueFrom(service.getAvatar(AVATAR_URL))).toBe("blob:cached");
});
describe("getAvatar network path", () => {
@@ -237,18 +241,29 @@ describe("UserService", () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, blob: () =>
Promise.resolve(blob) }) as any;
URL.createObjectURL = vi.fn().mockReturnValue("blob:fetched");
- const result = await firstValueFrom(service.getAvatar("remote-id"));
+ const result = await firstValueFrom(service.getAvatar(AVATAR_URL));
expect(result).toBe("blob:fetched");
-
expect(globalThis.fetch).toHaveBeenCalledWith("https://lh3.googleusercontent.com/a/remote-id",
{
+ // fetched verbatim — no CDN prefix is reconstructed here any more
+ expect(globalThis.fetch).toHaveBeenCalledWith(AVATAR_URL, {
referrerPolicy: "no-referrer",
});
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
});
+ it("fetches an avatar hosted anywhere the backend allowed, not just
Google's CDN", async () => {
+ const blob = new Blob(["img"]);
+ globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, blob: () =>
Promise.resolve(blob) }) as any;
+ URL.createObjectURL = vi.fn().mockReturnValue("blob:other");
+
+ const otherHost = "https://avatars.example-provider.com/u/12345";
+ expect(await
firstValueFrom(service.getAvatar(otherHost))).toBe("blob:other");
+ expect(globalThis.fetch).toHaveBeenCalledWith(otherHost, {
referrerPolicy: "no-referrer" });
+ });
+
it("returns undefined when the avatar fetch fails", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 })
as any;
- expect(await
firstValueFrom(service.getAvatar("bad-id"))).toBeUndefined();
+ expect(await
firstValueFrom(service.getAvatar("https://lh3.googleusercontent.com/a/BAD"))).toBeUndefined();
});
});
});
diff --git a/frontend/src/app/common/service/user/user.service.ts
b/frontend/src/app/common/service/user/user.service.ts
index 2905c2e892..bdb5f1c279 100644
--- a/frontend/src/app/common/service/user/user.service.ts
+++ b/frontend/src/app/common/service/user/user.service.ts
@@ -152,24 +152,32 @@ export class UserService {
return { result: true, message: "Email frontend validation success." };
}
- getAvatar(googleAvatar: string): Observable<string | undefined> {
- if (!googleAvatar) return of(undefined);
+ /**
+ * Fetch the avatar at `avatarUrl` and expose it as an object URL, cached
for `cacheDuration`.
+ *
+ * `avatarUrl` is the complete URL the identity provider supplied, stored
as-is on the user
+ * record. It used to be only the last path segment of Google's `picture`
claim, with this
+ * method rebuilding `https://lh3.googleusercontent.com/a/<fragment>` around
it — which made
+ * the stored value unusable for any other provider. The backend allowlists
the host before
+ * storing it, so what arrives here has already been validated.
+ */
+ getAvatar(avatarUrl: string): Observable<string | undefined> {
+ if (!avatarUrl) return of(undefined);
- const cached = this.cache.get(googleAvatar);
+ const cached = this.cache.get(avatarUrl);
if (cached) {
if (Date.now() <= cached.expiry) {
return of(cached.url);
} else {
URL.revokeObjectURL(cached.url);
- this.cache.delete(googleAvatar);
+ this.cache.delete(avatarUrl);
}
}
- const url = `https://lh3.googleusercontent.com/a/${googleAvatar}`;
- return this.fetchBlob(url).pipe(
+ return this.fetchBlob(avatarUrl).pipe(
map(blob => {
const blobUrl = URL.createObjectURL(blob);
- this.cache.set(googleAvatar, {
+ this.cache.set(avatarUrl, {
url: blobUrl,
expiry: Date.now() + this.cacheDuration,
});
diff --git a/frontend/src/app/common/type/user.ts
b/frontend/src/app/common/type/user.ts
index 85ead94a0a..ee4e1f781b 100644
--- a/frontend/src/app/common/type/user.ts
+++ b/frontend/src/app/common/type/user.ts
@@ -42,7 +42,7 @@ export interface User
googleId?: string;
role: Role;
color?: string;
- googleAvatar?: string;
+ avatar?: string;
comment: string;
lastLogin?: number;
accountCreation?: Second;
diff --git a/frontend/src/app/common/type/workflow-computing-unit.ts
b/frontend/src/app/common/type/workflow-computing-unit.ts
index 00aa9f15e1..8515aa32e8 100644
--- a/frontend/src/app/common/type/workflow-computing-unit.ts
+++ b/frontend/src/app/common/type/workflow-computing-unit.ts
@@ -50,6 +50,6 @@ export interface DashboardWorkflowComputingUnit {
metrics: WorkflowComputingUnitMetrics;
isOwner: boolean;
accessPrivilege: "READ" | "WRITE" | "NONE";
- ownerGoogleAvatar: string;
+ ownerAvatar: string;
ownerName: string;
}
diff --git a/frontend/src/app/common/util/computing-unit.util.spec.ts
b/frontend/src/app/common/util/computing-unit.util.spec.ts
index a1956cc23d..ab6d3b2a4f 100644
--- a/frontend/src/app/common/util/computing-unit.util.spec.ts
+++ b/frontend/src/app/common/util/computing-unit.util.spec.ts
@@ -45,7 +45,7 @@ function makeUnit(overrides:
Partial<DashboardWorkflowComputingUnit> = {}): Dash
metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
isOwner: true,
accessPrivilege: "READ",
- ownerGoogleAvatar: "",
+ ownerAvatar: "",
ownerName: "owner",
...overrides,
// Set computingUnit last so a `computingUnit` override merges into
(rather than replaces)
diff --git
a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
index d7cb2da8ea..07f82c752a 100644
--- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
+++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html
@@ -193,7 +193,7 @@
<tr *ngFor="let user of basicTable.data">
<td>
<texera-user-avatar
- [googleAvatar]="user.googleAvatar"
+ [avatar]="user.avatar"
[userName]="user.name"
class="user-avatar"
[ngClass]="{ active: isUserActive(user) }">
diff --git
a/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
b/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
index d5ae29674f..2b3d5c3a7f 100644
--- a/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
+++ b/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
@@ -215,7 +215,7 @@ export const testUserProjects: DashboardProject[] = [
];
export const mockUserInfo = {
- 1: { userName: "Texera", googleAvatar: "avatar_url_1" },
- 2: { userName: "Angular", googleAvatar: "avatar_url_2" },
- 3: { userName: "UCI", googleAvatar: "avatar_url_3" },
+ 1: { userName: "Texera", avatar: "avatar_url_1" },
+ 2: { userName: "Angular", avatar: "avatar_url_2" },
+ 3: { userName: "UCI", avatar: "avatar_url_3" },
};
diff --git
a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
index 05419f682f..c2e112601c 100644
---
a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
+++
b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
@@ -130,7 +130,7 @@
class="owner-info"
title="{{ entry.ownerName || 'User' }}">
<texera-user-avatar
- [googleAvatar]="entry.ownerGoogleAvatar"
+ [avatar]="entry.ownerAvatar"
userColor="#1E90FF"
[userName]="entry.ownerName || 'User'"></texera-user-avatar>
<span class="owner-name truncate-single-line">{{ entry.ownerName
|| "User" }}</span>
diff --git
a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
index 20b98b71ac..7f75f16fe2 100644
---
a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
+++
b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
@@ -119,7 +119,7 @@
nzFlex="50px"
class="resource-info">
<texera-user-avatar
- [googleAvatar]="entry.ownerGoogleAvatar"
+ [avatar]="entry.ownerAvatar"
userColor="#1E90FF"
[userName]="entry.ownerName || 'User'"
[isOwner]="entry.ownerId === this.currentUid">
diff --git
a/frontend/src/app/dashboard/component/user/user-avatar/user-avatar.component.ts
b/frontend/src/app/dashboard/component/user/user-avatar/user-avatar.component.ts
index 9ffdf900dd..7c6e7b5814 100644
---
a/frontend/src/app/dashboard/component/user/user-avatar/user-avatar.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-avatar/user-avatar.component.ts
@@ -34,11 +34,12 @@ import { NzAvatarComponent } from "ng-zorro-antd/avatar";
/**
* UserAvatarComponent is used to show the avatar of a user
- * The avatar of a Google user will be its Google profile picture
- * The avatar of a normal user will be a default one with the initial
+ * A user provisioned through an identity provider shows that provider's
profile picture,
+ * fetched from the complete URL stored on the user record
+ * A user without one shows a default avatar with their initials
*/
export class UserAvatarComponent implements OnChanges {
- @Input() googleAvatar?: string;
+ @Input() avatar?: string;
@Input() userName?: string;
@Input() userColor?: string;
@Input() isOwner: Boolean = false;
@@ -47,8 +48,8 @@ export class UserAvatarComponent implements OnChanges {
constructor(private userService: UserService) {}
ngOnChanges(): void {
- if (this.googleAvatar) {
- this.avatarUrl$ = this.userService.getAvatar(this.googleAvatar);
+ if (this.avatar) {
+ this.avatarUrl$ = this.userService.getAvatar(this.avatar);
} else {
this.avatarUrl$ = of(undefined);
}
diff --git
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
index 9fd52ee80d..1e8624714c 100644
---
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.spec.ts
@@ -73,7 +73,7 @@ function makeEntry(overrides:
Partial<DashboardWorkflowComputingUnit> = {}): Das
metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
isOwner: true,
accessPrivilege: "WRITE",
- ownerGoogleAvatar: "",
+ ownerAvatar: "",
ownerName: "owner",
...overrides,
};
diff --git
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
index 11208fa940..9cb56f32a4 100644
---
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
@@ -121,7 +121,7 @@ describe("UserComputingUnitComponent", () => {
metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
isOwner: true,
accessPrivilege: "WRITE",
- ownerGoogleAvatar: "",
+ ownerAvatar: "",
ownerName: "owner",
} as DashboardWorkflowComputingUnit;
}
diff --git
a/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.html
b/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.html
index 59f9e1d076..91c84f1cf5 100644
---
a/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.html
+++
b/frontend/src/app/dashboard/component/user/user-icon/user-icon.component.html
@@ -18,7 +18,7 @@
-->
<texera-user-avatar
- [googleAvatar]="user?.googleAvatar"
+ [avatar]="user?.avatar"
[nzDropdownMenu]="menu"
[userColor]="user?.color || ''"
[userName]="user?.name || ''"
diff --git
a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.html
b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.html
index ac96897e66..6ea319314c 100644
---
a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.html
+++
b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.html
@@ -163,7 +163,7 @@
</td>
<td nzEllipsis>
<texera-user-avatar
- [googleAvatar]="row.googleAvatar"
+ [avatar]="row.avatar"
userColor="setAvatarColor(row.userName)"
[userName]="abbreviate(row.userName || 'anonymous',
false)"></texera-user-avatar>
</td>
diff --git
a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts
index 44ea8b3f56..2cdfc23f70 100644
---
a/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-workflow/ngbd-modal-workflow-executions/workflow-execution-history.component.spec.ts
@@ -44,7 +44,7 @@ function makeEntry(overrides:
Partial<WorkflowExecutionsEntry> = {}): WorkflowEx
cuId: 1,
sId: 0,
userName: "alice",
- googleAvatar: "",
+ avatar: "",
name: "untitled",
startingTime: 0,
completionTime: 60000,
diff --git
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts
index 7b71ead98c..64a305aadf 100644
---
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts
@@ -854,7 +854,7 @@ describe("SavedWorkflowSectionComponent", () => {
const entries = component.searchResultsComponent.entries;
expect(entries.map(e => e.name)).toEqual(["dup", "existing"]);
expect(entries[0].ownerName).toBe("Angular");
- expect(entries[0].ownerGoogleAvatar).toBe("avatar_url_2");
+ expect(entries[0].ownerAvatar).toBe("avatar_url_2");
expect(entries[0].accessibleUserIds).toEqual([1]);
});
@@ -904,7 +904,7 @@ describe("SavedWorkflowSectionComponent", () => {
const entry = component.searchResultsComponent.entries[0];
expect(entry.ownerName).toBe("NoAvatar");
- expect(entry.ownerGoogleAvatar).toBe("");
+ expect(entry.ownerAvatar).toBe("");
});
it("does nothing when the entry has no wid", async () => {
diff --git
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts
index 0c5149eab9..e46582ea1d 100644
---
a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts
+++
b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts
@@ -351,7 +351,7 @@ export class UserWorkflowComponent implements AfterViewInit
{
const userInfo = userIdToInfoMap[duplicatedWorkflowInfo.ownerId];
if (userInfo) {
entry.setOwnerName(userInfo.userName);
- entry.setOwnerGoogleAvatar(userInfo.googleAvatar ?? "");
+ entry.setOwnerAvatar(userInfo.avatar ?? "");
}
if (this.currentUid !== undefined) {
entry.setAccessUsers([this.currentUid]);
diff --git a/frontend/src/app/dashboard/service/user/search.service.ts
b/frontend/src/app/dashboard/service/user/search.service.ts
index a48ff8991d..ecb49a8ec9 100644
--- a/frontend/src/app/dashboard/service/user/search.service.ts
+++ b/frontend/src/app/dashboard/service/user/search.service.ts
@@ -235,7 +235,7 @@ export class SearchService {
const ui = (userMap as any)[ownerId];
if (ui) {
entry.setOwnerName(ui.userName);
- entry.setOwnerGoogleAvatar(ui.googleAvatar ?? "");
+ entry.setOwnerAvatar(ui.avatar ?? "");
}
if (doCounts) {
diff --git a/frontend/src/app/dashboard/service/user/stub-search.service.ts
b/frontend/src/app/dashboard/service/user/stub-search.service.ts
index 6ab1628626..8dc8ef96d6 100644
--- a/frontend/src/app/dashboard/service/user/stub-search.service.ts
+++ b/frontend/src/app/dashboard/service/user/stub-search.service.ts
@@ -108,7 +108,7 @@ export class StubSearchService {
const info = this.mockUserInfo[entry.ownerId];
if (info) {
entry.setOwnerName(info.userName);
- entry.setOwnerGoogleAvatar(info.googleAvatar ?? "");
+ entry.setOwnerAvatar(info.avatar ?? "");
}
});
diff --git a/frontend/src/app/dashboard/type/dashboard-entry.spec.ts
b/frontend/src/app/dashboard/type/dashboard-entry.spec.ts
index 2d6621ecbf..be5519d50e 100644
--- a/frontend/src/app/dashboard/type/dashboard-entry.spec.ts
+++ b/frontend/src/app/dashboard/type/dashboard-entry.spec.ts
@@ -132,7 +132,7 @@ function makeComputingUnit():
DashboardWorkflowComputingUnit {
},
isOwner: true,
accessPrivilege: "WRITE",
- ownerGoogleAvatar: "avatar",
+ ownerAvatar: "avatar",
ownerName: "Bob",
};
}
@@ -153,7 +153,7 @@ describe("DashboardEntry", () => {
expect(entry.ownerName).toBe("Alice");
expect(entry.ownerId).toBe(10);
expect(entry.ownerEmail).toBe("");
- expect(entry.ownerGoogleAvatar).toBe("");
+ expect(entry.ownerAvatar).toBe("");
expect(entry.size).toBe(0);
expect(entry.coverImageUrl).toBe("http://example.com/cover.png");
expect(entry.value).toBe(value);
@@ -218,7 +218,7 @@ describe("DashboardEntry", () => {
expect(entry.creationTime).toBe(1700000005000);
expect(entry.accessLevel).toBe("WRITE");
expect(entry.ownerId).toBe(50);
- expect(entry.ownerGoogleAvatar).toBe("");
+ expect(entry.ownerAvatar).toBe("");
// The computing-unit branch does not populate these fields.
expect(entry.description).toBeUndefined();
expect(entry.lastModifiedTime).toBeUndefined();
@@ -249,10 +249,10 @@ describe("DashboardEntry", () => {
expect(entry.ownerName).toBe("Carol");
});
- it("setOwnerGoogleAvatar updates ownerGoogleAvatar", () => {
+ it("setOwnerAvatar updates ownerAvatar", () => {
const entry = new DashboardEntry(makeWorkflow());
- entry.setOwnerGoogleAvatar("https://example.com/avatar.png");
- expect(entry.ownerGoogleAvatar).toBe("https://example.com/avatar.png");
+ entry.setOwnerAvatar("https://example.com/avatar.png");
+ expect(entry.ownerAvatar).toBe("https://example.com/avatar.png");
});
it("setCount updates viewCount, cloneCount and likeCount together", () => {
diff --git a/frontend/src/app/dashboard/type/dashboard-entry.ts
b/frontend/src/app/dashboard/type/dashboard-entry.ts
index 71c9a1515f..995618fb5f 100644
--- a/frontend/src/app/dashboard/type/dashboard-entry.ts
+++ b/frontend/src/app/dashboard/type/dashboard-entry.ts
@@ -33,7 +33,7 @@ import { EntityType } from "../../hub/service/hub.service";
export interface UserInfo {
userName: string;
- googleAvatar?: string;
+ avatar?: string;
}
export class DashboardEntry {
@@ -47,7 +47,7 @@ export class DashboardEntry {
accessLevel: string | undefined;
ownerName: string | undefined;
ownerEmail: string | undefined;
- ownerGoogleAvatar: string | undefined;
+ ownerAvatar: string | undefined;
ownerId: number | undefined;
size: number | undefined;
viewCount: number;
@@ -75,7 +75,7 @@ export class DashboardEntry {
this.accessLevel = value.accessLevel;
this.ownerName = value.ownerName;
this.ownerEmail = "";
- this.ownerGoogleAvatar = "";
+ this.ownerAvatar = "";
this.ownerId = value.ownerId;
this.size = 0;
this.viewCount = 0;
@@ -94,7 +94,7 @@ export class DashboardEntry {
this.accessLevel = value.accessLevel;
this.ownerName = "";
this.ownerEmail = "";
- this.ownerGoogleAvatar = "";
+ this.ownerAvatar = "";
this.ownerId = value.ownerId;
this.size = 0;
this.viewCount = 0;
@@ -112,7 +112,7 @@ export class DashboardEntry {
this.accessLevel = value.accessLevel;
this.ownerName = "";
this.ownerEmail = value.ownerEmail;
- this.ownerGoogleAvatar = "";
+ this.ownerAvatar = "";
this.ownerId = value.file.ownerUid;
this.size = value.file.size;
this.viewCount = 0;
@@ -130,7 +130,7 @@ export class DashboardEntry {
this.accessLevel = value.accessPrivilege;
this.ownerName = "";
this.ownerEmail = value.ownerEmail;
- this.ownerGoogleAvatar = "";
+ this.ownerAvatar = "";
this.ownerId = value.dataset.ownerUid;
this.size = value.size;
this.viewCount = 0;
@@ -146,7 +146,7 @@ export class DashboardEntry {
this.creationTime = value.computingUnit.creationTime;
this.accessLevel = value.accessPrivilege;
this.ownerName = "";
- this.ownerGoogleAvatar = "";
+ this.ownerAvatar = "";
this.ownerId = value.computingUnit.uid;
this.viewCount = 0;
this.cloneCount = 0;
@@ -162,8 +162,8 @@ export class DashboardEntry {
this.ownerName = ownerName;
}
- setOwnerGoogleAvatar(ownerGoogleAvatar: string): void {
- this.ownerGoogleAvatar = ownerGoogleAvatar;
+ setOwnerAvatar(ownerAvatar: string): void {
+ this.ownerAvatar = ownerAvatar;
}
setCount(viewCount: number, cloneCount: number, likeCount: number): void {
diff --git a/frontend/src/app/dashboard/type/type-predicates.spec.ts
b/frontend/src/app/dashboard/type/type-predicates.spec.ts
index d98afd8b9f..e955215260 100644
--- a/frontend/src/app/dashboard/type/type-predicates.spec.ts
+++ b/frontend/src/app/dashboard/type/type-predicates.spec.ts
@@ -126,7 +126,7 @@ const computingUnitFixture: DashboardWorkflowComputingUnit
= {
},
isOwner: true,
accessPrivilege: "WRITE",
- ownerGoogleAvatar: "",
+ ownerAvatar: "",
ownerName: "Alice",
};
diff --git a/frontend/src/app/dashboard/type/workflow-executions-entry.ts
b/frontend/src/app/dashboard/type/workflow-executions-entry.ts
index 4f2542ec5f..8687416d83 100644
--- a/frontend/src/app/dashboard/type/workflow-executions-entry.ts
+++ b/frontend/src/app/dashboard/type/workflow-executions-entry.ts
@@ -24,7 +24,7 @@ export interface WorkflowExecutionsEntry {
cuId: number;
sId: number;
userName: string;
- googleAvatar: string;
+ avatar: string;
name: string;
startingTime: number;
completionTime: number;
diff --git
a/frontend/src/app/hub/component/browse-section/browse-section.component.html
b/frontend/src/app/hub/component/browse-section/browse-section.component.html
index 2fd8f37525..073f9ab5ed 100644
---
a/frontend/src/app/hub/component/browse-section/browse-section.component.html
+++
b/frontend/src/app/hub/component/browse-section/browse-section.component.html
@@ -33,7 +33,7 @@
<div class="footer">
<span class="footer-text">Edited: {{ entity.lastModifiedTime | date
}}</span>
<texera-user-avatar
- [googleAvatar]="entity.ownerGoogleAvatar"
+ [avatar]="entity.ownerAvatar"
userColor="grey"
[userName]="entity.ownerName || ''"
nz-button>
diff --git
a/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.html
b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.html
index 2ada04e285..027a1164f1 100644
---
a/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.html
+++
b/frontend/src/app/workspace/component/menu/coeditor-user-icon/coeditor-user-icon.component.html
@@ -18,7 +18,7 @@
-->
<texera-user-avatar
- [googleAvatar]="coeditor.googleAvatar"
+ [avatar]="coeditor.avatar"
[userName]="coeditor.name || ''"
[userColor]="coeditor.color || ''"
nz-button
diff --git
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
index 747354a04c..6a9a7d9061 100644
---
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
+++
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
@@ -69,7 +69,7 @@
<div class="button-content">
<texera-user-avatar
*ngIf="selectedComputingUnit"
- [googleAvatar]="selectedComputingUnit ?
selectedComputingUnit.ownerGoogleAvatar : ''"
+ [avatar]="selectedComputingUnit ? selectedComputingUnit.ownerAvatar :
''"
userColor="grey"
[userName]="selectedComputingUnit ? selectedComputingUnit.ownerName :
''"
[style.transform]="'scale(0.65)'"
@@ -116,7 +116,7 @@
(click)="selectedComputingUnit = unit;
selectComputingUnit(this.workflowId, unit?.computingUnit?.cuid)">
<div class="computing-unit-row">
<texera-user-avatar
- [googleAvatar]="unit.ownerGoogleAvatar"
+ [avatar]="unit.ownerAvatar"
userColor="grey"
[userName]="unit.ownerName || ''"
[style.transform]="'scale(0.65)'"
diff --git
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
index 4d6777e0ff..ef8b4f02a6 100644
---
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
+++
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
@@ -100,7 +100,7 @@ function makeComputingUnit(
metrics: { cpuUsage: "N/A", memoryUsage: "N/A" },
isOwner,
accessPrivilege: "WRITE",
- ownerGoogleAvatar: "",
+ ownerAvatar: "",
ownerName: "owner",
};
}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 05d1bb8ded..42e40f7b83 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -84,6 +84,11 @@
<sqlFile path="sql/updates/34.sql"/>
</changeSet>
+ <!-- Store the provider's full avatar URL in "user".avatar instead of a
Google fragment -->
+ <changeSet id="35" author="Neilk1021">
+ <sqlFile path="sql/updates/35.sql"/>
+ </changeSet>
+
<!-- example changeSet
<changeSet id="1" author="author">
<sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index f991bf9b62..aedfd6a848 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -107,7 +107,7 @@ CREATE TABLE IF NOT EXISTS "user"
uid SERIAL PRIMARY KEY,
name VARCHAR(256) NOT NULL,
email VARCHAR(256) UNIQUE,
- avatar VARCHAR(100),
+ avatar VARCHAR(512),
role user_role_enum NOT NULL DEFAULT 'INACTIVE',
comment TEXT,
account_creation_time TIMESTAMPTZ NOT NULL DEFAULT now(),
diff --git a/sql/updates/35.sql b/sql/updates/35.sql
new file mode 100644
index 0000000000..d80fa7da64
--- /dev/null
+++ b/sql/updates/35.sql
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+-- Store the identity provider's full avatar URL instead of a Google-specific
fragment.
+--
+-- Migration 33 renamed "user".google_avatar to "user".avatar but kept every
value as-is, so the
+-- column still holds only the last path segment of Google's `picture` claim
and the frontend
+-- still rebuilds `https://lh3.googleusercontent.com/a/<fragment>` around it.
That makes the value
+-- unusable for any other provider. This promotes the stored fragments to
complete URLs.
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+ALTER TABLE "user" ALTER COLUMN avatar TYPE VARCHAR(512);
+
+UPDATE "user" SET avatar = NULL WHERE avatar = '';
+
+UPDATE "user"
+SET avatar = 'https://lh3.googleusercontent.com/a/' || avatar
+WHERE avatar IS NOT NULL
+ AND avatar NOT LIKE 'http://%'
+ AND avatar NOT LIKE 'https://%';
+
+COMMIT;