Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion infrastructure/dev-sandbox/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface Identity {
w3id: string;
uri: string;
keyId: string;
displayName?: string;
bearerToken?: string;
tokenExpiresAt?: number;
}
Expand Down Expand Up @@ -468,6 +469,14 @@ async function doProvision() {
identity.w3id,
profile,
);
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);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Expand Down Expand Up @@ -826,7 +835,7 @@ async function doSign() {
<h2>Selected identity</h2>
<select bind:value={selectedIndex}>
{#each identities as id, i}
<option value={i}>{id.w3id}</option>
<option value={i}>{id.displayName ? `${id.displayName} (${id.w3id})` : id.w3id}</option>
{/each}
</select>
</section>
Expand Down
43 changes: 42 additions & 1 deletion platforms/file-manager/api/src/controllers/FileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
});
Expand Down Expand Up @@ -567,6 +573,41 @@ export class FileController {
}
};

/**
* Serves a file publicly. Only files explicitly marked isPublic=true
* are served; all others return 404.
*/
publicPreview = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const file = await this.fileService.getFileByIdPublic(id);
Comment on lines +580 to +583
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Security concern inherited from service layer.

This endpoint exposes the security issue identified in FileService.getFileByIdPublic — any non-deleted file becomes publicly accessible without authorization. See the review comment on FileService.ts for the recommended fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@platforms/file-manager/api/src/controllers/FileController.ts` around lines
574 - 577, The publicPreview endpoint currently calls
FileService.getFileByIdPublic which exposes any non-deleted file without auth;
update publicPreview to enforce access control by either calling a service
method that verifies requester permissions (e.g.,
FileService.getFileByIdWithAccess or a new FileService.checkAccessForFile) or by
validating the returned file's visibility/owner and the requester's auth (check
file.deleted, file.visibility/permissions and req.user) and return 403/404 when
access is not allowed; reference FileController.publicPreview and
FileService.getFileByIdPublic when making the change so the controller only
serves files that pass the access check.


if (!file) {
return res.status(404).json({ error: "File not found" });
}

if (file.url) {
return res.redirect(file.url);
}

if (!file.data) {
return res.status(410).json({ error: "File data unavailable" });
}

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) {
Expand Down
3 changes: 3 additions & 0 deletions platforms/file-manager/api/src/database/entities/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export class File {
@Column({ type: "text", nullable: true })
url!: string | null;

@Column({ default: false })
isPublic!: boolean;

@Column()
ownerId!: string;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddIsPublicToFiles1775700000000 implements MigrationInterface {
name = 'AddIsPublicToFiles1775700000000'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "files" ADD "isPublic" boolean NOT NULL DEFAULT false`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "isPublic"`);
}
}
1 change: 1 addition & 0 deletions platforms/file-manager/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Public route requires access control in service layer.

This route correctly bypasses authentication middleware, but the underlying FileService.getFileByIdPublic has no access control mechanism. As noted in the FileService review, this exposes all files publicly.

Before merging, either:

  1. Add an isPublic flag to the File entity and check it in getFileByIdPublic
  2. Restrict this endpoint to specific file types or contexts (e.g., only profile avatars/banners)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@platforms/file-manager/api/src/index.ts` at line 83, The public GET route
app.get("/api/public/files/:id", fileController.publicPreview) delegates to
FileService.getFileByIdPublic which currently lacks access control; update the
service (FileService.getFileByIdPublic) to enforce access rules by either (A)
adding an isPublic boolean to the File entity and having getFileByIdPublic
verify file.isPublic before returning it, or (B) restricting the
endpoint/service to only allow specific kinds of files (e.g., only profile
avatars/banners) by checking file.type or file.context in getFileByIdPublic and
returning 403/404 for disallowed files; ensure the controller still calls the
secured service method and return appropriate error codes when access is denied.


// Protected routes (auth required)
app.use(authMiddleware);
Expand Down
14 changes: 14 additions & 0 deletions platforms/file-manager/api/src/services/FileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export class FileService {
return savedFile;
}

async getFileByIdPublic(id: string): Promise<File | null> {
const file = await this.fileRepository.findOne({
where: { id, isPublic: true },
});
if (!file || file.name === SOFT_DELETED_FILE_NAME) {
return null;
}
return file;
}
Comment on lines +117 to +125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for isPublic or public flag in File entity
rg -n 'isPublic|public' platforms/file-manager/api/src/database/entities/File.ts

# Check if there's any authorization in the public preview flow
ast-grep --pattern 'getFileByIdPublic($_) {
  $$$
}'

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Check if File.ts exists and read its structure
find platforms/file-manager -name "File.ts" -o -name "File.entity.ts" -o -name "file.entity.ts" | head -20

Repository: MetaState-Prototype-Project/prototype

Length of output: 137


🏁 Script executed:

# Read the File entity to see its actual properties
cat -n platforms/file-manager/api/src/database/entities/File.ts | head -100

Repository: MetaState-Prototype-Project/prototype

