Compare commits

...
2 Commits
Author SHA1 Message Date
Gabriel Brown 2356383da1 CI: run on the VPS runner (plain shell instead of checkout/setup-bun actions)
Build and Push Next App / quality (push) Successful in 2m5s
Build and Push Next App / build-next (push) Canceled after 10s
Rootless podman on cirunner-vps cannot copy cached actions into job
containers (symlinks: "path escapes from parent"), so any job that
landed there failed. Checkout is now plain git and Bun comes from its
install script; both are verified on cirunner-vps.
2026-10-02 04:36:50 -04:00
Gabriel Brown 55929d8c7f Fix avatar crash when users.image is an IdP URL
Build and Push Next App / quality (push) Successful in 1m19s
Build and Push Next App / build-next (push) Successful in 2m35s
Authentik sign-ins write the picture claim URL to users.image, and both avatar components cast that value to a storage ID and passed it to files:getImageUrl, which failed argument validation and took the layout down after sign-in. getUser now returns imageUrl resolved from either a storage ID or a URL, and updateUser only deletes the previous avatar when it was an uploaded file. getImageUrl stays until the new Next image is live.
2026-10-01 15:01:51 -04:00
4 changed files with 46 additions and 20 deletions

No files matched your search

+26 -5
View File
@@ -34,12 +34,23 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 # Plain git instead of actions/checkout: rootless podman on cirunner-vps
# rejects symlinks inside cached actions ("path escapes from parent").
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git init -q .
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
auth="$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 -w0)"
git -c http.extraHeader="Authorization: basic $auth" \
fetch -q --depth 1 origin "${{ gitea.sha }}"
git checkout -q FETCH_HEAD
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 # Install script instead of oven-sh/setup-bun (same podman symlink issue).
with: run: |
bun-version: 1.3.14 curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" >/dev/null
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- name: Install dependencies - name: Install dependencies
run: ./scripts/ci-install run: ./scripts/ci-install
@@ -60,7 +71,17 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 # Plain git instead of actions/checkout: rootless podman on cirunner-vps
# rejects symlinks inside cached actions ("path escapes from parent").
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git init -q .
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
auth="$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 -w0)"
git -c http.extraHeader="Authorization: basic $auth" \
fetch -q --depth 1 origin "${{ gitea.sha }}"
git checkout -q FETCH_HEAD
- name: Log in to container registry - name: Log in to container registry
run: | run: |
@@ -3,7 +3,7 @@
import type { Preloaded } from 'convex/react'; import type { Preloaded } from 'convex/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react'; import { useMutation, usePreloadedQuery } from 'convex/react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react'; import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,11 +48,6 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const generateUploadUrl = useMutation(api.files.generateUploadUrl); const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser); const updateUser = useMutation(api.auth.updateUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null; const file = event.target.files?.[0] ?? null;
if (!file) return; if (!file) return;
@@ -117,7 +112,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
<BasedAvatar <BasedAvatar
src={currentImageUrl ?? undefined} src={user?.imageUrl}
fullName={user?.name} fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold' className='h-42 w-42 text-6xl font-semibold'
userIconProps={{ size: 100 }} userIconProps={{ size: 100 }}
@@ -6,7 +6,6 @@ import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react'; import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react'; import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
import { api } from '@gib/backend/convex/_generated/api.js'; import { api } from '@gib/backend/convex/_generated/api.js';
import { import {
BasedAvatar, BasedAvatar,
@@ -25,10 +24,6 @@ export const AvatarDropdown = () => {
const { signOut } = useAuthActions(); const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {}); const user = useQuery(api.auth.getUser, {});
useAnalyticsIdentity(user?._id); useAnalyticsIdentity(user?._id);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
if (isLoading) { if (isLoading) {
return ( return (
@@ -53,7 +48,7 @@ export const AvatarDropdown = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<BasedAvatar <BasedAvatar
src={currentImageUrl} src={user?.imageUrl}
fullName={user?.name} fullName={user?.name}
className='h-9 w-9' className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }} fallbackProps={{ className: 'text-sm font-semibold' }}
+17 -2
View File
@@ -45,12 +45,24 @@ export const getUserProvider = query({
}, },
}); });
/**
* `users.image` holds either a Convex storage ID (avatar uploaded in-app) or a
* plain URL (the `picture` claim Authentik writes on every OAuth sign-in).
* Resolves either form to a URL the client can render directly.
*/
const resolveImageUrl = async (ctx: QueryCtx, image: string | undefined) => {
if (!image) return null;
const storageId = ctx.db.system.normalizeId('_storage', image);
return storageId ? await ctx.storage.getUrl(storageId) : image;
};
export const getUser = query({ export const getUser = query({
args: { userId: v.optional(v.id('users')) }, args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = args.userId ?? (await getAuthUserId(ctx)); const userId = args.userId ?? (await getAuthUserId(ctx));
if (!userId) return null; if (!userId) return null;
return getUserById(ctx, userId); const user = await getUserById(ctx, userId);
return { ...user, imageUrl: await resolveImageUrl(ctx, user.image) };
}, },
}); });
@@ -73,7 +85,10 @@ export const updateUser = mutation({
if (args.name !== undefined) patch.name = args.name; if (args.name !== undefined) patch.name = args.name;
if (args.email !== undefined) patch.email = args.email; if (args.email !== undefined) patch.email = args.email;
if (args.image !== undefined) { if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined; // Only delete the previous avatar when it was an uploaded file, not an IdP URL.
const oldImage = user.image
? ctx.db.system.normalizeId('_storage', user.image)
: null;
patch.image = args.image; patch.image = args.image;
if (oldImage && oldImage !== args.image) { if (oldImage && oldImage !== args.image) {
await ctx.storage.delete(oldImage); await ctx.storage.delete(oldImage);