From 81ef4683ae60651ff957c742e916cad2c80002fd Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Mon, 6 Apr 2026 22:50:35 +0530 Subject: [PATCH 1/3] fix: profile editor media sync --- .env.example | 2 +- .../dev-sandbox/src/routes/+page.svelte | 7 +- .../api/src/controllers/FileController.ts | 32 ++++ platforms/file-manager/api/src/index.ts | 1 + .../api/src/services/FileService.ts | 10 + .../client/client/src/data/apps.json | 8 + .../client/client/src/pages/app-detail.tsx | 4 + .../api/src/controllers/WebhookController.ts | 4 +- .../api/src/controllers/ProfileController.ts | 4 +- .../api/src/controllers/WebhookController.ts | 37 +++- .../api/src/database/entities/User.ts | 4 +- ...1775600000000-RenameAvatarBannerColumns.ts | 15 ++ platforms/profile-editor/api/src/index.ts | 2 +- .../api/src/services/EVaultProfileService.ts | 76 +++++++- .../api/src/services/EVaultSyncService.ts | 4 +- .../api/src/services/UserSearchService.ts | 4 +- .../profile-editor/api/src/types/profile.ts | 12 +- .../api/src/utils/file-proxy.ts | 62 ++++++ .../professional-profile.mapping.json | 2 - .../web3adapter/mappings/user.mapping.json | 2 - .../components/profile/ProfileHeader.svelte | 34 ++-- .../client/src/lib/stores/discovery.ts | 2 +- .../client/src/lib/stores/profile.ts | 4 +- .../client/src/lib/utils/axios.ts | 2 +- .../client/src/lib/utils/file-manager.ts | 2 +- .../ontology/schemas/professionalProfile.json | 181 ++++++++++++++++++ 26 files changed, 464 insertions(+), 53 deletions(-) create mode 100644 platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts create mode 100644 services/ontology/schemas/professionalProfile.json diff --git a/.env.example b/.env.example index c62b15c2f..2d6081ea1 100644 --- a/.env.example +++ b/.env.example @@ -92,7 +92,7 @@ PUBLIC_PROVISIONER_SHARED_SECRET="your-provisioner-shared-secret" PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005" -PUBLIC_PROFILE_EDITOR_BASE_URL="http://localhost:3006" +PUBLIC_PROFILE_EDITOR_BASE_URL="http://localhost:3007" DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" diff --git a/infrastructure/dev-sandbox/src/routes/+page.svelte b/infrastructure/dev-sandbox/src/routes/+page.svelte index fa810dad0..35045c6a4 100644 --- a/infrastructure/dev-sandbox/src/routes/+page.svelte +++ b/infrastructure/dev-sandbox/src/routes/+page.svelte @@ -49,6 +49,7 @@ interface Identity { w3id: string; uri: string; keyId: string; + displayName?: string; bearerToken?: string; tokenExpiresAt?: number; } @@ -468,6 +469,10 @@ async function doProvision() { identity.w3id, profile, ); + const next = [...identities]; + next[selectedIndex] = { ...next[selectedIndex], displayName: profile.displayName }; + identities = next; + saveIdentities(next); addLog("success", "UserProfile created", profile.displayName); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -826,7 +831,7 @@ async function doSign() {

Selected identity

