Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-03-27 13:33:04 +07:00
14 changed files with 547 additions and 160 deletions
@@ -22,10 +22,20 @@
let popup: HTMLDivElement | undefined
function isClickInsidePopup (target: Node): boolean {
if (popup !== undefined && popup.contains(target)) return true
if (!(target instanceof Element)) return false
if (target.closest('.tippy-box') !== null) return true
if (target.closest('[data-block-editor-blur="true"]') !== null) return true
return false
}
function handleClick (event: MouseEvent): void {
if (event.target instanceof Node) {
const top = $popups.length > 0 && $popups[$popups.length - 1].id === popupId
if (top && popup !== undefined && !popup.contains(event.target)) {
if (top && !isClickInsidePopup(event.target)) {
event.preventDefault()
event.stopPropagation()
dispatch('close', undefined)
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl, escapeTableCell } from '../markdown/escape'
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from '../markdown/escape'
describe('markdown/escape', () => {
describe('escapeMarkdownLinkText', () => {
@@ -56,30 +56,4 @@ describe('markdown/escape', () => {
expect(escapeMarkdownLinkUrl('https://example.com')).toBe('https://example.com')
})
})
describe('escapeTableCellPreservingLink', () => {
it('escapes plain text pipe for table safety', () => {
expect(escapeTableCell('a|b')).toBe('a\\|b')
})
it('preserves markdown link and escapes pipes inside text and URL', () => {
const input = '[a|b](http://example.com/x|y)'
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y)')
})
it('escapes pipes even when value contains escaped characters', () => {
const input = '[a|b](http://example.com/x|y\\z)'
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y\\\\z)')
})
it('treats strings that do not end with `)` as plain text', () => {
const input = '[a|b](http://example.com/x|y'
expect(escapeTableCell(input)).toBe('\\[a\\|b\\](http://example.com/x\\|y')
})
it('returns empty string for null/undefined', () => {
expect(escapeTableCell(null)).toBe('')
expect(escapeTableCell(undefined)).toBe('')
})
})
})
@@ -37,23 +37,3 @@ export function escapeMarkdownLinkUrl (url: string): string {
.replace(/\|/g, '\\|')
)
}
/**
* Escape a markdown table cell while preserving `[text](url)` links.
*/
export function escapeTableCell (value: unknown): string {
const s = value == null ? '' : String(value)
const sep = s.indexOf('](')
const looksLikeMarkdownLink = s.startsWith('[') && sep !== -1 && s.endsWith(')')
if (!looksLikeMarkdownLink) {
return escapeMarkdownLinkText(s)
}
const rawText = s.slice(1, sep)
const rawUrl = s.slice(sep + 2, -1)
const escapedText = escapeMarkdownLinkText(rawText)
const escapedUrl = escapeMarkdownLinkUrl(rawUrl)
return `[${escapedText}](${escapedUrl})`
}
@@ -28,7 +28,7 @@ import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } f
import { formatValue } from '../formatter'
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
import { escapeTableCell } from './escape'
import { escapeMarkdownLinkText } from './escape'
import { createMarkdownLink } from './link'
async function preloadRefLookups (
@@ -253,7 +253,7 @@ export async function buildMarkdownTableFromDocs (
const linkValue = await createMarkdownLink(hierarchy, card, value)
row.push(linkValue)
} else {
row.push(escapeTableCell(value))
row.push(escapeMarkdownLinkText(value == null ? '' : String(value)))
}
}
rows.push(row)
@@ -375,7 +375,7 @@ export async function buildRelationshipTableMarkdown (
if (isDocumentTitle) {
value = await createMarkdownLink(hierarchy, docToUse, value)
} else {
value = escapeTableCell(value)
value = escapeMarkdownLinkText(value == null ? '' : String(value))
}
row[attrIndex] = value
@@ -36,6 +36,7 @@ function PasteTextAsMarkdownPlugin (): Plugin {
if (clipboardData === null) return false
const pastedText = clipboardData.getData('text/plain')
const pastedMarkdown = clipboardData.getData('text/markdown')
// check if we are in code block
const { $from } = view.state.selection
@@ -47,12 +48,21 @@ function PasteTextAsMarkdownPlugin (): Plugin {
}
}
// If the clipboard explicitly provides markdown, prefer it even if other types (e.g. html) exist.
const hasMarkdown = pastedMarkdown.trim().length > 0
const markdownSource = hasMarkdown ? pastedMarkdown : pastedText
// Table copies include metadata comment; treat as markdown even when clipboard has rich types.
const hasTableMetadata =
pastedText.includes('<!-- huly-table-metadata:') || pastedMarkdown.includes('<!-- huly-table-metadata:')
const isPlainPaste = clipboardData.types.length === 1 && clipboardData.types[0] === 'text/plain'
if (!isPlainPaste) return false
// Keep default paste behavior for rich clipboard formats, unless markdown is explicitly present.
if (!hasMarkdown && !hasTableMetadata && !isPlainPaste) return false
try {
const markupNode = cleanUnknownContent(view.state.schema, markdownToMarkup(pastedText))
const markupNode = cleanUnknownContent(view.state.schema, markdownToMarkup(markdownSource))
if (shouldUseMarkdownOutput(markupNode)) {
const content = Node.fromJSON(view.state.schema, markupNode)
content.check()
@@ -0,0 +1,35 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { normalizeEscapedMarkdownLinks } from './tablePaste'
describe('normalizeEscapedMarkdownLinks', () => {
it('normalizes partially escaped markdown links', () => {
const input = '| \\[Label\\](https://example.com/a\\|b) |'
const output = normalizeEscapedMarkdownLinks(input)
expect(output).toBe('| [Label](https://example.com/a|b) |')
})
it('normalizes fully escaped markdown links', () => {
const input = '| \\[Label\\]\\(https://example.com/a\\|b\\) |'
const output = normalizeEscapedMarkdownLinks(input)
expect(output).toBe('| [Label](https://example.com/a|b) |')
})
it('keeps non-link text unchanged', () => {
const input = '| plain \\| text |'
const output = normalizeEscapedMarkdownLinks(input)
expect(output).toBe(input)
})
})
@@ -48,6 +48,26 @@ function extractMetadataFromHtmlComments (text: string): { metadata: TableMetada
return { metadata: null, cleanedText: text }
}
export function normalizeEscapedMarkdownLinks (markdown: string): string {
// Some producers escape markdown links in table cells:
// 1) partially escaped: `\\[text\\](url)`
// 2) fully escaped: `\\[text\\]\\(url\\)`
// Normalize both back to `[text](url)` so markdown parser restores link marks.
let result = markdown.replace(/\\\[((?:\\.|[^\\\]])*?)\\\]\(([^)\r\n]+)\)/g, (_m, text, url) => {
const unescapedText = String(text).replace(/\\([\\[\]|])/g, '$1')
const unescapedUrl = String(url).replace(/\\([\\|)])/g, '$1')
return `[${unescapedText}](${unescapedUrl})`
})
result = result.replace(/\\\[((?:\\.|[^\\\]])*?)\\\]\\\(([^)\r\n]+?)\\\)/g, (_m, text, url) => {
const unescapedText = String(text).replace(/\\([\\[\]|])/g, '$1')
const unescapedUrl = String(url).replace(/\\([\\|)])/g, '$1')
return `[${unescapedText}](${unescapedUrl})`
})
return result
}
function TableMetadataPastePlugin (): Plugin {
return new Plugin({
props: {
@@ -128,6 +148,8 @@ function TableMetadataPastePlugin (): Plugin {
return false
}
markdown = normalizeEscapedMarkdownLinks(markdown)
// Check if we're in a code block (don't process tables there)
const { $from } = view.state.selection
for (let d = $from.depth; d > 0; d--) {
+155 -1
View File
@@ -89,6 +89,18 @@ describe('URL Validation', () => {
it('should reject invalid URLs', async () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'not-a-valid-url')).rejects.toThrow(LinkPreviewError)
})
it('should set INVALID_URL code for malformed URLs', async () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'not-a-valid-url')).rejects.toMatchObject({
code: 'INVALID_URL'
})
})
it('should set INVALID_PROTOCOL code for non-http protocols', async () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'javascript:alert(1)')).rejects.toMatchObject({
code: 'INVALID_PROTOCOL'
})
})
})
describe('SSRF protection', () => {
@@ -103,7 +115,13 @@ describe('URL Validation', () => {
'192.168.255.255',
'169.254.0.1',
'0.0.0.0',
'localhost'
'localhost',
'localhost.',
'[::1]',
// IPv6-mapped IPv4 loopback (hex form used in the report)
'[::ffff:7f00:1]',
// IPv6-mapped IPv4 loopback (dotted form)
'[::ffff:127.0.0.1]'
]
it.each(blockedAddresses)('should block access to %s', async (host) => {
@@ -120,6 +138,24 @@ describe('URL Validation', () => {
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://8.8.8.8')
expect(result).toBeDefined()
})
it('should block unique-local and link-local IPv6 addresses', async () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fc00::1]/path')).rejects.toThrow(
LinkPreviewError
)
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fd12::abcd]/path')).rejects.toThrow(
LinkPreviewError
)
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://[fe80::1]/path')).rejects.toThrow(
LinkPreviewError
)
})
it('should set BLOCKED_URL code for blocked hosts', async () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://localhost./path')).rejects.toMatchObject({
code: 'BLOCKED_URL'
})
})
})
})
@@ -469,6 +505,50 @@ describe('oEmbed Integration', () => {
expect(result.imageWidth).toBe(1920)
expect(result.imageHeight).toBe(1080)
})
it('should block SSRF via oEmbed discovery URL (internal destination)', async () => {
const html = `
<html>
<head>
<link type="application/json+oembed" href="http://[::ffff:7f00:1]:7777/oembed-ssrf">
<meta property="og:title" content="OG Title">
</head>
</html>
`
global.fetch = jest.fn().mockImplementation((url: string, init?: RequestInit) => {
// Implementation should not rely on automatic redirect following.
if (init?.redirect === 'follow') return Promise.reject(new Error('redirect: follow should not be used'))
if (url === 'https://example.com/attacker') return Promise.resolve(createHtmlResponse(html))
// oEmbed fetch must not happen (blocked by URL validation).
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
})
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/attacker')).rejects.toThrow(
LinkPreviewError
)
})
it('should fall back to OG when oEmbed request throws network error', async () => {
const html = `
<html>
<head>
<link type="application/json+oembed" href="https://example.com/oembed">
<meta property="og:title" content="OG Fallback Title">
</head>
</html>
`
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://example.com/oembed') return Promise.reject(new Error('oEmbed network down'))
if (url === 'https://example.com/page') return Promise.resolve(createHtmlResponse(html))
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
})
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/page')
expect(result.title).toBe('OG Fallback Title')
})
})
// ============================================================================
@@ -568,6 +648,14 @@ describe('Error Handling', () => {
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com')).rejects.toThrow(LinkPreviewError)
})
it('should set FETCH_FAILED code on network errors', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'))
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com')).rejects.toMatchObject({
code: 'FETCH_FAILED'
})
})
it('should handle malformed HTML gracefully', async () => {
const html = '<html><head><title>Broken<<<<</title></head></html>'
global.fetch = jest.fn().mockResolvedValue(createHtmlResponse(html))
@@ -726,6 +814,72 @@ describe('Edge Cases', () => {
expect(result.title).toBe('Redirected')
})
it('should block SSRF via redirects to internal addresses', async () => {
global.fetch = jest.fn().mockImplementation((url: string, init?: RequestInit) => {
if (init?.redirect === 'follow') return Promise.reject(new Error('redirect: follow should not be used'))
if (url === 'https://example.com/start') {
return Promise.resolve(
new Response(null, {
status: 302,
headers: { location: 'http://[::ffff:7f00:1]:9999/' }
})
)
}
// Redirect target should never be fetched if validation is correct.
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
})
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
LinkPreviewError
)
})
it('should keep current URL when redirect response has no location', async () => {
const html = '<html><head><title>No Location Redirect</title></head></html>'
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://example.com/start') return Promise.resolve(new Response(null, { status: 302 }))
return Promise.resolve(createHtmlResponse(html))
})
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
LinkPreviewError
)
})
it('should resolve relative redirect locations', async () => {
const html = '<html><head><title>Relative Redirect</title></head></html>'
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url === 'https://example.com/start') {
return Promise.resolve(
new Response(null, {
status: 302,
headers: { location: '/next' }
})
)
}
if (url === 'https://example.com/next') return Promise.resolve(createHtmlResponse(html))
return Promise.reject(new Error(`Unexpected fetch to ${url}`))
})
const result = await parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')
expect(result.title).toBe('Relative Redirect')
})
it('should fail after too many redirects', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: '/loop' }
})
)
await expect(parseLinkPreviewDetails(ctx, defaultConfig, 'https://example.com/start')).rejects.toThrow(
'Too many redirects'
)
})
it('should sanitize HTML entities in titles', async () => {
const html = '<html><head><title>Rock &amp; Roll</title></head></html>'
global.fetch = jest.fn().mockResolvedValue(createHtmlResponse(html))
+175 -38
View File
@@ -17,6 +17,7 @@ import { MeasureContext } from '@hcengineering/core'
import * as cheerio from 'cheerio'
import { imageSize } from 'image-size'
import oembedProviders from 'oembed-providers'
import net from 'node:net'
// ============================================================================
// Types and Interfaces
@@ -89,20 +90,6 @@ const DEFAULT_TIMEOUT_MS = 10_000
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB
const OEMBED_SERVICE_NAME = 'Huly Link Preview Service/1.0'
// Private IP ranges to block for SSRF protection
const BLOCKED_IP_PATTERNS = [
/^127\./, // Loopback
/^10\./, // Private Class A
/^172\.(1[6-9]|2\d|3[01])\./, // Private Class B
/^192\.168\./, // Private Class C
/^169\.254\./, // Link-local
/^0\./, // Current network
/^localhost$/i,
/^::1$/, // IPv6 loopback
/^fc00:/i, // IPv6 private
/^fe80:/i // IPv6 link-local
]
// ============================================================================
// Error Classes
// ============================================================================
@@ -110,6 +97,7 @@ const BLOCKED_IP_PATTERNS = [
export class LinkPreviewError extends Error {
constructor (
message: string,
public readonly code?: 'BLOCKED_URL' | 'INVALID_URL' | 'INVALID_PROTOCOL' | 'TIMEOUT' | 'FETCH_FAILED',
public readonly cause?: unknown
) {
super(message)
@@ -121,30 +109,165 @@ export class LinkPreviewError extends Error {
// URL Validation
// ============================================================================
function normalizeHostnameForChecks (hostname: string): string {
// URL.hostname is already punycode-normalized by WHATWG URL for IDNs.
// Keep it lowercase for comparisons.
const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, '')
if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1)
return trimmed
}
function parseIpv6MappedIpv4 (ipv6: string): string | undefined {
const host = ipv6.toLowerCase()
// Common form: ::ffff:127.0.0.1
const dotted = host.match(/(?:^|:)ffff:(\d{1,3}(?:\.\d{1,3}){3})$/)
if (dotted?.[1] !== undefined) return dotted[1]
// Hex form used in the report: ::ffff:7f00:1 (=> 127.0.0.1)
const hex = host.match(/(?:^|:)ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
if (hex?.[1] === undefined || hex?.[2] === undefined) return undefined
const hi = Number.parseInt(hex[1], 16)
const lo = Number.parseInt(hex[2], 16)
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return undefined
const a = (hi >> 8) & 0xff
const b = hi & 0xff
const c = (lo >> 8) & 0xff
const d = lo & 0xff
return `${a}.${b}.${c}.${d}`
}
function isBlockedIpv4 (ipv4: string): boolean {
const parts = ipv4.split('.').map((p) => Number.parseInt(p, 10))
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return true
const [a, b] = parts
// 0.0.0.0/8
if (a === 0) return true
// 127.0.0.0/8 loopback
if (a === 127) return true
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 link-local
if (a === 169 && b === 254) return true
return false
}
function isBlockedIpv6 (ipv6: string): boolean {
const host = ipv6.toLowerCase()
// unspecified / loopback (compressed or expanded)
if (host === '::' || host === '0:0:0:0:0:0:0:0') return true
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7 (coarse but safe)
if (
host.startsWith('fe80:') ||
host.startsWith('fe8') ||
host.startsWith('fe9') ||
host.startsWith('fea') ||
host.startsWith('feb')
) {
// link-local fe80::/10 (coarse but safe)
return true
}
const mapped = parseIpv6MappedIpv4(host)
if (mapped !== undefined) return isBlockedIpv4(mapped)
return false
}
function isBlockedHost (hostname: string): boolean {
const host = normalizeHostnameForChecks(hostname)
if (host === 'localhost') return true
const ipType = net.isIP(host)
if (ipType === 4) return isBlockedIpv4(host)
if (ipType === 6) return isBlockedIpv6(host)
// Some Node versions are stricter about IPv6 parsing. If it still looks like an IPv6 literal,
// apply our IPv6 checks anyway (covers IPv6-mapped IPv4 forms like ::ffff:7f00:1).
if (host.includes(':') && isBlockedIpv6(host)) return true
// Hostname is not an IP literal. Keep legacy explicit localhost-ish blocks.
// (We intentionally do not attempt DNS resolution here.)
if (host.endsWith('.localhost')) return true
return false
}
function validateUrl (urlString: string): URL {
let url: URL
try {
url = new URL(urlString)
} catch {
throw new LinkPreviewError(`Invalid URL: ${urlString}`)
throw new LinkPreviewError(`Invalid URL: ${urlString}`, 'INVALID_URL')
}
// Only allow HTTP(S) protocols
if (!['http:', 'https:'].includes(url.protocol)) {
throw new LinkPreviewError(`Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`)
throw new LinkPreviewError(
`Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`,
'INVALID_PROTOCOL'
)
}
// SSRF protection: block private/internal IPs
const hostname = url.hostname
for (const pattern of BLOCKED_IP_PATTERNS) {
if (pattern.test(hostname)) {
throw new LinkPreviewError('Blocked URL: Access to internal addresses is not allowed.')
}
// SSRF protection: block private/internal hosts and IP literals (incl. IPv6-mapped IPv4)
if (isBlockedHost(url.hostname)) {
throw new LinkPreviewError('Blocked URL: Access to internal addresses is not allowed.', 'BLOCKED_URL')
}
return url
}
function isRedirectStatus (status: number): boolean {
return status >= 300 && status < 400
}
async function fetchWithValidatedRedirects (
url: string,
options: RequestInit,
timeoutMs: number,
maxRedirects: number = 5
): Promise<{ response: Response, finalUrl: string }> {
let currentUrl = url
for (let i = 0; i <= maxRedirects; i++) {
// Validate every hop (including the initial request URL).
validateUrl(currentUrl)
const response = await fetchWithTimeout(
currentUrl,
{
...options,
redirect: 'manual'
},
timeoutMs
)
if (!isRedirectStatus(response.status)) {
return { response, finalUrl: currentUrl }
}
const location = response.headers.get('location')
if (!isNonEmptyString(location)) {
return { response, finalUrl: currentUrl }
}
const nextUrl = new URL(location, currentUrl).href
currentUrl = nextUrl
}
throw new LinkPreviewError('Too many redirects')
}
// ============================================================================
// Fetch Utilities
// ============================================================================
@@ -163,10 +286,11 @@ async function fetchWithTimeout (url: string, options: RequestInit, timeoutMs: n
return response
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new LinkPreviewError(`Request timed out after ${timeoutMs}ms`, error)
throw new LinkPreviewError(`Request timed out after ${timeoutMs}ms`, 'TIMEOUT', error)
}
throw new LinkPreviewError(
`Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}`,
'FETCH_FAILED',
error
)
} finally {
@@ -230,9 +354,15 @@ async function fetchOEmbedData (
if (oembedUrl === null) return null
// Validate discovered/provider oEmbed URL to prevent SSRF.
validateUrl(oembedUrl)
ctx.info('fetching oEmbed data', { oembedUrl })
const response = await fetchWithTimeout(oembedUrl, { headers: { 'User-Agent': OEMBED_SERVICE_NAME } }, timeoutMs)
const { response } = await fetchWithValidatedRedirects(
oembedUrl,
{ headers: { 'User-Agent': OEMBED_SERVICE_NAME } },
timeoutMs
)
if (!response.ok) {
ctx.warn('oEmbed fetch failed', {
@@ -259,12 +389,16 @@ async function fetchOEmbedData (
ctx.info('successfully fetched oEmbed data', { type: data.type })
return data
} catch (error) {
// Don't throw - oEmbed failure should fall back to OG parsing
// Security-related URL validation errors must still fail fast.
if (error instanceof LinkPreviewError && error.code === 'BLOCKED_URL') {
throw error
}
// Other oEmbed failures should fall back to OG parsing.
ctx.warn('failed to fetch oEmbed data', {
error: error instanceof Error ? error.message : String(error)
})
// return null
throw error
return null
}
}
@@ -424,7 +558,11 @@ async function loadImageSize (ctx: MeasureContext, url: string, config: Config):
// Validate the image URL too
validateUrl(url)
const response = await fetchWithTimeout(url, { headers: { 'User-Agent': config.UserAgent } }, timeoutMs)
const { response } = await fetchWithValidatedRedirects(
url,
{ headers: { 'User-Agent': config.UserAgent } },
timeoutMs
)
if (!response.ok) {
ctx.warn('failed to fetch image', { status: response.status, url })
@@ -598,10 +736,9 @@ export async function parseLinkPreviewDetails (
const parsedUrl = validateUrl(query)
// Fetch the page
const response = await fetchWithTimeout(
const { response, finalUrl } = await fetchWithValidatedRedirects(
query,
{
redirect: 'follow',
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': config.UserAgent
@@ -617,10 +754,10 @@ export async function parseLinkPreviewDetails (
// Check if response is an image
const contentType = response.headers.get('content-type') ?? ''
if (contentType.startsWith('image/')) {
const size = await loadImageSize(ctx, query, config)
const size = await loadImageSize(ctx, finalUrl, config)
return {
url: query,
image: query,
url: finalUrl,
image: finalUrl,
host: `${parsedUrl.protocol}//${parsedUrl.host}`,
hostname: parsedUrl.hostname,
imageWidth: size?.width,
@@ -639,17 +776,17 @@ export async function parseLinkPreviewDetails (
const host = `${parsedUrl.protocol}//${parsedUrl.host}`
// Try oEmbed first
const oembedData = await fetchOEmbedData(ctx, $, query, timeoutMs)
const oembedData = await fetchOEmbedData(ctx, $, finalUrl, timeoutMs)
if (oembedData !== null) {
const ogSiteName = $('meta[property="og:site_name"]').attr('content')
const hostname = isNonEmptyString(ogSiteName) ? ogSiteName : parsedUrl.hostname
ctx.info('using oEmbed data', { url: query })
return convertOEmbedToPreview(oembedData, query, hostname, host)
ctx.info('using oEmbed data', { url: finalUrl })
return convertOEmbedToPreview(oembedData, finalUrl, hostname, host)
}
// Fall back to Open Graph / meta tag parsing
ctx.info('using Open Graph data', { url: query })
const preview = parseOpenGraphData($, config, parsedUrl, query)
ctx.info('using Open Graph data', { url: finalUrl })
const preview = parseOpenGraphData($, config, parsedUrl, finalUrl)
// Get image dimensions if we have an image but no dimensions
let imageWidth: number | undefined
+62 -50
View File
@@ -30,25 +30,53 @@ import core, {
TxUpdateDoc
} from '@hcengineering/core'
import process, {
ApproveRequest,
ContextId,
Execution,
ExecutionStatus,
isUpdateTx,
Method,
parseContext,
Process,
ProcessContext,
ProcessCustomEvent,
ProcessToDo,
SelectedExecutionContext,
State,
Step,
Transition,
isUpdateTx,
ProcessCustomEvent,
ApproveRequest,
ExecutionStatus,
Trigger
} from '@hcengineering/process'
import { QueueTopic, TriggerControl } from '@hcengineering/server-core'
import { ProcessMessage } from '@hcengineering/server-process'
import {
AddRelation,
AddTag,
ApproveRequestApproved,
ApproveRequestRejected,
CancelSubProcess,
CancelToDo,
CheckSubProcessesDone,
CheckSubProcessMatch,
CheckTime,
CheckToDoCancelled,
CheckToDoDone,
CreateCard,
CreateToDo,
EventCheck,
FieldChangedCheck,
LockCard,
LockField,
LockSection,
MatchCardCheck,
RequestApproval,
RunSubProcess,
UnlockCard,
UnlockField,
UnlockSection,
UpdateCard
} from './functions'
import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './rollback'
import {
Absolute,
Add,
@@ -58,7 +86,16 @@ import {
CurrentDate,
CurrentUser,
Cut,
DateDifference,
DateFromNumber,
DateFromString,
DayFromDate,
Divide,
EmptyArray,
ExecutionInitiator,
ExecutionStarted,
Filter,
FirstMatchValue,
FirstValue,
FirstWorkingDayAfter,
Floor,
@@ -66,10 +103,12 @@ import {
LastValue,
LowerCase,
Modulo,
MonthFromDate,
Multiply,
NumberFromDate,
NumberFromString,
Offset,
Power,
Sqrt,
Prepend,
Random,
Remove,
@@ -80,54 +119,15 @@ import {
RoleContext,
Round,
Split,
Sqrt,
StringFromBoolean,
StringFromDate,
StringFromNumber,
Subtract,
Trim,
UpperCase,
EmptyArray,
ExecutionInitiator,
ExecutionStarted,
FirstMatchValue,
Filter,
StringFromNumber,
StringFromDate,
StringFromBoolean,
NumberFromDate,
DateFromNumber,
NumberFromString,
DateFromString,
YearFromDate,
MonthFromDate,
DayFromDate,
DateDifference
YearFromDate
} from './transform'
import {
RunSubProcess,
CancelSubProcess,
CreateToDo,
UpdateCard,
CreateCard,
AddRelation,
AddTag,
CheckToDoDone,
CheckToDoCancelled,
MatchCardCheck,
CheckSubProcessesDone,
CheckSubProcessMatch,
CheckTime,
FieldChangedCheck,
EventCheck,
RequestApproval,
ApproveRequestApproved,
ApproveRequestRejected,
CancelToDo,
LockCard,
LockSection,
UnlockCard,
UnlockSection,
LockField,
UnlockField
} from './functions'
import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './rollback'
async function putEventToQueue (value: Omit<ProcessMessage, 'account'>, control: TriggerControl): Promise<void> {
if (control.queue === undefined) return
@@ -374,6 +374,7 @@ async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: Tr
doneOn: null,
field: { $ne: null }
} as any)
const cache = new Map<Ref<Execution>, Execution>()
const handledGroups = new Set<string>()
for (const todo of todos as any[]) {
if (todo.field === undefined || !TxProcessor.hasUpdate(ops, todo.field)) continue
@@ -383,7 +384,18 @@ async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: Tr
if (handledGroups.has(request.group)) continue
handledGroups.add(request.group)
const newUsers = (card[todo.field as keyof Card] as any[]) ?? []
const execution =
cache.get(todo.execution) ??
(await control.findAll(control.ctx, process.class.Execution, { _id: todo.execution }, { limit: 1 }))[0]
if (execution === undefined) continue
cache.set(todo.execution, execution)
const _process = control.modelDb.findObject(execution.process)
if (_process === undefined) continue
const h = control.hierarchy
const target = h.isMixin(_process.masterTag) ? h.asIf(card, _process.masterTag) : card
if (target === undefined) continue
const newUsers = (target[todo.field as keyof Card] as any[]) ?? []
if (newUsers.length === 0) {
continue
}
+3 -1
View File
@@ -173,7 +173,9 @@ export const main = async (): Promise<void> => {
res.status(400).send({ err: "'event' or 'workspace' or 'type' is missing" })
return
}
void OutcomingClient.push(ctx, accountClient, workspace, event, type)
void OutcomingClient.push(ctx, accountClient, workspace, event, type).catch((err: any) => {
ctx.error('Outcoming sync failed', { eventId: event.eventId, workspace, type, error: err.message })
})
res.send()
}
}
@@ -153,10 +153,20 @@ export class OutcomingClient {
const calendarId = calendar.externalId
if (calendarId !== undefined) {
await this.rateLimiter.take(1)
await this.calendar.events.insert({
calendarId,
requestBody: body
})
try {
await this.calendar.events.insert({
calendarId,
requestBody: body
})
} catch (err: any) {
this.ctx.error('Google API insert error', {
calendarId,
eventId: event.eventId,
error: err.message,
code: err.code
})
throw err
}
}
}
@@ -380,17 +390,33 @@ export class OutcomingClient {
}
private async remove (eventId: string, calendarId: string): Promise<void> {
const current = await this.calendar.events.get({ calendarId, eventId })
if (current?.data !== undefined) {
if (current.data.organizer?.self === true) {
await this.rateLimiter.take(1)
try {
await this.calendar.events.delete({
eventId,
calendarId
})
} catch {}
try {
const current = await this.calendar.events.get({ calendarId, eventId })
if (current?.data !== undefined) {
if (current.data.organizer?.self === true) {
await this.rateLimiter.take(1)
try {
await this.calendar.events.delete({
eventId,
calendarId
})
} catch (err: any) {
this.ctx.error('Google API delete error', {
calendarId,
eventId,
error: err.message,
code: err.code
})
}
}
}
} catch (err: any) {
this.ctx.error('Failed to get event for deletion', {
calendarId,
eventId,
error: err.message,
code: err.code
})
}
}
@@ -24,7 +24,15 @@ import {
githubExternalSyncVersion,
githubSyncVersion
} from '../types'
import { collectUpdate, deleteObjects, ensureGraphQLOctokit, errorToObj, getSince, isGHWriteAllowed } from './utils'
import {
collectUpdate,
deleteObjects,
ensureGraphQLOctokit,
ensureRESTOctokit,
errorToObj,
getSince,
isGHWriteAllowed
} from './utils'
import { Analytics } from '@hcengineering/analytics'
import { IssueComment, IssueCommentCreatedEvent, IssueCommentEvent } from '@octokit/webhooks-types'
@@ -353,7 +361,10 @@ export class CommentSyncManager implements DocSyncManager {
if (Object.keys(platformUpdate).length > 0) {
// Check and update body with external
const okit = (await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit
const okit = ensureRESTOctokit(
(await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit,
container
)
const mdown = await this.provider.getMarkdown(existingComment.message)
if (mdown.trim().length > 0) {
await okit.rest.issues.updateComment({
@@ -426,7 +437,10 @@ export class CommentSyncManager implements DocSyncManager {
return {}
}
const chatMessage = existing as ChatMessage
const okit = (await this.provider.getOctokit(ctx, chatMessage.modifiedBy)) ?? container.container.octokit
const okit = ensureRESTOctokit(
(await this.provider.getOctokit(ctx, chatMessage.modifiedBy)) ?? container.container.octokit,
container
)
// No external version yet, create it.
try {
@@ -50,6 +50,17 @@ export function ensureGraphQLOctokit (okit: Octokit | undefined, container: Cont
return container.container.octokit
}
/**
* Ensures an Octokit instance has the REST API available.
* If the provided okit doesn't have rest.issues, falls back to container.octokit which is guaranteed to have it.
*/
export function ensureRESTOctokit (okit: Octokit | undefined, container: ContainerFocus): Octokit {
if (okit !== undefined && typeof (okit as any).rest?.issues?.createComment === 'function') {
return okit
}
return container.container.octokit
}
/**
* @public
*/