Length of output: 2400


🏁 Script executed:

# Read FileService.ts around the method to see full context and any authorization
cat -n platforms/file-manager/api/src/services/FileService.ts | sed -n '100,150p'

Repository: MetaState-Prototype-Project/prototype

Length of output: 1857


🏁 Script executed:

# Search for public endpoints related to files
rg -n 'getFileByIdPublic|public/files' platforms/file-manager/api/src/controllers/ -A 5 -B 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 839


🏁 Script executed:

# Search for any isPublic, isPrivate, visibility, or permission fields in File entity
rg -n 'Column\|@' platforms/file-manager/api/src/database/entities/File.ts

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Read FileController around the public endpoint
cat -n platforms/file-manager/api/src/controllers/FileController.ts | sed -n '570,600p'

Repository: MetaState-Prototype-Project/prototype

Length of output: 1404


🏁 Script executed:

# Search for route decorators/middleware before the handler
rg -B 10 'getFileByIdPublic' platforms/file-manager/api/src/controllers/FileController.ts | head -30

Repository: MetaState-Prototype-Project/prototype

Length of output: 413


🏁 Script executed:

# Check if there's a route definition with auth decorators
rg -B 5 'publicPreview' platforms/file-manager/api/src/controllers/FileController.ts | grep -E '@|router\.'

Repository: MetaState-Prototype-Project/prototype

Length of output: 63


🏁 Script executed:

# Search for any middleware or auth decorators on the route
rg 'router.*publicPreview|app.*publicPreview|@' platforms/file-manager/api/src -A 2 -B 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 38106


🏁 Script executed:

# Check route definitions
find platforms/file-manager -name "*route*" -o -name "*router*" | head -10

Repository: MetaState-Prototype-Project/prototype

Length of output: 121


Critical: Unauthenticated access to all files without distinguishing public vs. private.

The getFileByIdPublic method retrieves ANY non-deleted file by ID without verifying whether the file is intended to be publicly accessible. The File entity has no isPublic or visibility flag—all files in the system become accessible to anyone who obtains a file ID.

The controller's own comment acknowledges this relies on "unguessable capability tokens" (UUIDs). However, security through obscurity is insufficient; file IDs may leak through URLs, logs, client-side code, or error messages, exposing private user documents.

Add an isPublic flag to the File entity and check it:

