+
+
\ 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);
+ }
+ });