Skip to content
Merged
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
38 changes: 38 additions & 0 deletions backend/__tests__/unit/models/ExternalLink.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,42 @@ describe('ExternalLink Model Tests', () => {
expect(saved.type).toBe('wechat');
expect(saved.qrCodePath).toBe('/path/to/qr.png');
});

it.each([
'notion',
'google_doc',
'google_sheet',
'google_slides',
'google_drive',
'figma',
'zoom',
'gmail',
'github_pr',
'github_issue',
'github_repo',
'youtube',
'loom',
'other_link',
])('accepts artifact type %s with url', async (type) => {
const link = new ExternalLink({
podId: pod._id,
name: `${type} doc`,
type,
url: 'https://example.com/x',
createdBy: user._id,
});
const saved = await link.save();
expect(saved.type).toBe(type);
});

it('rejects unknown type', async () => {
const link = new ExternalLink({
podId: pod._id,
name: 'Bogus',
type: 'not-a-real-type',
url: 'https://example.com',
createdBy: user._id,
});
await expect(link.save()).rejects.toThrow();
});
});
128 changes: 124 additions & 4 deletions backend/__tests__/unit/routes/pods.external-links.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('pods routes - external links', () => {
const podSave = jest.fn();
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'user1' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: podSave,
});
Expand Down Expand Up @@ -66,6 +67,122 @@ describe('pods routes - external links', () => {
expect(podSave).toHaveBeenCalled();
});

it('member (non-owner) can add a link', async () => {
const podSave = jest.fn();
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'someone-else' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: podSave,
});
const linkSave = jest.fn();
ExternalLink.mockImplementation((data) => ({ ...data, _id: 'l2', save: linkSave }));
await request(app)
.post('/api/pods/external-link')
.send({
podId: 'p1',
name: 'n',
type: 'notion',
url: 'https://notion.so/foo',
})
.expect(201);
expect(linkSave).toHaveBeenCalled();
});

it('auto-detects type when client passes type=auto', async () => {
const podSave = jest.fn();
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'user1' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: podSave,
});
const linkSave = jest.fn();
ExternalLink.mockImplementation((data) => ({ ...data, _id: 'l3', save: linkSave }));
await request(app)
.post('/api/pods/external-link')
.send({ podId: 'p1', type: 'auto', url: 'https://docs.google.com/document/d/abc/edit' })
.expect(201);
expect(ExternalLink).toHaveBeenCalledWith(
expect.objectContaining({ podId: 'p1', type: 'google_doc', createdBy: 'user1' }),
);
});

it.each([
['https://notion.so/workspace/page-id', 'notion'],
['https://acme.notion.so/page-id', 'notion'],
['https://www.figma.com/file/abc/Design', 'figma'],
['https://us02web.zoom.us/j/12345', 'zoom'],
['https://drive.google.com/file/d/abc/view', 'google_drive'],
['https://youtu.be/abc123', 'youtube'],
['https://www.loom.com/share/abc', 'loom'],
['https://example.com/random', 'other_link'],
])('auto-detect maps %s → %s', async (url, expected) => {
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'user1' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: jest.fn(),
});
ExternalLink.mockImplementation((data) => ({
...data,
_id: 'lx',
save: jest.fn(),
}));
await request(app)
.post('/api/pods/external-link')
.send({ podId: 'p1', type: 'auto', url })
.expect(201);
expect(ExternalLink).toHaveBeenLastCalledWith(
expect.objectContaining({ type: expected }),
);
});

it.each([
// eslint-disable-next-line no-script-url -- intentional: the safe-URL guard must reject this
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
'not a url at all',
])('rejects unsafe URL %s with 400', async (url) => {
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'user1' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: jest.fn(),
});
await request(app)
.post('/api/pods/external-link')
.send({ podId: 'p1', type: 'other_link', url })
.expect(400);
});

