Skip to content

Commit 70fd0c5

Browse files
committed
fix: address coderabbit comments
1 parent a8fd4f9 commit 70fd0c5

10 files changed

Lines changed: 145 additions & 15 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ PUBLIC_PROVISIONER_SHARED_SECRET="your-provisioner-shared-secret"
9292

9393
PUBLIC_ESIGNER_BASE_URL="http://localhost:3004"
9494
PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005"
95-
PUBLIC_PROFILE_EDITOR_BASE_URL="http://localhost:3007"
95+
PUBLIC_PROFILE_EDITOR_BASE_URL=http://localhost:3007
9696

9797
DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync
9898
VITE_DREAMSYNC_BASE_URL="http://localhost:8888"

infrastructure/dev-sandbox/src/routes/+page.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,12 @@ async function doProvision() {
469469
identity.w3id,
470470
profile,
471471
);
472-
const next = [...identities];
473-
next[selectedIndex] = { ...next[selectedIndex], displayName: profile.displayName };
472+
const provisionedKeyId = identity.keyId;
473+
const next = identities.map((id) =>
474+
id.keyId === provisionedKeyId
475+
? { ...id, displayName: profile.displayName }
476+
: id,
477+
);
474478
identities = next;
475479
saveIdentities(next);
476480
addLog("success", "UserProfile created", profile.displayName);

platforms/file-manager/api/src/controllers/FileController.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ export class FileController {
453453
}
454454

455455
const { id } = req.params;
456-
const { displayName, description, folderId } = req.body;
456+
const { displayName, description, folderId, isPublic } = req.body;
457457

458458
const file = await this.fileService.updateFile(
459459
id,
@@ -473,6 +473,11 @@ export class FileController {
473473
.json({ error: "File not found or not authorized" });
474474
}
475475

476+
if (typeof isPublic === "boolean") {
477+
await this.fileService.setFilePublic(id, isPublic);
478+
file.isPublic = isPublic;
479+
}
480+
476481
res.json({
477482
id: file.id,
478483
name: file.name,
@@ -483,6 +488,7 @@ export class FileController {
483488
md5Hash: file.md5Hash,
484489
ownerId: file.ownerId,
485490
folderId: file.folderId,
491+
isPublic: file.isPublic,
486492
createdAt: file.createdAt,
487493
updatedAt: file.updatedAt,
488494
});
@@ -568,8 +574,8 @@ export class FileController {
568574
};
569575

570576
/**
571-
* Serves a file publicly without authentication.
572-
* The file ID acts as an unguessable capability token.
577+
* Serves a file publicly. Only files explicitly marked isPublic=true
578+
* are served; all others return 404.
573579
*/
574580
publicPreview = async (req: Request, res: Response) => {
575581
try {
@@ -584,7 +590,10 @@ export class FileController {
584590
return res.redirect(file.url);
585591
}
586592

587-
// Legacy fallback for files still in DB
593+
if (!file.data) {
594+
return res.status(410).json({ error: "File data unavailable" });
595+
}
596+
588597
res.setHeader("Content-Type", file.mimeType);
589598
res.setHeader(
590599
"Content-Disposition",

platforms/file-manager/api/src/database/entities/File.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export class File {
4444
@Column({ type: "text", nullable: true })
4545
url!: string | null;
4646

47+
@Column({ default: false })
48+
isPublic!: boolean;
49+
4750
@Column()
4851
ownerId!: string;
4952

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class AddIsPublicToFiles1775700000000 implements MigrationInterface {
4+
name = 'AddIsPublicToFiles1775700000000'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "files" ADD "isPublic" boolean NOT NULL DEFAULT false`);
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "isPublic"`);
12+
}
13+
}

platforms/file-manager/api/src/services/FileService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,18 @@ export class FileService {
116116

117117
async getFileByIdPublic(id: string): Promise<File | null> {
118118
const file = await this.fileRepository.findOne({
119-
where: { id },
119+
where: { id, isPublic: true },
120120
});
121121
if (!file || file.name === SOFT_DELETED_FILE_NAME) {
122122
return null;
123123
}
124124
return file;
125125
}
126126

127+
async setFilePublic(id: string, isPublic: boolean): Promise<void> {
128+
await this.fileRepository.update(id, { isPublic });
129+
}
130+
127131
async getFileById(id: string, userId: string): Promise<File | null> {
128132
const file = await this.fileRepository.findOne({
129133
where: { id },

platforms/pictique/api/src/controllers/WebhookController.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ export class WebhookController {
4141
const mapping = Object.values(this.adapter.mapping).find(
4242
(m) => m.schemaId === schemaId
4343
);
44-
this.adapter.addToLockedIds(globalId);
4544

4645
if (!mapping) {
46+
console.log(`[webhook] skipping unknown schema ${schemaId} for ${globalId}`);
4747
return res.status(200).send();
4848
}
49+
50+
this.adapter.addToLockedIds(globalId);
4951
const local = await this.adapter.fromGlobal({
5052
data: req.body.data,
5153
mapping,

platforms/profile-editor/api/src/services/EVaultProfileService.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,11 @@ export class EVaultProfileService {
295295
if (data.avatar !== undefined) u.avatar = data.avatar;
296296
if (data.banner !== undefined) u.banner = data.banner;
297297
await repo.save(u);
298+
299+
// Mark new avatar/banner files as publicly accessible
300+
const { markFilePublic } = await import("../utils/file-proxy");
301+
if (data.avatar) markFilePublic(data.avatar, eName).catch(() => {});
302+
if (data.banner) markFilePublic(data.banner, eName).catch(() => {});
298303
}
299304

300305
const cached = this.cache.get(eName);
@@ -470,7 +475,10 @@ export class EVaultProfileService {
470475
try {
471476
const userNode = await this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY);
472477
const existing = (userNode?.parsed ?? {}) as Record<string, unknown>;
478+
// Preserve the existing ACL; only default to public for new envelopes
479+
const existingAcl = (userNode as any)?.acl;
473480

481+
// Only patch avatarUrl/bannerUrl — don't overwrite other User fields
474482
const patch: Record<string, unknown> = { ...existing };
475483
if (profile.avatar) {
476484
patch.avatarUrl = getFileManagerPublicUrl(profile.avatar);
@@ -482,7 +490,11 @@ export class EVaultProfileService {
482490
if (userNode) {
483491
await client.request<UpdateResult>(UPDATE_MUTATION, {
484492
id: userNode.id,
485-
input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] },
493+
input: {
494+
ontology: USER_ONTOLOGY,
495+
payload: patch,
496+
acl: existingAcl ?? ["*"],
497+
},
486498
});
487499
} else {
488500
patch.ename = eName;

platforms/profile-editor/api/src/utils/file-proxy.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,59 @@ function mintFmToken(userId: string): string {
1212
return jwt.sign({ userId }, secret, { expiresIn: "1h" });
1313
}
1414

15+
import dns from "dns/promises";
16+
import net from "net";
17+
18+
/**
19+
* Validates a URL is safe to fetch (not internal/private).
20+
* Blocks non-https schemes (except data:), loopback, private, and link-local IPs.
21+
*/
22+
async function isUrlAllowed(url: string): Promise<boolean> {
23+
if (url.startsWith("data:")) return true;
24+
let parsed: URL;
25+
try {
26+
parsed = new URL(url);
27+
} catch {
28+
return false;
29+
}
30+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
31+
32+
const hostname = parsed.hostname;
33+
if (hostname === "localhost" || hostname === "[::1]") return false;
34+
35+
// Resolve hostname to IP and check for private ranges
36+
let addresses: string[];
37+
try {
38+
if (net.isIP(hostname)) {
39+
addresses = [hostname];
40+
} else {
41+
const results = await dns.resolve4(hostname).catch(() => [] as string[]);
42+
const results6 = await dns.resolve6(hostname).catch(() => [] as string[]);
43+
addresses = [...results, ...results6];
44+
}
45+
} catch {
46+
return false;
47+
}
48+
49+
for (const addr of addresses) {
50+
if (net.isIPv4(addr)) {
51+
const parts = addr.split(".").map(Number);
52+
if (parts[0] === 127) return false; // 127.0.0.0/8
53+
if (parts[0] === 10) return false; // 10.0.0.0/8
54+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; // 172.16.0.0/12
55+
if (parts[0] === 192 && parts[1] === 168) return false; // 192.168.0.0/16
56+
if (parts[0] === 169 && parts[1] === 254) return false; // 169.254.0.0/16
57+
if (parts[0] === 0) return false; // 0.0.0.0/8
58+
} else if (net.isIPv6(addr)) {
59+
const normalized = addr.toLowerCase();
60+
if (normalized === "::1") return false;
61+
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return false; // fc00::/7
62+
if (normalized.startsWith("fe80")) return false; // fe80::/10
63+
}
64+
}
65+
return true;
66+
}
67+
1568
/**
1669
* Downloads an image from a URL (HTTP or data: URI) and uploads it to the
1770
* file-manager service, returning the resulting file ID.
@@ -22,6 +75,11 @@ export async function downloadUrlAndUploadToFileManager(
2275
ename: string,
2376
): Promise<string | null> {
2477
try {
78+
if (!(await isUrlAllowed(url))) {
79+
console.warn("SSRF blocked: disallowed URL", url);
80+
return null;
81+
}
82+
2583
let buffer: Buffer;
2684
let mimeType = "image/png";
2785
let filename = "avatar.png";
@@ -37,6 +95,7 @@ export async function downloadUrlAndUploadToFileManager(
3795
const response = await axios.get(url, {
3896
responseType: "arraybuffer",
3997
timeout: 15_000,
98+
maxRedirects: 3,
4099
});
41100
buffer = Buffer.from(response.data);
42101
const ct = response.headers["content-type"];
@@ -63,7 +122,11 @@ export async function downloadUrlAndUploadToFileManager(
63122
},
64123
);
65124

66-
return res.data?.id ?? null;
125+
const fileId = res.data?.id;
126+
if (fileId) {
127+
await markFilePublic(fileId, ename);
128+
}
129+
return fileId ?? null;
67130
} catch (error: any) {
68131
console.error(
69132
"Failed to download/upload avatar or banner:",
@@ -73,6 +136,28 @@ export async function downloadUrlAndUploadToFileManager(
73136
}
74137
}
75138

139+
/**
140+
* Marks a file as publicly accessible in file-manager via PATCH.
141+
*/
142+
export async function markFilePublic(fileId: string, ename: string): Promise<void> {
143+
try {
144+
const token = mintFmToken(ename);
145+
await axios.patch(
146+
`${FILE_MANAGER_BASE_URL()}/api/files/${fileId}`,
147+
{ isPublic: true },
148+
{
149+
headers: {
150+
"Content-Type": "application/json",
151+
Authorization: `Bearer ${token}`,
152+
},
153+
timeout: 10_000,
154+
},
155+
);
156+
} catch (error: any) {
157+
console.error("Failed to mark file as public:", error.message);
158+
}
159+
}
160+
76161
export async function proxyFileFromFileManager(
77162
fileId: string,
78163
ename: string,

platforms/profile-editor/client/src/lib/utils/file-manager.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { PUBLIC_PROFILE_EDITOR_BASE_URL } from '$env/static/public';
21
import { apiClient } from './axios';
32

4-
const API_BASE = () => PUBLIC_PROFILE_EDITOR_BASE_URL || 'http://localhost:3007';
5-
63
export async function uploadFile(
74
file: File,
85
onProgress?: (progress: number) => void
@@ -27,5 +24,6 @@ export async function uploadFile(
2724
export type ProfileAssetType = 'avatar' | 'banner' | 'cv' | 'video';
2825

2926
export function getProfileAssetUrl(ename: string, type: ProfileAssetType): string {
30-
return `${API_BASE()}/api/profiles/${encodeURIComponent(ename)}/${type}`;
27+
const base = apiClient.defaults.baseURL || 'http://localhost:3007';
28+
return `${base}/api/profiles/${encodeURIComponent(ename)}/${type}`;
3129
}

0 commit comments

Comments
 (0)