diff --git a/platforms/file-manager/api/src/controllers/FileController.ts b/platforms/file-manager/api/src/controllers/FileController.ts index a9af2540d..2eaf76ea5 100644 --- a/platforms/file-manager/api/src/controllers/FileController.ts +++ b/platforms/file-manager/api/src/controllers/FileController.ts @@ -567,6 +567,38 @@ export class FileController { } }; + /** + * Serves a file publicly without authentication. + * The file ID acts as an unguessable capability token. + */ + publicPreview = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const file = await this.fileService.getFileByIdPublic(id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + if (file.url) { + return res.redirect(file.url); + } + + // Legacy fallback for files still in DB + res.setHeader("Content-Type", file.mimeType); + res.setHeader( + "Content-Disposition", + `inline; filename="${file.name}"`, + ); + res.setHeader("Content-Length", file.size.toString()); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(file.data); + } catch (error) { + console.error("Error serving public file:", error); + res.status(500).json({ error: "Failed to serve file" }); + } + }; + deleteFile = async (req: Request, res: Response) => { try { if (!req.user) { diff --git a/platforms/file-manager/api/src/index.ts b/platforms/file-manager/api/src/index.ts index a8dcee982..b6fb8b5ef 100644 --- a/platforms/file-manager/api/src/index.ts +++ b/platforms/file-manager/api/src/index.ts @@ -80,6 +80,7 @@ app.get("/api/auth/offer", authController.getOffer); app.post("/api/auth", authController.login); app.get("/api/auth/sessions/:id", authController.sseStream); app.post("/api/webhook", webhookController.handleWebhook); +app.get("/api/public/files/:id", fileController.publicPreview); // Protected routes (auth required) app.use(authMiddleware); diff --git a/platforms/file-manager/api/src/services/FileService.ts b/platforms/file-manager/api/src/services/FileService.ts index bf71b464b..d35db1ab7 100644 --- a/platforms/file-manager/api/src/services/FileService.ts +++ b/platforms/file-manager/api/src/services/FileService.ts @@ -114,6 +114,16 @@ export class FileService { return savedFile; } + async getFileByIdPublic(id: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id }, + }); + if (!file || file.name === SOFT_DELETED_FILE_NAME) { + return null; + } + return file; + } + async getFileById(id: string, userId: string): Promise { const file = await this.fileRepository.findOne({ where: { id }, diff --git a/platforms/marketplace/client/client/src/data/apps.json b/platforms/marketplace/client/client/src/data/apps.json index c13a2dad9..7a7ec1e85 100644 --- a/platforms/marketplace/client/client/src/data/apps.json +++ b/platforms/marketplace/client/client/src/data/apps.json @@ -87,5 +87,13 @@ "category": "Storage", "logoUrl": "/emover.png", "url": "https://emover.w3ds.metastate.foundation" + }, + { + "id": "profile-editor", + "name": "Profile Editor", + "description": "Create and manage your professional profile across the W3DS ecosystem. Showcase your skills, experience, and credentials.", + "category": "Identity", + "logoUrl": "/profile-editor.png", + "url": "https://profile-editor.w3ds.metastate.foundation" } ] diff --git a/platforms/marketplace/client/client/src/pages/app-detail.tsx b/platforms/marketplace/client/client/src/pages/app-detail.tsx index 1af922069..fc895e955 100644 --- a/platforms/marketplace/client/client/src/pages/app-detail.tsx +++ b/platforms/marketplace/client/client/src/pages/app-detail.tsx @@ -46,6 +46,10 @@ const appDetails: Record { + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatarFileId" TO "avatar"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "bannerFileId" TO "banner"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatar" TO "avatarFileId"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "banner" TO "bannerFileId"`); + } +} diff --git a/platforms/profile-editor/api/src/index.ts b/platforms/profile-editor/api/src/index.ts index b2e516512..dc8911c23 100644 --- a/platforms/profile-editor/api/src/index.ts +++ b/platforms/profile-editor/api/src/index.ts @@ -19,7 +19,7 @@ import { EVaultSyncService } from "./services/EVaultSyncService"; import { adapter } from "./web3adapter/watchers/subscriber"; const app = express(); -const PORT = process.env.PROFILE_EDITOR_API_PORT || 3006; +const PORT = process.env.PROFILE_EDITOR_API_PORT || 3007; app.use(cors()); app.use(express.json()); diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index 786204a22..222b9ca01 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -9,6 +9,11 @@ import type { const PROFESSIONAL_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440009"; const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +function getFileManagerPublicUrl(fileId: string): string { + const base = process.env.PUBLIC_FILE_MANAGER_BASE_URL || "http://localhost:3005"; + return `${base}/api/public/files/${fileId}`; +} + /** Match BindingDocumentService's normalizeSubject format for ACL entries */ function normalizeEName(eName: string): string { return eName.startsWith("@") ? eName : `@${eName}`; @@ -146,6 +151,12 @@ export class EVaultProfileService { const userData = (userNode?.parsed ?? {}) as UserOntologyData; const profData = (professionalNode?.parsed ?? {}) as ProfessionalProfile; + // Avatar/banner live on the local User entity (file-manager IDs), + // not in any eVault ontology. + const { AppDataSource } = await import("../database/data-source"); + const { User } = await import("../database/entities/User"); + const localUser = await AppDataSource.getRepository(User).findOneBy({ ename: eName }); + const name = profData.displayName ?? userData.displayName ?? eName; @@ -158,8 +169,8 @@ export class EVaultProfileService { displayName: profData.displayName, headline: profData.headline, bio: profData.bio, - avatarFileId: profData.avatarFileId, - bannerFileId: profData.bannerFileId, + avatar: localUser?.avatar ?? undefined, + banner: localUser?.banner ?? undefined, cvFileId: profData.cvFileId, videoIntroFileId: profData.videoIntroFileId, email: profData.email, @@ -268,9 +279,70 @@ export class EVaultProfileService { } } + // Always persist avatar/banner to the local User row first so + // getProfile returns the correct value immediately. + if (data.avatar !== undefined || data.banner !== undefined) { + const { AppDataSource } = await import("../database/data-source"); + const { User } = await import("../database/entities/User"); + const userRepo = AppDataSource.getRepository(User); + let localUser = await userRepo.findOneBy({ ename: eName }); + if (!localUser) { + localUser = userRepo.create({ ename: eName }); + } + if (data.avatar !== undefined) localUser.avatar = data.avatar; + if (data.banner !== undefined) localUser.banner = data.banner; + await userRepo.save(localUser); + + // Propagate a public file-manager URL to the User ontology so + // other platforms (Pictique, Blabsy, etc.) pick it up. + this.syncAvatarBannerToUserOntology(client, eName, merged).catch( + (e) => console.error("Failed to sync avatar/banner to User ontology:", e), + ); + } + return this.getProfile(eName); } + /** + * Writes avatarUrl / bannerUrl as public file-manager URLs into the + * User ontology MetaEnvelope so other platforms can render them directly. + * Also updates the local User entity so the DB stays in sync. + */ + private async syncAvatarBannerToUserOntology( + client: GraphQLClient, + eName: string, + profile: ProfessionalProfile, + ): Promise { + try { + const userNode = await this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY); + const existing = (userNode?.parsed ?? {}) as Record; + + const patch: Record = { ...existing }; + if (profile.avatar) { + patch.avatarUrl = getFileManagerPublicUrl(profile.avatar); + } + if (profile.banner) { + patch.bannerUrl = getFileManagerPublicUrl(profile.banner); + } + + if (userNode) { + await client.request(UPDATE_MUTATION, { + id: userNode.id, + input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] }, + }); + } else { + patch.ename = eName; + patch.displayName = profile.displayName ?? eName; + await client.request(CREATE_MUTATION, { + input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] }, + }); + } + + } catch (e) { + console.error("Failed to sync avatar/banner to User ontology:", e); + } + } + async getProfileByEnvelope( eName: string, id: string, diff --git a/platforms/profile-editor/api/src/services/EVaultSyncService.ts b/platforms/profile-editor/api/src/services/EVaultSyncService.ts index 70608100e..3ccbe8899 100644 --- a/platforms/profile-editor/api/src/services/EVaultSyncService.ts +++ b/platforms/profile-editor/api/src/services/EVaultSyncService.ts @@ -96,8 +96,8 @@ export class EVaultSyncService { bio: profile.professional.bio, headline: profile.professional.headline, location: profile.professional.location, - avatarFileId: profile.professional.avatarFileId, - bannerFileId: profile.professional.bannerFileId, + avatar: profile.professional.avatar, + banner: profile.professional.banner, skills: profile.professional.skills, isPublic: profile.professional.isPublic === true, isArchived: false, diff --git a/platforms/profile-editor/api/src/services/UserSearchService.ts b/platforms/profile-editor/api/src/services/UserSearchService.ts index c60155437..a8d56e458 100644 --- a/platforms/profile-editor/api/src/services/UserSearchService.ts +++ b/platforms/profile-editor/api/src/services/UserSearchService.ts @@ -33,7 +33,7 @@ export class UserSearchService { "user.name", "user.handle", "user.bio", - "user.avatarFileId", + "user.avatar", "user.headline", "user.location", "user.skills", @@ -101,7 +101,7 @@ export class UserSearchService { name: user.name, handle: user.handle, bio: user.bio, - avatarFileId: user.avatarFileId, + avatar: user.avatar, headline: user.headline, location: user.location, skills: user.skills, diff --git a/platforms/profile-editor/api/src/types/profile.ts b/platforms/profile-editor/api/src/types/profile.ts index ee437aee5..38b90219b 100644 --- a/platforms/profile-editor/api/src/types/profile.ts +++ b/platforms/profile-editor/api/src/types/profile.ts @@ -31,8 +31,8 @@ export interface ProfessionalProfile { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; @@ -50,8 +50,8 @@ export interface UserOntologyData { username?: string; displayName?: string; bio?: string; - avatarUrl?: string; - bannerUrl?: string; + avatar?: string; + banner?: string; ename?: string; isVerified?: boolean; isPrivate?: boolean; @@ -71,8 +71,8 @@ export interface ProfileUpdatePayload { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts index 61470bcdd..df8025d1e 100644 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ b/platforms/profile-editor/api/src/utils/file-proxy.ts @@ -1,5 +1,6 @@ import axios from "axios"; import jwt from "jsonwebtoken"; +import FormData from "form-data"; import type { Response } from "express"; const FILE_MANAGER_BASE_URL = () => @@ -11,6 +12,67 @@ function mintFmToken(userId: string): string { return jwt.sign({ userId }, secret, { expiresIn: "1h" }); } +/** + * Downloads an image from a URL (HTTP or data: URI) and uploads it to the + * file-manager service, returning the resulting file ID. + * Returns null on any failure so webhook processing can continue. + */ +export async function downloadUrlAndUploadToFileManager( + url: string, + ename: string, +): Promise { + try { + let buffer: Buffer; + let mimeType = "image/png"; + let filename = "avatar.png"; + + if (url.startsWith("data:")) { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!match) return null; + mimeType = match[1]; + buffer = Buffer.from(match[2], "base64"); + const ext = mimeType.split("/")[1] || "bin"; + filename = `upload.${ext}`; + } else { + const response = await axios.get(url, { + responseType: "arraybuffer", + timeout: 15_000, + }); + buffer = Buffer.from(response.data); + const ct = response.headers["content-type"]; + if (ct) mimeType = ct.split(";")[0].trim(); + const ext = mimeType.split("/")[1] || "bin"; + filename = `upload.${ext}`; + } + + const token = mintFmToken(ename); + const form = new FormData(); + form.append("file", buffer, { filename, contentType: mimeType }); + + const res = await axios.post( + `${FILE_MANAGER_BASE_URL()}/api/files`, + form, + { + headers: { + ...form.getHeaders(), + Authorization: `Bearer ${token}`, + }, + timeout: 30_000, + maxContentLength: Infinity, + maxBodyLength: Infinity, + }, + ); + + return res.data?.id ?? null; + } catch (error: any) { + console.error( + "Failed to download/upload avatar or banner:", + error.message, + ); + return null; + } +} + export async function proxyFileFromFileManager( fileId: string, ename: string, diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json index 415c03fcd..906b2c9e6 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json @@ -7,8 +7,6 @@ "name": "displayName", "headline": "headline", "bio": "bio", - "avatarFileId": "avatarFileId", - "bannerFileId": "bannerFileId", "cvFileId": "cvFileId", "videoIntroFileId": "videoIntroFileId", "location": "location", diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json index 54fef39f6..0801aacc7 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,6 @@ "handle": "username", "name": "displayName", "bio": "bio", - "avatarFileId": "avatarFileId", - "bannerFileId": "bannerFileId", "ename": "ename", "isVerified": "isVerified", "isPublic": "isPublic", diff --git a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte index be9f524f7..8d3ed6d27 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte @@ -20,19 +20,17 @@ let bannerInput = $state(null); let avatarInput = $state(null); - function avatarUrl(): string | null { - if (profile.professional.avatarFileId) { - return getProfileAssetUrl(profile.ename, 'avatar'); - } - return null; - } + let avatarSrc = $derived( + profile.professional.avatar + ? `${getProfileAssetUrl(profile.ename, 'avatar')}?v=${profile.professional.avatar}` + : null + ); - function bannerUrl(): string | null { - if (profile.professional.bannerFileId) { - return getProfileAssetUrl(profile.ename, 'banner'); - } - return null; - } + let bannerSrc = $derived( + profile.professional.banner + ? `${getProfileAssetUrl(profile.ename, 'banner')}?v=${profile.professional.banner}` + : null + ); async function handleAvatarUpload(e: Event) { const input = e.target as HTMLInputElement; @@ -42,7 +40,7 @@ uploadingAvatar = true; try { const result = await uploadFile(file); - await updateProfile({ avatarFileId: result.id }); + await updateProfile({ avatar: result.id }); toast.success('Avatar updated'); } catch { toast.error('Failed to upload avatar'); @@ -59,7 +57,7 @@ uploadingBanner = true; try { const result = await uploadFile(file); - await updateProfile({ bannerFileId: result.id }); + await updateProfile({ banner: result.id }); toast.success('Banner updated'); } catch { toast.error('Failed to upload banner'); @@ -82,8 +80,8 @@
- {#if bannerUrl()} - Banner + {#if bannerSrc} + Banner {/if} {#if editable}
@@ -101,8 +99,8 @@
- {#if avatarUrl()} - + {#if avatarSrc} + {/if} {(profile.name ?? profile.ename ?? '?')[0]?.toUpperCase()} diff --git a/platforms/profile-editor/client/src/lib/stores/discovery.ts b/platforms/profile-editor/client/src/lib/stores/discovery.ts index 13500adc5..91cb9c794 100644 --- a/platforms/profile-editor/client/src/lib/stores/discovery.ts +++ b/platforms/profile-editor/client/src/lib/stores/discovery.ts @@ -10,7 +10,7 @@ export interface ProfileSearchResult { bio: string; location: string; skills: string[]; - avatarFileId: string | null; + avatar: string | null; isVerified: boolean; } diff --git a/platforms/profile-editor/client/src/lib/stores/profile.ts b/platforms/profile-editor/client/src/lib/stores/profile.ts index 40110e511..38d7c8788 100644 --- a/platforms/profile-editor/client/src/lib/stores/profile.ts +++ b/platforms/profile-editor/client/src/lib/stores/profile.ts @@ -40,8 +40,8 @@ export interface ProfileData { displayName?: string; headline?: string; bio?: string; - avatarFileId?: string; - bannerFileId?: string; + avatar?: string; + banner?: string; cvFileId?: string; videoIntroFileId?: string; email?: string; diff --git a/platforms/profile-editor/client/src/lib/utils/axios.ts b/platforms/profile-editor/client/src/lib/utils/axios.ts index 4d9c430d4..903195b55 100644 --- a/platforms/profile-editor/client/src/lib/utils/axios.ts +++ b/platforms/profile-editor/client/src/lib/utils/axios.ts @@ -42,6 +42,6 @@ if (token) { } export const apiClient: AxiosInstance = axios.create({ - baseURL: PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3006', + baseURL: PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3007', headers }); diff --git a/platforms/profile-editor/client/src/lib/utils/file-manager.ts b/platforms/profile-editor/client/src/lib/utils/file-manager.ts index bc0f0d98f..ff16cd9ba 100644 --- a/platforms/profile-editor/client/src/lib/utils/file-manager.ts +++ b/platforms/profile-editor/client/src/lib/utils/file-manager.ts @@ -1,7 +1,7 @@ import { PUBLIC_PROFILE_EDITOR_BASE_URL } from '$env/static/public'; import { apiClient } from './axios'; -const API_BASE = () => PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3006'; +const API_BASE = () => PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3007'; export async function uploadFile( file: File, diff --git a/services/ontology/schemas/professionalProfile.json b/services/ontology/schemas/professionalProfile.json new file mode 100644 index 000000000..f47a02b8c --- /dev/null +++ b/services/ontology/schemas/professionalProfile.json @@ -0,0 +1,181 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "550e8400-e29b-41d4-a716-446655440009", + "title": "ProfessionalProfile", + "type": "object", + "properties": { + "displayName": { + "type": "string", + "description": "The user's display name" + }, + "headline": { + "type": "string", + "description": "A short professional headline or tagline" + }, + "bio": { + "type": "string", + "description": "Professional biography or summary" + }, + "avatar": { + "type": "string", + "description": "File-manager file ID for the user's profile picture" + }, + "banner": { + "type": "string", + "description": "File-manager file ID for the user's profile banner" + }, + "cvFileId": { + "type": "string", + "description": "File-manager file ID for the user's CV/resume document" + }, + "videoIntroFileId": { + "type": "string", + "description": "File-manager file ID for the user's video introduction" + }, + "email": { + "type": "string", + "format": "email", + "description": "Professional contact email" + }, + "phone": { + "type": "string", + "description": "Professional contact phone number" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Personal or professional website URL" + }, + "location": { + "type": "string", + "description": "Professional location or city" + }, + "isPublic": { + "type": "boolean", + "description": "Whether the professional profile is publicly visible" + }, + "workExperience": { + "type": "array", + "description": "List of work experience entries", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entry" + }, + "company": { + "type": "string", + "description": "Company or organization name" + }, + "role": { + "type": "string", + "description": "Job title or role" + }, + "description": { + "type": "string", + "description": "Description of responsibilities and achievements" + }, + "startDate": { + "type": "string", + "format": "date", + "description": "Start date of the position" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "End date of the position (omit if current)" + }, + "location": { + "type": "string", + "description": "Location of the position" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } + }, + "required": ["company", "role", "startDate", "sortOrder"] + } + }, + "education": { + "type": "array", + "description": "List of education entries", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the entry" + }, + "institution": { + "type": "string", + "description": "Educational institution name" + }, + "degree": { + "type": "string", + "description": "Degree or qualification obtained" + }, + "fieldOfStudy": { + "type": "string", + "description": "Field or area of study" + }, + "startDate": { + "type": "string", + "format": "date", + "description": "Start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "End date (omit if ongoing)" + }, + "description": { + "type": "string", + "description": "Additional details about the education" + }, + "sortOrder": { + "type": "integer", + "description": "Display order" + } + }, + "required": ["institution", "degree", "startDate", "sortOrder"] + } + }, + "skills": { + "type": "array", + "description": "List of professional skills", + "items": { + "type": "string" + } + }, + "socialLinks": { + "type": "array", + "description": "List of social media or professional links", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the link" + }, + "platform": { + "type": "string", + "description": "Platform name (e.g. LinkedIn, GitHub, Twitter)" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL to the profile on the platform" + }, + "label": { + "type": "string", + "description": "Optional display label" + } + }, + "required": ["platform", "url"] + } + } + }, + "required": ["displayName"] +} From 70fd0c56905be3b2997584c032aa5efc3089b591 Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Mon, 6 Apr 2026 23:29:17 +0530 Subject: [PATCH 2/3] fix: address coderabbit comments --- .env.example | 2 +- .../dev-sandbox/src/routes/+page.svelte | 8 +- .../api/src/controllers/FileController.ts | 17 +++- .../api/src/database/entities/File.ts | 3 + .../1775700000000-AddIsPublicToFiles.ts | 13 +++ .../api/src/services/FileService.ts | 6 +- .../api/src/controllers/WebhookController.ts | 4 +- .../api/src/services/EVaultProfileService.ts | 14 ++- .../api/src/utils/file-proxy.ts | 87 ++++++++++++++++++- .../client/src/lib/utils/file-manager.ts | 6 +- 10 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts diff --git a/.env.example b/.env.example index 2d6081ea1..07386de5c 100644 --- a/.env.example +++ b/.env.example @@ -92,7 +92,7 @@ PUBLIC_PROVISIONER_SHARED_SECRET="your-provisioner-shared-secret" PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005" -PUBLIC_PROFILE_EDITOR_BASE_URL="http://localhost:3007" +PUBLIC_PROFILE_EDITOR_BASE_URL=http://localhost:3007 DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" diff --git a/infrastructure/dev-sandbox/src/routes/+page.svelte b/infrastructure/dev-sandbox/src/routes/+page.svelte index 35045c6a4..de6147f80 100644 --- a/infrastructure/dev-sandbox/src/routes/+page.svelte +++ b/infrastructure/dev-sandbox/src/routes/+page.svelte @@ -469,8 +469,12 @@ async function doProvision() { identity.w3id, profile, ); - const next = [...identities]; - next[selectedIndex] = { ...next[selectedIndex], displayName: profile.displayName }; + const provisionedKeyId = identity.keyId; + const next = identities.map((id) => + id.keyId === provisionedKeyId + ? { ...id, displayName: profile.displayName } + : id, + ); identities = next; saveIdentities(next); addLog("success", "UserProfile created", profile.displayName); diff --git a/platforms/file-manager/api/src/controllers/FileController.ts b/platforms/file-manager/api/src/controllers/FileController.ts index 2eaf76ea5..96c071d05 100644 --- a/platforms/file-manager/api/src/controllers/FileController.ts +++ b/platforms/file-manager/api/src/controllers/FileController.ts @@ -453,7 +453,7 @@ export class FileController { } const { id } = req.params; - const { displayName, description, folderId } = req.body; + const { displayName, description, folderId, isPublic } = req.body; const file = await this.fileService.updateFile( id, @@ -473,6 +473,11 @@ export class FileController { .json({ error: "File not found or not authorized" }); } + if (typeof isPublic === "boolean") { + await this.fileService.setFilePublic(id, isPublic); + file.isPublic = isPublic; + } + res.json({ id: file.id, name: file.name, @@ -483,6 +488,7 @@ export class FileController { md5Hash: file.md5Hash, ownerId: file.ownerId, folderId: file.folderId, + isPublic: file.isPublic, createdAt: file.createdAt, updatedAt: file.updatedAt, }); @@ -568,8 +574,8 @@ export class FileController { }; /** - * Serves a file publicly without authentication. - * The file ID acts as an unguessable capability token. + * Serves a file publicly. Only files explicitly marked isPublic=true + * are served; all others return 404. */ publicPreview = async (req: Request, res: Response) => { try { @@ -584,7 +590,10 @@ export class FileController { return res.redirect(file.url); } - // Legacy fallback for files still in DB + if (!file.data) { + return res.status(410).json({ error: "File data unavailable" }); + } + res.setHeader("Content-Type", file.mimeType); res.setHeader( "Content-Disposition", diff --git a/platforms/file-manager/api/src/database/entities/File.ts b/platforms/file-manager/api/src/database/entities/File.ts index 5cbf9e488..3fa5cebd3 100644 --- a/platforms/file-manager/api/src/database/entities/File.ts +++ b/platforms/file-manager/api/src/database/entities/File.ts @@ -44,6 +44,9 @@ export class File { @Column({ type: "text", nullable: true }) url!: string | null; + @Column({ default: false }) + isPublic!: boolean; + @Column() ownerId!: string; diff --git a/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts b/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts new file mode 100644 index 000000000..eb10298a2 --- /dev/null +++ b/platforms/file-manager/api/src/database/migrations/1775700000000-AddIsPublicToFiles.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIsPublicToFiles1775700000000 implements MigrationInterface { + name = 'AddIsPublicToFiles1775700000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" ADD "isPublic" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "isPublic"`); + } +} diff --git a/platforms/file-manager/api/src/services/FileService.ts b/platforms/file-manager/api/src/services/FileService.ts index d35db1ab7..db92ce9b5 100644 --- a/platforms/file-manager/api/src/services/FileService.ts +++ b/platforms/file-manager/api/src/services/FileService.ts @@ -116,7 +116,7 @@ export class FileService { async getFileByIdPublic(id: string): Promise { const file = await this.fileRepository.findOne({ - where: { id }, + where: { id, isPublic: true }, }); if (!file || file.name === SOFT_DELETED_FILE_NAME) { return null; @@ -124,6 +124,10 @@ export class FileService { return file; } + async setFilePublic(id: string, isPublic: boolean): Promise { + await this.fileRepository.update(id, { isPublic }); + } + async getFileById(id: string, userId: string): Promise { const file = await this.fileRepository.findOne({ where: { id }, diff --git a/platforms/pictique/api/src/controllers/WebhookController.ts b/platforms/pictique/api/src/controllers/WebhookController.ts index 8a988bc05..e8f67226f 100644 --- a/platforms/pictique/api/src/controllers/WebhookController.ts +++ b/platforms/pictique/api/src/controllers/WebhookController.ts @@ -41,11 +41,13 @@ export class WebhookController { const mapping = Object.values(this.adapter.mapping).find( (m) => m.schemaId === schemaId ); - this.adapter.addToLockedIds(globalId); if (!mapping) { + console.log(`[webhook] skipping unknown schema ${schemaId} for ${globalId}`); return res.status(200).send(); } + + this.adapter.addToLockedIds(globalId); const local = await this.adapter.fromGlobal({ data: req.body.data, mapping, diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index d766a2ed6..7fd5a11e9 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -295,6 +295,11 @@ export class EVaultProfileService { if (data.avatar !== undefined) u.avatar = data.avatar; if (data.banner !== undefined) u.banner = data.banner; await repo.save(u); + + // Mark new avatar/banner files as publicly accessible + const { markFilePublic } = await import("../utils/file-proxy"); + if (data.avatar) markFilePublic(data.avatar, eName).catch(() => {}); + if (data.banner) markFilePublic(data.banner, eName).catch(() => {}); } const cached = this.cache.get(eName); @@ -470,7 +475,10 @@ export class EVaultProfileService { try { const userNode = await this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY); const existing = (userNode?.parsed ?? {}) as Record; + // Preserve the existing ACL; only default to public for new envelopes + const existingAcl = (userNode as any)?.acl; + // Only patch avatarUrl/bannerUrl — don't overwrite other User fields const patch: Record = { ...existing }; if (profile.avatar) { patch.avatarUrl = getFileManagerPublicUrl(profile.avatar); @@ -482,7 +490,11 @@ export class EVaultProfileService { if (userNode) { await client.request(UPDATE_MUTATION, { id: userNode.id, - input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] }, + input: { + ontology: USER_ONTOLOGY, + payload: patch, + acl: existingAcl ?? ["*"], + }, }); } else { patch.ename = eName; diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts index 5ba1f9859..f160dc369 100644 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ b/platforms/profile-editor/api/src/utils/file-proxy.ts @@ -12,6 +12,59 @@ function mintFmToken(userId: string): string { return jwt.sign({ userId }, secret, { expiresIn: "1h" }); } +import dns from "dns/promises"; +import net from "net"; + +/** + * Validates a URL is safe to fetch (not internal/private). + * Blocks non-https schemes (except data:), loopback, private, and link-local IPs. + */ +async function isUrlAllowed(url: string): Promise { + if (url.startsWith("data:")) return true; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + + const hostname = parsed.hostname; + if (hostname === "localhost" || hostname === "[::1]") return false; + + // Resolve hostname to IP and check for private ranges + let addresses: string[]; + try { + if (net.isIP(hostname)) { + addresses = [hostname]; + } else { + const results = await dns.resolve4(hostname).catch(() => [] as string[]); + const results6 = await dns.resolve6(hostname).catch(() => [] as string[]); + addresses = [...results, ...results6]; + } + } catch { + return false; + } + + for (const addr of addresses) { + if (net.isIPv4(addr)) { + const parts = addr.split(".").map(Number); + if (parts[0] === 127) return false; // 127.0.0.0/8 + if (parts[0] === 10) return false; // 10.0.0.0/8 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) return false; // 192.168.0.0/16 + if (parts[0] === 169 && parts[1] === 254) return false; // 169.254.0.0/16 + if (parts[0] === 0) return false; // 0.0.0.0/8 + } else if (net.isIPv6(addr)) { + const normalized = addr.toLowerCase(); + if (normalized === "::1") return false; + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return false; // fc00::/7 + if (normalized.startsWith("fe80")) return false; // fe80::/10 + } + } + return true; +} + /** * Downloads an image from a URL (HTTP or data: URI) and uploads it to the * file-manager service, returning the resulting file ID. @@ -22,6 +75,11 @@ export async function downloadUrlAndUploadToFileManager( ename: string, ): Promise { try { + if (!(await isUrlAllowed(url))) { + console.warn("SSRF blocked: disallowed URL", url); + return null; + } + let buffer: Buffer; let mimeType = "image/png"; let filename = "avatar.png"; @@ -37,6 +95,7 @@ export async function downloadUrlAndUploadToFileManager( const response = await axios.get(url, { responseType: "arraybuffer", timeout: 15_000, + maxRedirects: 3, }); buffer = Buffer.from(response.data); const ct = response.headers["content-type"]; @@ -63,7 +122,11 @@ export async function downloadUrlAndUploadToFileManager( }, ); - return res.data?.id ?? null; + const fileId = res.data?.id; + if (fileId) { + await markFilePublic(fileId, ename); + } + return fileId ?? null; } catch (error: any) { console.error( "Failed to download/upload avatar or banner:", @@ -73,6 +136,28 @@ export async function downloadUrlAndUploadToFileManager( } } +/** + * Marks a file as publicly accessible in file-manager via PATCH. + */ +export async function markFilePublic(fileId: string, ename: string): Promise { + try { + const token = mintFmToken(ename); + await axios.patch( + `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}`, + { isPublic: true }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + timeout: 10_000, + }, + ); + } catch (error: any) { + console.error("Failed to mark file as public:", error.message); + } +} + export async function proxyFileFromFileManager( fileId: string, ename: string, diff --git a/platforms/profile-editor/client/src/lib/utils/file-manager.ts b/platforms/profile-editor/client/src/lib/utils/file-manager.ts index 70ee0f28d..88271c4d1 100644 --- a/platforms/profile-editor/client/src/lib/utils/file-manager.ts +++ b/platforms/profile-editor/client/src/lib/utils/file-manager.ts @@ -1,8 +1,5 @@ -import { PUBLIC_PROFILE_EDITOR_BASE_URL } from '$env/static/public'; import { apiClient } from './axios'; -const API_BASE = () => PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3007'; - export async function uploadFile( file: File, onProgress?: (progress: number) => void @@ -27,5 +24,6 @@ export async function uploadFile( export type ProfileAssetType = 'avatar' | 'banner' | 'cv' | 'video'; export function getProfileAssetUrl(ename: string, type: ProfileAssetType): string { - return `${API_BASE()}/api/profiles/${encodeURIComponent(ename)}/${type}`; + const base = apiClient.defaults.baseURL || 'http://localhost:3007'; + return `${base}/api/profiles/${encodeURIComponent(ename)}/${type}`; } From b4164c9b232ea78ab3ffcd2595c6f3337c0c36ea Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Tue, 7 Apr 2026 06:58:53 +0530 Subject: [PATCH 3/3] fix: app source initialization --- .../profile-editor/api/src/services/EVaultProfileService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index 7fd5a11e9..ab3b4888a 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -129,6 +129,9 @@ function buildPayload(merged: ProfessionalProfile): Record { async function getLocalUser(eName: string) { const { AppDataSource } = await import("../database/data-source"); const { User } = await import("../database/entities/User"); + if (!AppDataSource.isInitialized) { + throw new Error("Database not initialized — cannot access local user"); + } const repo = AppDataSource.getRepository(User); return { repo, user: await repo.findOneBy({ ename: eName }), User }; }