Skip to content

Commit

Permalink
Added TikTok integration
Browse files Browse the repository at this point in the history
  • Loading branch information
dinoosauro committed Jun 27, 2024
1 parent f428573 commit 5c81148
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 21 deletions.
13 changes: 13 additions & 0 deletions public/oauth.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>

<body>
<h1>There has been an error in the request :(</h1>
<script>
const search = new URLSearchParams(window.location.search);
const [code, state] = [search.get("code"), search.get("state")];
if (code && state) {
window.opener.postMessage({ code, state }, window.location.origin);
close();
}
</script>
</body>
4 changes: 2 additions & 2 deletions public/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
62 changes: 62 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
@@ -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
176 changes: 176 additions & 0 deletions server/server.ts
Original file line number Diff line number Diff line change
@@ -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: `<!DOCTYPE html><body><script>window.location.href = "https://www.tiktok.com/v2/auth/authorize?client_key=${Deno.env.get("clientKey")}&redirect_uri=${encodeURIComponent(Deno.env.get("sourceUrl") + "/oauth.html")}&state=${encodeURIComponent(state)}&response_type=code&scope=${encodeURIComponent("video.publish,video.upload,user.info.basic,user.info.basic")}"</script></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 })
})
35 changes: 31 additions & 4 deletions src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<script lang="ts">
import Header from "./lib/Header.svelte";
import "./app.css";
import { conversionStatus } from "./Scripts/Storage";
import {
conversionStatus,
TikTokProgress,
TikTokURL,
} from "./Scripts/Storage";
import Picker from "./lib/Picker.svelte";
import Resize from "./lib/ImageEditing/Resize.svelte";
import Filter from "./lib/ImageEditing/Filter.svelte";
Expand All @@ -18,6 +22,7 @@
import Privacy from "./lib/Privacy.svelte";
import Licenses from "./lib/Licenses.svelte";
import CheckNativeHeicSupport from "./Scripts/CheckNativeHeicSupport";
import TikTokIntegration from "./lib/Extra/TikTokIntegration.svelte";
/**
* If set to `1`, the Settings dialog will be shown
*/
Expand Down Expand Up @@ -110,9 +115,9 @@
* Set in the LocalStorage the new PDF Scale
* @param e the Event
*/
function PDFScaleChange(e: Event) {
function PDFScaleChange(e: Event, key?: string) {
localStorage.setItem(
"ImageConverter-PDFScale",
key ?? "ImageConverter-PDFScale",
(e.target as HTMLInputElement).value,
);
}
Expand Down Expand Up @@ -156,15 +161,18 @@
{:else if $conversionStatus === 1}
<ImagePicker></ImagePicker><br />
<div class="flex multiPage">
<Resize></Resize>
<div>
<Filter></Filter>
</div>
<CanvasRender></CanvasRender>
</div>
<br /><br />
{#if $TikTokProgress !== -1}
<TikTokIntegration></TikTokIntegration>
{/if}
{/if}
<ExportDialog></ExportDialog>

<br /><br /><br />
<span
style="position: absolute; right: 15px; top: 15px; transform: scale(0.9)"
Expand Down Expand Up @@ -253,6 +261,25 @@
{/await}
</div>
<br /><br />
<div class="second card">
<TitleIcon asset="musicnote" isH3={true}>TikTok integration</TitleIcon>
<p>
Write the link for the server that'll be used for posting images on
TikTok (add the / after the URL). Leave this field blank to disable this
function
</p>
<input
class="fullWidth"
type="text"
bind:value={$TikTokURL}
on:input={(e) => PDFScaleChange(e, "ImageConverter-TikTokURL")}
/><br /><br />
<i
>TikTok is a trademark of ByteDance, that is in no way affiliated with
image-renderer</i
>
</div>
<br /><br />
<Licenses></Licenses>
</Dialog>
{:else if dialogShow === 2}
Expand Down
Loading

0 comments on commit 5c81148

Please sign in to comment.