From 5c811486e9a5c2f2c3fb9e9be990d5e9d8c797ca Mon Sep 17 00:00:00 2001 From: Dinoosauro <80783030+Dinoosauro@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:17:48 +0200 Subject: [PATCH] Added TikTok integration --- public/oauth.html | 13 ++ public/service-worker.js | 4 +- server/README.md | 62 ++++++++ server/server.ts | 176 +++++++++++++++++++++++ src/App.svelte | 35 ++++- src/Scripts/ImageContentHandler.ts | 3 +- src/Scripts/Storage.ts | 22 ++- src/app.css | 11 +- src/lib/ExportDialog.svelte | 4 +- src/lib/Extra/TikTokIntegration.svelte | 52 +++++++ src/lib/ImageEditing/CanvasRender.svelte | 131 +++++++++++++++-- 11 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 public/oauth.html create mode 100644 server/README.md create mode 100644 server/server.ts create mode 100644 src/lib/Extra/TikTokIntegration.svelte diff --git a/public/oauth.html b/public/oauth.html new file mode 100644 index 0000000..c05d77f --- /dev/null +++ b/public/oauth.html @@ -0,0 +1,13 @@ + + + +

There has been an error in the request :(

+ + \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index 95acb29..4e940e2 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -21,9 +21,9 @@ self.addEventListener('install', e => { ); }); self.addEventListener('activate', e => self.clients.claim()); -self.addEventListener('fetch', event => { +self.addEventListener('fetch', async (event) => { const req = event.request; - if (req.url.indexOf("updatecode") !== -1) return fetch(req); else event.respondWith(networkFirst(req)); + if (req.url.indexOf("updatecode") !== -1 || req.url.indexOf(localStorage.getItem("ImageConverter-TikTokServer")) !== -1) event.respondWith(await fetch(req)); else event.respondWith(networkFirst(req)); }); async function networkFirst(req) { diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..92a071f --- /dev/null +++ b/server/README.md @@ -0,0 +1,62 @@ +# TikTok Server + +Since TikTok API requries that photo upload is done through an URL, you'll need +a server to handle this. Here's a server example that you can use with Deno +Deploy. + +## Setup: + +### Getting an API Key + +You'll need an API Key to upload content on TikTok. + +1. Go to the + [TikTok for Developers' website](https://developers.tiktok.com/apps/). You + might need to create an account. +2. Go into the [Manage apps](https://developers.tiktok.com/apps/) section. +3. Click to the "Connect an app" button, and write the app name. +4. Now go to the "Sandbox" tab, and then click the red "Create Sandbox" button. + Add whatever name you like. +5. Fill the form of the "Basic information" card. Check "Web" in the platforms. + Currently, don't add anything in the URLs. + +### Deno Deploy + +6. Create a new serverless project in Deno Deploy, and copy the `server.ts` + content. +7. You now need to add some environment values: click on the option button (at + the left of the "TS" select) and write the following values + - `clientKey`: the Client Key that you can access from the TikTok dashboard + in the "Credentials" tab + - `clientSecret`: same as above, but for the Client Secret + - `denoUrl`: the URL where the Deno project is deployed. Put it without "/" + - `sourceUrl`: the URL where your image-converter webpage is located. Put it + without "/" +8. Save the values. Copy again the URL of your Deno project, and add it where + required in the TikTok form. + +### Add the products on TikTok + +9. On the TikTok Developers page, click to "Add products", and choose "Login + Kit" and "Content Posting API" +10. Write again the Deno URL in the "Redirect URI" textbox in the "Login Kit" + card +11. Enable "Direct Post" on the "Content Posting API" card +12. Now click on the "Verify" button of the "Content Posting API" card. +13. Choose "sandbox1" from the "URL properties for" select, and click on "Verify + new property" +14. Choose "URL prefix", write again the URL of the Deno Deploy server (this + time, add a "/"), and press on "Verify". Download the file. +15. Go back to the Deno Deploy project, and add two new environment values: + - `tiktokVerificationFileName`: the name of the file you've downloaded (with + also the extension) + - `tiktokVerificationFileContent`: the content of the file you've downloaded +16. Save the values. Now verify the URL on the TikTok page. +17. In the Sandbox settings, login with your TikTok account. + +### Compatibility on image-converter + +You are now ready to use the image-converter integration to TikTok! + +18. On image-converter, open the settings. +19. Write the Deno URL (with the final "/") in the "TikTok integration" card diff --git a/server/server.ts b/server/server.ts new file mode 100644 index 0000000..c46c29b --- /dev/null +++ b/server/server.ts @@ -0,0 +1,176 @@ + +const kv = await Deno.openKv(); +kv.listenQueue(async (value: string) => { // Time to delete the content + const list = (await kv.get(["availableImages", value])).value as number ?? 0; // The maximum "position" number + for (let i = 1; i < list + 1; i++) { // Items start to 1 + const getBuffersAvailable = (await kv.get(["availableImages", value, i.toString()])).value as number ?? 0; // Get the number of divided buffers + for (let x = 0; x < getBuffersAvailable; x++) await kv.delete(["availableImages", value, i.toString(), x]); // Iterate on each buffer to delete them + await kv.delete(["availableImages", value, i.toString()]); + } + await kv.delete(["availableImages", value]); + await kv.delete(["availableIds", value]); +}) +interface AutoResponse { + body?: BodyInit, + status?: number, + type?: string +} +interface PostRequest { + id: string, + code: string, + description: string, + title: string, + thumbnail: number +} +/** + * Make a Response with CORS configured + * @param body the body of the request + * @param status the status of the response + * @param type the "Content-Type" added in the header + * @returns a Response to return to the request + */ +function autoResponse({ body, status, type }: AutoResponse) { + return new Response(body, { + status: status, + headers: { + "Content-Type": type ?? "text/plain", + "Access-Control-Allow-Origin": Deno.env.get("sourceUrl") ?? "http://localhost:5173", // TODO: Change it with only the production website! + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "Authorization", + + } + }); +} +/** + * Divide an ArrayBuffer in an array of ArrayBuffers of a smaller length, so that they can be stored on Deno KV + * @param buffer the buffer to divide + * @param chunkSize the size of the chunk + * @returns an ArrayBuffer[], of a fixed length + */ +function chunkArrayBuffer(buffer: ArrayBuffer, chunkSize: number) { + const chunked = []; + let offset = 0; + while (offset < buffer.byteLength) { + const size = Math.min(chunkSize, buffer.byteLength - offset); + const chunk = buffer.slice(offset, offset + size); + chunked.push(chunk); + offset += size; + } + return chunked; +} +/** + * Join multiple ArrayBuffers in a single one + * @param buffers the Buffers to join + * @returns the joined ArrayBuffer + */ +function joinArrayBuffers(buffers: ArrayBuffer[]) { + const size = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0); + const finalBuffer = new ArrayBuffer(size); + const array = new Uint8Array(finalBuffer); + let offset = 0; + for (const buffer of buffers) { + array.set(new Uint8Array(buffer), offset); + offset += buffer.byteLength; + } + return finalBuffer; +} + +Deno.serve(async (req) => { + if (req.method.toLowerCase() === "options") return autoResponse({ body: "", status: 204 }); // Request sent by the browser for CORS. There's no need to continue. + let url = req.url.substring(req.url.lastIndexOf("/") + 1); + if (url.indexOf("?") !== -1) url = url.substring(0, url.indexOf("?")); + switch (url) { + case "getId": { // Get a random ID that'll be used for identifying the uploaded images + const ids = crypto.randomUUID(); + await kv.set(["availableIds", ids], Date.now()); + kv.enqueue(ids, { delay: 600000 }); // Delete everything after 10 minutes + return autoResponse({ status: 200, body: JSON.stringify({ id: ids }) }); + } + case "upload": { // Upload image to the server + if (req.method !== "POST") return autoResponse({ body: "Endpoint available only with POST request.", status: 405 }); + const params = new URLSearchParams(req.url.substring(req.url.indexOf("?") + 1)); + const getPosition = params.get("position"); + const getId = params.get("id"); + if (getPosition && getId && !isNaN(+getPosition) && +getPosition > 0 && getPosition.indexOf(".") === -1) { // getPosition must be a positive integer, since this is the way we'll keep track of images + if (!(await kv.get(["availableIds", getId])).value) return autoResponse({ body: "Request expired.", status: 403 }); + const previousPosition = (await kv.get(["availableImages", getId])).value as number ?? 0; + const buffer = chunkArrayBuffer(await new Response(req.body).arrayBuffer(), 50 * 1024); // Divide the buffers with a maximum size of 50kb so that they can be stored in Deno KV. + await kv.set(["availableImages", getId, getPosition], buffer.length); + for (let i = 0; i < buffer.length; i++) await kv.set(["availableImages", getId, getPosition, i], buffer[i]); + await kv.set(["availableImages", getId], Math.max(previousPosition, +getPosition)); + return autoResponse({ body: "OK", status: 200 }); + } + return autoResponse({ body: "Missing fields", status: 400 }) + } + case "fetch": { // Get the image content + const params = new URLSearchParams(req.url.substring(req.url.indexOf("?") + 1)); + const getPosition = params.get("position"); + const getId = params.get("id"); + if (getPosition && getId) { + const generalInfo = await kv.get(["availableImages", getId, getPosition]); + if (typeof generalInfo.value === "number") { // There are some ArrayBuffers of the image + const mergedArr: ArrayBuffer[] = []; + for (let i = 0; i < generalInfo.value; i++) { // Get each ArrayBuffer and merge them + const output = await kv.get(["availableImages", getId, getPosition, i]); + output.value instanceof ArrayBuffer && mergedArr.push(output.value); + } + const outputBuffer = joinArrayBuffers(mergedArr); + return autoResponse({ body: outputBuffer as ArrayBuffer, status: 200 }); + } else return autoResponse({ body: "Not found", status: 404 }); + } else return autoResponse({ body: "Missing fields", status: 400 }); + } + case "auth": { // Start the authentication process using OAuth + const state = new URLSearchParams(req.url.substring(req.url.indexOf("?") + 1)).get("state"); + if (!state) return autoResponse({ body: "Missing fields", status: 40 }) + return autoResponse({ body: ``, type: "text/html" }) + } + case "post": { // Send the request to post the content to TikTok servers + if (req.method !== "POST") return autoResponse({ body: "Endpoint available only with POST request.", status: 405 }); + const json = await new Response(req.body).json() as PostRequest; + if (typeof json.description === "string" && typeof json.code === "string" && typeof json.id === "string" && typeof json.title === "string" && typeof json.thumbnail === "number") { + const tokenRequest = await fetch(`https://open.tiktokapis.com/v2/oauth/token/`, { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + method: "POST", + body: `client_key=${Deno.env.get("clientKey")}&client_secret=${Deno.env.get("clientSecret")}&code=${json.code}&grant_type=authorization_code&redirect_uri=${encodeURIComponent(Deno.env.get("sourceUrl") + "/oauth.html")}` + }); + const tokenJson = await tokenRequest.json(); + if (tokenRequest.status === 200) { + const buildImageUrl = (await kv.get(["availableImages", json.id])).value; + if (!buildImageUrl) return autoResponse({ body: "Non-existent ID", status: 401 }); + const finalBuild = Math.min(buildImageUrl as number, 35); + const finalArr = []; + for (let i = 1; i < finalBuild + 1; i++) finalArr.push(`${Deno.env.get("denoUrl")}/fetch?id=${json.id}&position=${i}`); + const imageRequest = await fetch("https://open.tiktokapis.com/v2/post/publish/content/init/", { + headers: { + Authorization: `Bearer ${tokenJson.access_token}`, + "Content-Type": `application/json; charset=UTF-8` + }, + method: "POST", + body: JSON.stringify({ + media_type: "PHOTO", + post_mode: "DIRECT_POST", + post_info: { + title: json.title, + description: json.description, + privacy_level: "SELF_ONLY", + }, + source_info: { + source: "PULL_FROM_URL", + photo_images: finalArr, + photo_cover_index: json.thumbnail + } + }) + }) + if (imageRequest.status === 200) return autoResponse({ body: "", status: 200 }); else return autoResponse({ body: "Error while uploading images", status: 500 }); + }; + return autoResponse({ body: "Error while fetching token", status: 500 }); + } else return autoResponse({ body: "Missing fields" }) + } + case Deno.env.get("tiktokVerificationFileName"): { // The file that TikTok requires to keep in the server to verify ownership + return autoResponse({ body: Deno.env.get("tiktokVerificationFileContent") }); + } + } + return autoResponse({ body: "Not found", status: 400 }) +}) diff --git a/src/App.svelte b/src/App.svelte index b2f690a..ad21672 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,7 +1,11 @@ + +
+

Upload to TikTok

+ {#if $TikTokProgress === 0} +

+ The image will stored on the server written in the settings for up + to 10 minutes, so that they can be uploaded to TikTok. Then, the + server (if it's running the same version as the one suggested in the + repository) will delete them. The currently selected image will be + used for the thumbnail. +

+

+
+

Post title and caption:

+

Title:

+
+

Caption:

+ +
+
+
+ + {:else if $TikTokProgress === 1} +

Uploading images on the server. Please wait.

+ {:else if $TikTokProgress === 2} +

+ Request successfully made to TikTok. The slideshow is now available + on your profile as a private content. +

+
+ + {/if} +
diff --git a/src/lib/ImageEditing/CanvasRender.svelte b/src/lib/ImageEditing/CanvasRender.svelte index 9d4e43a..f98c15f 100644 --- a/src/lib/ImageEditing/CanvasRender.svelte +++ b/src/lib/ImageEditing/CanvasRender.svelte @@ -7,6 +7,11 @@ currentImageEditing, forceCanvasUpdate, measureMap, + TikTokCaption, + TikTokCode, + TikTokProgress, + TikTokTitle, + TikTokURL, type FileConversion, } from "../../Scripts/Storage"; import { ExportFile, getZip, restoreZip } from "../../Scripts/ExportFile"; @@ -117,7 +122,19 @@ } else reject(); }); } - onMount(() => updateView()); + onMount(() => { + updateView(); + window.onmessage = (msg) => { + console.log(msg); + if ( + msg.origin === window.location.origin && + msg.data.state === userState + ) { + TikTokProgress.set(0); + tikTokAuthorization = msg.data.code; + } + }; + }); forceCanvasUpdate.subscribe(() => reRender()); // Re-render the canvas when is needed (usually when filters are changed) currentImageEditing.subscribe(() => updateView()); // Change the image source when the user clicks on another image /** @@ -261,6 +278,93 @@ */ let scaleBlurPixel = false; const blurScaleCheckboxId = `Checkbox-${Math.random().toString().substring(2)}`; + /** + * The string that contains the TikTok code used for authentication + */ + let tikTokAuthorization = ""; + /** + * The State that'll be used for TikTok API + */ + let userState = ""; + TikTokProgress.subscribe(async (value) => { + value === 0 && + window.scrollTo({ + top: document.body.scrollHeight, + behavior: "smooth", + }); + if (value === 1) { + // Time to send the requests to the server + const storeThumbnail = $currentImageEditing; + for (let i = 0; i < $convertFiles.length; i++) { + $currentImageEditing = i; // Change the image that'll be rendeerd + conversionProgress.set($currentImageEditing); // And set the number of the current exported image, so that the bottom dialog can be shown with that number. + await updateView(); + const resizedCanvas = document.createElement("canvas"); + // Currently, TikTok supports image uploads to their API up to 1080p. + resizedCanvas.width = + canvas.width > canvas.height + ? (canvas.width * 1080) / canvas.height + : 1080; + resizedCanvas.height = + canvas.height > canvas.width + ? (canvas.height * 1080) / canvas.width + : 1080; + resizedCanvas + .getContext("2d") + ?.drawImage( + canvas, + 0, + 0, + resizedCanvas.width, + resizedCanvas.height, + ); + const arr = await new Promise((resolve) => { + // Render the canvas to a Blob, and then get the Uint8Array that'll be sent + resizedCanvas.toBlob( + async (blob) => { + if (blob) { + resolve( + new Uint8Array( + await new Response(blob).arrayBuffer(), + ), + ); + } + }, + document + .createElement("canvas") + .toDataURL("image/webp") + .startsWith("data:image/webp") + ? "image/webp" + : "image/jpeg", + 0.8, + ); + }); + await fetch( + `${$TikTokURL}upload?id=${$TikTokCode}&position=${i + 1}`, // Upload the image to the server + { + method: "POST", + body: arr, + }, + ); + } + if ( + ( + await fetch(`${$TikTokURL}post`, { + method: "POST", + body: JSON.stringify({ + id: $TikTokCode, + description: $TikTokCaption, + code: tikTokAuthorization, + thumbnail: storeThumbnail, + title: $TikTokTitle, + }), + }) + ).status === 200 + ) + TikTokProgress.set(2); // Successful + conversionProgress.set(undefined); + } + });
@@ -340,13 +444,24 @@ }}>Export all images
- +
+ + {#if !!$TikTokURL} + + {/if} +