Recommended fix
 async getFileByIdPublic(id: string): Promise<File | null> {
     const file = await this.fileRepository.findOne({
-        where: { id },
+        where: { id, isPublic: true },
     });
     if (!file || file.name === SOFT_DELETED_FILE_NAME) {
         return null;
     }
     return file;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@platforms/file-manager/api/src/services/FileService.ts` around lines 117 -
125, The getFileByIdPublic method currently returns any non-deleted file by id;
add a boolean visibility flag to the File entity (e.g., isPublic: boolean,
default false), update the repository query in getFileByIdPublic to include
where: { id, isPublic: true } (or use fileRepository.findOne({ where: { id,
isPublic: true } })) and ensure any existing creation/update flows (e.g., File
constructor, save/update handlers) set isPublic appropriately; also add a
migration to persist the new isPublic column and update any tests that assume
public access.


async setFilePublic(id: string, isPublic: boolean): Promise<void> {
await this.fileRepository.update(id, { isPublic });
}

async getFileById(id: string, userId: string): Promise<File | null> {
const file = await this.fileRepository.findOne({
where: { id },
Expand Down
8 changes: 8 additions & 0 deletions platforms/marketplace/client/client/src/data/apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const appDetails: Record<string, { fullDescription: string; screenshots: string[
"file-manager": {
fullDescription: "File Manager is a decentralized file management system built on the Web 3.0 Data Space (W3DS) architecture. Organize, store, and share files with complete control over your data across the MetaState ecosystem.\n\nBuilt around the principle of data-platform separation, all your files are stored in your own sovereign eVault. Manage folders, organize documents, control access, and share files securely. Experience file management reimagined with privacy-first principles and complete data sovereignty.",
screenshots: []
},
"profile-editor": {
fullDescription: "Profile Editor is a professional profile management platform built on the Web 3.0 Data Space (W3DS) architecture. Create, edit, and share your professional profile across the entire MetaState ecosystem with a single source of truth.\n\nShowcase your work experience, education, skills, and social links. Upload your CV and video introduction. All your profile data is stored in your own sovereign eVault and automatically synced across every W3DS platform — update once, reflected everywhere.",
screenshots: []
}
};

Expand Down
8 changes: 6 additions & 2 deletions platforms/pictique/api/src/controllers/WebhookController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,13 @@ export class WebhookController {
const mapping = Object.values(this.adapter.mapping).find(
(m) => m.schemaId === schemaId
);
this.adapter.addToLockedIds(globalId);

if (!mapping) throw new Error();
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ export class ProfileController {
data: Partial<ProfessionalProfile>,
res: Response,
) {
console.log(`[controller] optimisticUpdate ${ename}: keys=[${Object.keys(data).join(",")}] avatarFileId=${(data as any).avatarFileId ?? "N/A"} bannerFileId=${(data as any).bannerFileId ?? "N/A"}`);
console.log(`[controller] optimisticUpdate ${ename}: keys=[${Object.keys(data).join(",")}] avatar=${(data as any).avatar ?? "N/A"} banner=${(data as any).banner ?? "N/A"}`);
const { profile, persisted } = await this.evaultService.prepareUpdate(ename, data);
console.log(`[controller] optimisticUpdate ${ename}: returning avatarFileId=${profile.professional.avatarFileId ?? "NONE"} bannerFileId=${profile.professional.bannerFileId ?? "NONE"}`);
console.log(`[controller] optimisticUpdate ${ename}: returning avatar=${profile.professional.avatar ?? "NONE"} banner=${profile.professional.banner ?? "NONE"}`);
// Fire eVault write in background — don't block the response
persisted
.then(() => {
Expand Down Expand Up @@ -221,7 +221,7 @@ export class ProfileController {
return res.status(403).json({ error: "This profile is private" });
}

const fileId = profile.professional.avatarFileId;
const fileId = profile.professional.avatar;
if (!fileId) {
console.log(`[profile] avatar ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`);
return res.status(404).json({ error: "No avatar set" });
Expand Down Expand Up @@ -249,7 +249,7 @@ export class ProfileController {
return res.status(403).json({ error: "This profile is private" });
}

const fileId = profile.professional.bannerFileId;
const fileId = profile.professional.banner;
if (!fileId) {
console.log(`[profile] banner ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`);
return res.status(404).json({ error: "No banner set" });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from "express";
import { Web3Adapter } from "web3-adapter";
import { UserSearchService } from "../services/UserSearchService";
import { downloadUrlAndUploadToFileManager } from "../utils/file-proxy";

export class WebhookController {
private userSearchService: UserSearchService;
Expand Down Expand Up @@ -75,8 +76,20 @@ export class WebhookController {
isArchived: localData.isArchived ?? false,
};

if (localData.avatarFileId) userData.avatarFileId = localData.avatarFileId;
if (localData.bannerFileId) userData.bannerFileId = localData.bannerFileId;
if (localData.avatar) userData.avatar = localData.avatar;
if (localData.banner) userData.banner = localData.banner;

// If the source platform sent a URL (Blabsy/Pictique) instead of a
// file-manager ID, download the image and upload it to file-manager.
if (!userData.avatar && rawBody.data?.avatarUrl) {
const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename);
if (fileId) userData.avatar = fileId;
}
if (!userData.banner && rawBody.data?.bannerUrl) {
const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename);
if (fileId) userData.banner = fileId;
}

if (localData.location) userData.location = localData.location;

const user = await this.userSearchService.upsertFromWebhook(userData);
Expand All @@ -98,7 +111,7 @@ export class WebhookController {
const ename = rawBody.w3id;
if (!ename) return;

console.log(`[webhook] professional_profile ${ename}: avatarFileId=${localData.avatarFileId ?? "NONE"} bannerFileId=${localData.bannerFileId ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`);
console.log(`[webhook] professional_profile ${ename}: avatar=${localData.avatar ?? "NONE"} banner=${localData.banner ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`);

const profileData: any = { ename };

Expand All @@ -107,10 +120,22 @@ export class WebhookController {
}
if (localData.headline) profileData.headline = localData.headline;
if (localData.bio) profileData.bio = localData.bio;
if (localData.avatarFileId)
profileData.avatarFileId = localData.avatarFileId;
if (localData.bannerFileId)
profileData.bannerFileId = localData.bannerFileId;
if (localData.avatar)
profileData.avatar = localData.avatar;
if (localData.banner)
profileData.banner = localData.banner;

// If the source platform sent a URL instead of a file-manager ID,
// download the image and upload it to file-manager.
if (!profileData.avatar && rawBody.data?.avatarUrl) {
const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename);
if (fileId) profileData.avatar = fileId;
}
if (!profileData.banner && rawBody.data?.bannerUrl) {
const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename);
if (fileId) profileData.banner = fileId;
}

if (localData.cvFileId) profileData.cvFileId = localData.cvFileId;
if (localData.videoIntroFileId)
profileData.videoIntroFileId = localData.videoIntroFileId;
Expand Down
4 changes: 2 additions & 2 deletions platforms/profile-editor/api/src/database/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ export class User {
bio!: string;

@Column({ nullable: true })
avatarFileId!: string;
avatar!: string;

@Column({ nullable: true })
bannerFileId!: string;
banner!: string;

@Column({ nullable: true })
headline!: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class RenameAvatarBannerColumns1775600000000 implements MigrationInterface {
name = 'RenameAvatarBannerColumns1775600000000'

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatar" TO "avatarFileId"`);
await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "banner" TO "bannerFileId"`);
}
}
2 changes: 1 addition & 1 deletion platforms/profile-editor/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading