Skip to content

Commit 26217bf

Browse files
Merge remote-tracking branch 'origin/staging' into improve/slack
# Conflicts: # apps/sim/lib/webhooks/deploy.ts # packages/db/migrations/meta/0258_snapshot.json # packages/db/migrations/meta/_journal.json
2 parents ec06db5 + 5d3809a commit 26217bf

80 files changed

Lines changed: 22211 additions & 381 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,31 @@ export function xAIIcon(props: SVGProps<SVGSVGElement>) {
12741274
)
12751275
}
12761276

1277+
export function TikTokIcon(props: SVGProps<SVGSVGElement>) {
1278+
return (
1279+
<svg
1280+
xmlns='http://www.w3.org/2000/svg'
1281+
viewBox='0 0 520.48 520.48'
1282+
width='1em'
1283+
height='1em'
1284+
{...props}
1285+
>
1286+
<path
1287+
fill='#ff004f'
1288+
d='m392.19,206.7v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63v-193.01h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.32,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1289+
/>
1290+
<path
1291+
fill='#00f7ef'
1292+
d='m375.32,166.81v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63V97.74h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.33,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1293+
/>
1294+
<path
1295+
fill='#fff'
1296+
d='m386.06,188.29v49.13c-8.6-.84-19.74-2.78-32.09-7.31-16.13-5.91-28.13-13.99-35.98-20.25v99.32l-.19-.31c.14,1.96.19,3.97.19,5.99,0,49.33-40.13,89.48-89.48,89.48s-89.48-40.15-89.48-89.48,40.13-89.5,89.48-89.5c4.82,0,9.57.39,14.2,1.13v48.43c-4.45-1.6-9.22-2.45-14.2-2.45-23.36,0-42.38,19-42.38,42.38s19.02,42.38,42.38,42.38,42.38-19.02,42.38-42.38c0-.88-.02-1.75-.08-2.63V119.22h49.11c.18,4.16.35,8.34.53,12.51.33,8.19,3.25,16.05,8.32,22.49,5.97,7.57,14.76,16.36,27.13,23.38,11.57,6.56,22.45,9.38,30.15,10.7Z'
1297+
/>
1298+
</svg>
1299+
)
1300+
}
1301+
12771302
export function xIcon(props: SVGProps<SVGSVGElement>) {
12781303
return (
12791304
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>

apps/sim/app/api/tools/jupyter/upload/route.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
buildJupyterAuthHeaders,
1919
encodeJupyterPath,
2020
normalizeJupyterServerUrl,
21+
parseJupyterContentModel,
2122
UnsafeJupyterPathError,
2223
} from '@/tools/jupyter/utils'
2324

@@ -128,17 +129,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
128129
)
129130
}
130131

131-
const uploaded = await response.json()
132+
const uploaded = parseJupyterContentModel(await response.json())
133+
if (!uploaded) {
134+
logger.error(`[${requestId}] Jupyter returned an invalid upload response`)
135+
return NextResponse.json(
136+
{ success: false, error: 'Jupyter returned an invalid upload response' },
137+
{ status: 502 }
138+
)
139+
}
140+
141+
const uploadedName = uploaded.name ?? fileName
142+
const uploadedPath = uploaded.path ?? destinationPath
143+
const uploadedSize = uploaded.size ?? fileBuffer.length
144+
const lastModified = uploaded.lastModified ?? null
132145

133-
logger.info(`[${requestId}] File uploaded to Jupyter: ${uploaded.path}`)
146+
logger.info(`[${requestId}] File uploaded to Jupyter: ${uploadedPath}`)
134147

135148
return NextResponse.json({
136149
success: true,
137150
output: {
138-
name: uploaded.name ?? fileName,
139-
path: uploaded.path ?? destinationPath,
140-
size: uploaded.size ?? fileBuffer.length,
141-
lastModified: uploaded.last_modified ?? null,
151+
name: uploadedName,
152+
path: uploadedPath,
153+
size: uploadedSize,
154+
lastModified,
142155
},
143156
})
144157
} catch (error) {
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import {
11+
getFileExtension,
12+
getMimeTypeFromExtension,
13+
processSingleFileToUserFile,
14+
} from '@/lib/uploads/utils/file-utils'
15+
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
16+
import { assertToolFileAccess } from '@/app/api/files/authorization'
17+
import type { UserFile } from '@/executor/types'
18+
import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
19+
import { readTikTokApiResponse } from '@/tools/tiktok/utils'
20+
21+
export const dynamic = 'force-dynamic'
22+
23+
const logger = createLogger('TikTokPublishVideoAPI')
24+
25+
const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm'])
26+
27+
/** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */
28+
const DEFAULT_CHUNK_SIZE = 10_000_000
29+
const TIKTOK_ERROR_RESPONSE_MAX_BYTES = 64 * 1024
30+
31+
/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's
32+
* own limit is 4GB, but relaying that much through this server's memory per request isn't
33+
* safe under concurrent load. Enforced before downloading the file so an oversized upload
34+
* fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB
35+
* in-process. */
36+
const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024
37+
38+
function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } {
39+
if (totalBytes <= DEFAULT_CHUNK_SIZE) {
40+
return { chunkSize: totalBytes, totalChunkCount: 1 }
41+
}
42+
const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE)
43+
return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount }
44+
}
45+
46+
function resolveVideoMimeType(fileName: string, fileType: string | undefined): string | null {
47+
if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType
48+
const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName))
49+
return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : null
50+
}
51+
52+
async function validateDirectPostSettings(
53+
accessToken: string,
54+
postInfo: { privacy_level: string; brand_content_toggle: boolean },
55+
requestId: string
56+
): Promise<string | null> {
57+
if (postInfo.brand_content_toggle && postInfo.privacy_level === 'SELF_ONLY') {
58+
return 'Branded content cannot use Only Me privacy.'
59+
}
60+
61+
const response = await fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', {
62+
method: 'POST',
63+
headers: {
64+
Authorization: `Bearer ${accessToken}`,
65+
'Content-Type': 'application/json; charset=UTF-8',
66+
},
67+
body: '{}',
68+
})
69+
const data = await response.json()
70+
if (!response.ok || (data.error?.code && data.error.code !== 'ok')) {
71+
logger.warn(`[${requestId}] TikTok creator-info preflight failed`, {
72+
status: response.status,
73+
code: data.error?.code,
74+
})
75+
return data.error?.message || 'TikTok creator information could not be verified.'
76+
}
77+
78+
const privacyOptions: unknown = data.data?.privacy_level_options
79+
if (
80+
!Array.isArray(privacyOptions) ||
81+
!privacyOptions.some((option) => option === postInfo.privacy_level)
82+
) {
83+
return `The selected privacy level (${postInfo.privacy_level}) is not currently available for this TikTok account. Run Query Creator Info and choose one of the returned options.`
84+
}
85+
86+
return null
87+
}
88+
89+
async function uploadChunks(
90+
uploadUrl: string,
91+
buffer: Buffer,
92+
mimeType: string,
93+
requestId: string
94+
): Promise<void> {
95+
const totalBytes = buffer.length
96+
const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes)
97+
98+
for (let i = 0; i < totalChunkCount; i++) {
99+
const start = i * chunkSize
100+
const isLastChunk = i === totalChunkCount - 1
101+
const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1
102+
const chunk = buffer.subarray(start, end + 1)
103+
104+
const response = await fetch(uploadUrl, {
105+
method: 'PUT',
106+
headers: {
107+
'Content-Type': mimeType,
108+
'Content-Length': String(chunk.length),
109+
'Content-Range': `bytes ${start}-${end}/${totalBytes}`,
110+
},
111+
body: new Uint8Array(chunk),
112+
})
113+
114+
if (!response.ok) {
115+
const errorText = await readResponseTextWithLimit(response, {
116+
maxBytes: TIKTOK_ERROR_RESPONSE_MAX_BYTES,
117+
label: 'TikTok upload error response',
118+
}).catch(() => 'Error response exceeded the allowed size')
119+
logger.error(`[${requestId}] TikTok chunk upload failed`, {
120+
chunkIndex: i,
121+
status: response.status,
122+
errorText,
123+
})
124+
throw new Error(
125+
`TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}`
126+
)
127+
}
128+
}
129+
}
130+
131+
export const POST = withRouteHandler(async (request: NextRequest) => {
132+
const requestId = generateRequestId()
133+
134+
try {
135+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
136+
if (!authResult.success || !authResult.userId) {
137+
logger.warn(`[${requestId}] Unauthorized TikTok publish-video attempt: ${authResult.error}`)
138+
return NextResponse.json(
139+
{ success: false, error: authResult.error || 'Authentication required' },
140+
{ status: 401 }
141+
)
142+
}
143+
144+
const parsed = await parseRequest(tiktokPublishVideoContract, request, {})
145+
if (!parsed.success) return parsed.response
146+
const data = parsed.data.body
147+
148+
let userFile: UserFile
149+
try {
150+
userFile = processSingleFileToUserFile(data.file, requestId, logger)
151+
} catch (error) {
152+
return NextResponse.json(
153+
{ success: false, error: getErrorMessage(error, 'Failed to process file') },
154+
{ status: 400 }
155+
)
156+
}
157+
158+
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
159+
if (denied) return denied
160+
161+
if (data.mode === 'direct') {
162+
const settingsError = await validateDirectPostSettings(
163+
data.accessToken,
164+
data.postInfo,
165+
requestId
166+
)
167+
if (settingsError) {
168+
return NextResponse.json({ success: false, error: settingsError }, { status: 400 })
169+
}
170+
}
171+
172+
const mimeType = resolveVideoMimeType(userFile.name, userFile.type)
173+
if (!mimeType) {
174+
return NextResponse.json(
175+
{
176+
success: false,
177+
error: 'Unsupported video type. TikTok accepts MP4, MOV/QuickTime, or WebM files.',
178+
},
179+
{ status: 400 }
180+
)
181+
}
182+
183+
logger.info(`[${requestId}] Downloading video from storage`, {
184+
fileName: userFile.name,
185+
size: userFile.size,
186+
})
187+
188+
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, {
189+
maxBytes: TIKTOK_MAX_VIDEO_BYTES,
190+
})
191+
if (fileBuffer.length === 0) {
192+
return NextResponse.json(
193+
{ success: false, error: 'The video file is empty.' },
194+
{ status: 400 }
195+
)
196+
}
197+
const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length)
198+
199+
const initUrl =
200+
data.mode === 'draft'
201+
? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/'
202+
: 'https://open.tiktokapis.com/v2/post/publish/video/init/'
203+
204+
const initBody: Record<string, unknown> = {
205+
source_info: {
206+
source: 'FILE_UPLOAD',
207+
video_size: fileBuffer.length,
208+
chunk_size: chunkSize,
209+
total_chunk_count: totalChunkCount,
210+
},
211+
}
212+
if (data.mode === 'direct') {
213+
initBody.post_info = data.postInfo
214+
}
215+
216+
logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, {
217+
videoSize: fileBuffer.length,
218+
chunkSize,
219+
totalChunkCount,
220+
})
221+
222+
const initResponse = await fetch(initUrl, {
223+
method: 'POST',
224+
headers: {
225+
Authorization: `Bearer ${data.accessToken}`,
226+
'Content-Type': 'application/json; charset=UTF-8',
227+
},
228+
body: JSON.stringify(initBody),
229+
})
230+
231+
const { data: initData, error: initError } = await readTikTokApiResponse(
232+
initResponse,
233+
tiktokPublishInitApiDataSchema
234+
)
235+
236+
if (initError) {
237+
logger.error(`[${requestId}] TikTok init failed`, { error: initError })
238+
return NextResponse.json(
239+
{ success: false, error: initError.message || 'Failed to initialize TikTok upload' },
240+
{ status: initResponse.status >= 400 ? initResponse.status : 502 }
241+
)
242+
}
243+
244+
const publishId = initData?.publish_id
245+
const uploadUrl = initData?.upload_url
246+
247+
if (!publishId || !uploadUrl) {
248+
return NextResponse.json(
249+
{ success: false, error: 'TikTok did not return a publish ID and upload URL' },
250+
{ status: 502 }
251+
)
252+
}
253+
254+
try {
255+
await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId)
256+
} catch (error) {
257+
return NextResponse.json(
258+
{ success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') },
259+
{ status: 502 }
260+
)
261+
}
262+
263+
logger.info(`[${requestId}] TikTok video upload complete`, { publishId })
264+
265+
return NextResponse.json({ success: true, output: { publishId } })
266+
} catch (error) {
267+
if (isPayloadSizeLimitError(error)) {
268+
logger.warn(`[${requestId}] Rejected oversized TikTok video upload`, {
269+
maxBytes: error.maxBytes,
270+
observedBytes: error.observedBytes,
271+
})
272+
const maxMb = Math.floor(TIKTOK_MAX_VIDEO_BYTES / (1024 * 1024))
273+
return NextResponse.json(
274+
{
275+
success: false,
276+
error: `Video exceeds the ${maxMb}MB limit for file uploads.`,
277+
},
278+
{ status: 413 }
279+
)
280+
}
281+
logger.error(`[${requestId}] Error publishing video to TikTok:`, error)
282+
return NextResponse.json(
283+
{ success: false, error: getErrorMessage(error, 'Internal server error') },
284+
{ status: 500 }
285+
)
286+
}
287+
})

0 commit comments

Comments
 (0)