it('detects github PR vs issue vs repo by path', async () => {
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'user1' },
members: [{ toString: () => 'user1' }],
externalLinks: [],
save: jest.fn(),
});
const expectGithubKind = async (url, expected) => {
ExternalLink.mockImplementation((data) => ({
...data,
_id: 'l',
save: jest.fn(),
}));
await request(app)
.post('/api/pods/external-link')
.send({ podId: 'p1', type: 'auto', url })
.expect(201);
expect(ExternalLink).toHaveBeenLastCalledWith(
expect.objectContaining({ type: expected }),
);
};
await expectGithubKind('https://github.com/Team-Commonly/commonly/pull/261', 'github_pr');
await expectGithubKind('https://github.com/Team-Commonly/commonly/issues/45', 'github_issue');
await expectGithubKind('https://github.com/Team-Commonly/commonly', 'github_repo');
});

it('returns 400 when required fields missing', async () => {
await request(app).post('/api/pods/external-link').send({}).expect(400);
});
Expand All @@ -78,20 +195,23 @@ describe('pods routes - external links', () => {
podId: 'p1',
name: 'n',
type: 'discord',
url: 'u',
url: 'https://discord.gg/x',
})
.expect(404);
});

it('returns 403 when user not owner', async () => {
Pod.findById.mockResolvedValue({ createdBy: { toString: () => 'other' } });
it('returns 403 when user is neither owner nor member', async () => {
Pod.findById.mockResolvedValue({
createdBy: { toString: () => 'other' },
members: [{ toString: () => 'someone-else' }],
});
await request(app)
.post('/api/pods/external-link')
.send({
podId: 'p1',
name: 'n',
type: 'discord',
url: 'u',
url: 'https://discord.gg/x',
})
.expect(403);
});
Expand Down
29 changes: 26 additions & 3 deletions backend/models/ExternalLink.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import mongoose, { Document, Schema, Types } from 'mongoose';

export type ExternalLinkType = 'discord' | 'telegram' | 'wechat' | 'groupme' | 'other';
// Doc/link types added 2026-05-01: ExternalLink doubles as the pod "Artifacts"
// store in v2 — Notion docs, Google Suite, Figma, Zoom, GitHub URLs, etc.
// Original chat-bridge types (discord/telegram/wechat/groupme/other) are kept
// for backward compat with QR-code-based community joins.
export type ExternalLinkType =
| 'discord' | 'telegram' | 'wechat' | 'groupme' | 'other'
| 'notion'
| 'google_doc' | 'google_sheet' | 'google_slides' | 'google_drive'
| 'figma'
| 'zoom'
| 'gmail'
| 'github_pr' | 'github_issue' | 'github_repo'
| 'youtube' | 'loom'
| 'other_link';

const EXTERNAL_LINK_TYPES: ExternalLinkType[] = [
'discord', 'telegram', 'wechat', 'groupme', 'other',
'notion',
'google_doc', 'google_sheet', 'google_slides', 'google_drive',
'figma', 'zoom', 'gmail',
'github_pr', 'github_issue', 'github_repo',
'youtube', 'loom',
'other_link',
];

export interface IExternalLink extends Document {
podId: Types.ObjectId;
Expand All @@ -20,8 +43,8 @@ const ExternalLinkSchema = new Schema<IExternalLink>(
type: {
type: String,
required: true,
enum: ['discord', 'telegram', 'wechat', 'groupme', 'other'],
default: 'other',
enum: EXTERNAL_LINK_TYPES,
default: 'other_link',
},
url: { type: String, trim: true },
qrCodePath: { type: String },
Expand Down
86 changes: 81 additions & 5 deletions backend/routes/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,90 @@ router.delete('/announcement/:id', auth, async (req: AuthReq, res: Res) => {
}
});

// Reject anything that isn't a real http(s) URL — guards against `javascript:`
// or `data:` schemes ending up in an <a href> in the inspector. WeChat QR-code
// links are exempt because their primary surface is qrCodePath, not href.
const isSafeHttpUrl = (rawUrl: string): boolean => {
try {
const u = new URL(rawUrl);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
};

// URL → ExternalLinkType. Used when the client passes type='auto' (or omits
// type with a URL present) so the v2 inspector "+ Add" flow is paste-and-go.
// Match the most specific host first; everything unknown falls back to
// 'other_link'. Keep this in sync with the enum in models/ExternalLink.ts.
const detectLinkType = (rawUrl: string): string => {
if (!rawUrl) return 'other_link';
let host = '';
let pathname = '';
Comment on lines +99 to +118
try {
const u = new URL(rawUrl);
host = u.hostname.toLowerCase();
pathname = u.pathname.toLowerCase();
} catch {
return 'other_link';
}
if (host === 'notion.so' || host.endsWith('.notion.so') || host.endsWith('.notion.site')) return 'notion';
if (host === 'docs.google.com') {
if (pathname.includes('/document/')) return 'google_doc';
if (pathname.includes('/spreadsheets/')) return 'google_sheet';
if (pathname.includes('/presentation/')) return 'google_slides';
return 'google_doc';
}
if (host === 'sheets.google.com') return 'google_sheet';
if (host === 'slides.google.com') return 'google_slides';
if (host === 'drive.google.com') return 'google_drive';
if (host === 'figma.com' || host.endsWith('.figma.com')) return 'figma';
if (host === 'zoom.us' || host.endsWith('.zoom.us')) return 'zoom';
if (host === 'mail.google.com') return 'gmail';
if (host === 'github.com' || host.endsWith('.github.com')) {
if (/\/pull\/\d+/.test(pathname)) return 'github_pr';
if (/\/issues\/\d+/.test(pathname)) return 'github_issue';
return 'github_repo';
}
if (host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be') return 'youtube';
if (host === 'loom.com' || host.endsWith('.loom.com')) return 'loom';
if (host.includes('discord.com') || host.includes('discord.gg')) return 'discord';
if (host === 't.me' || host.endsWith('.telegram.org')) return 'telegram';
if (host.includes('groupme.com')) return 'groupme';
return 'other_link';
};

const deriveLinkName = (rawUrl: string): string => {
try {
const u = new URL(rawUrl);
const tail = u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).pop();
return tail ? `${u.hostname}${u.pathname.length > 1 ? ` · ${decodeURIComponent(tail).slice(0, 60)}` : ''}` : u.hostname;
} catch {
return rawUrl.slice(0, 80);
}
};

router.post('/external-link', auth, upload.single('qrCode'), async (req: AuthReq, res: Res) => {
try {
const { podId, name, type, url } = (req.body || {}) as { podId?: string; name?: string; type?: string; url?: string };
if (!podId || !name || !type) return res.status(400).json({ message: 'Missing required fields' });
const pod = await Pod.findById(podId) as { createdBy?: { toString: () => string }; externalLinks?: unknown[]; save: () => Promise<void> } | null;
const { podId, name: rawName, type: rawType, url } = (req.body || {}) as { podId?: string; name?: string; type?: string; url?: string };
if (!podId) return res.status(400).json({ message: 'Missing podId' });
// Auto-detect type when client passes 'auto' or no type with a URL — lets
// the v2 inspector add-link flow work as a single paste field.
const type = (!rawType || rawType === 'auto') && url ? detectLinkType(url) : rawType;
if (!type) return res.status(400).json({ message: 'Missing type' });
// Block javascript:/data: URLs before they reach the DB or the inspector
// <a href> render path. WeChat is the only type that can ship without a
// url (it carries qrCodePath instead).
if (url && !isSafeHttpUrl(url)) return res.status(400).json({ message: 'URL must be http or https' });
const name = (rawName && rawName.trim()) || (url ? deriveLinkName(url) : '');
if (!name) return res.status(400).json({ message: 'Missing name' });
const pod = await Pod.findById(podId) as { createdBy?: { toString: () => string }; members?: Array<{ toString: () => string }>; externalLinks?: unknown[]; save: () => Promise<void> } | null;
if (!pod) return res.status(404).json({ message: 'Pod not found' });
if (pod.createdBy?.toString() !== req.user?.id) return res.status(403).json({ message: 'Only pod owner can add external links' });
const externalLink = new ExternalLink({ podId, name, type, createdBy: req.user?.id });
const userId = req.user?.id;
const isOwner = pod.createdBy?.toString() === userId;
const isMember = pod.members?.some((m) => m.toString() === userId);
if (!isOwner && !isMember) return res.status(403).json({ message: 'Only pod members can add links' });
const externalLink = new ExternalLink({ podId, name, type, createdBy: userId });
if (type === 'wechat' && req.file) externalLink.qrCodePath = req.file.path;
else if (url) externalLink.url = url;
else return res.status(400).json({ message: 'URL or QR code is required' });
Expand Down
Loading
Loading