BREAK: init new apps/web to latest nextjs and switch from ow to zod

Signed-off-by: Hanif Dwy Putra S <hanifdwyputrasembiring@gmail.com>
This commit is contained in:
Hanif Dwy Putra S
2025-08-30 07:22:29 +08:00
parent aefd753b36
commit b64eb30abd
58 changed files with 4424 additions and 1267 deletions

View File

@@ -1,6 +0,0 @@
const eslintConfigs = require('tiktok-dl-config/eslint.typescript');
module.exports = {
...eslintConfigs,
extends: eslintConfigs.extends.concat(['plugin:@next/next/recommended']),
};

43
apps/web/.gitignore vendored
View File

@@ -1,2 +1,41 @@
.next
*.rdb
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,3 +0,0 @@
*.json
.next

View File

@@ -1 +0,0 @@
module.exports = require('tiktok-dl-config/prettier');

21
apps/web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
/**
* Body Component.
* @param {{children: JSX.Element}} param0 BodyComponent props.
* @return {JSX.Element}
*/
export const BodyComponent = ({
children,
}: {
children: JSX.Element;
}): JSX.Element => {
return (
<React.Fragment>
<section className="min-h-screen bg-light-200">
<div className="md:container md:mx-auto">{children}</div>
</section>
</React.Fragment>
);
};

View File

@@ -1,15 +0,0 @@
import React from 'react';
export const Footer = () => (
<React.Fragment>
<p className="text-lg">
&copy; {new Date().getFullYear()}{' '}
<a
target="_blank"
href="https://github.com/hansputera/tiktok-dl.git"
>
TikTok-DL Project
</a>
</p>
</React.Fragment>
);

View File

@@ -1,187 +0,0 @@
import React from 'react';
import useSWR, {Fetcher} from 'swr';
import {ExtractedInfo} from 'tiktok-dl-core';
import {getTikTokURL} from '../lib/url';
import {VideoComponent} from './Video';
// // ERRORS ///
/**
* @class InvalidUrlError
*/
class InvalidUrlError extends Error {
/**
* @param {string} msg error message.
*/
constructor(msg?: string) {
super(msg);
this.name = 'INVALID_URL';
}
}
export type ExtractedInfoWithProvider = ExtractedInfo & {
provider: string;
_url: string;
};
interface StateData {
submitted: boolean;
error?: string | Error;
url: string;
}
const fetcher: Fetcher<ExtractedInfoWithProvider, string> = (...args) =>
fetch(...args).then((r) => r.json());
/**
* FormInput Component.
* @return {JSX.Element}
*/
export const FormInputComponent = (): JSX.Element => {
const [state, setState] = React.useState<StateData>({
submitted: false,
error: undefined,
url: '',
});
const {data, mutate} = useSWR(
(!state.error || !(state.error as string).length) &&
/^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test(state.url)
? [
'/api/download',
{
method: 'POST',
body: JSON.stringify({url: state.url}),
},
]
: null,
fetcher,
{
loadingTimeout: 5_000,
refreshInterval: 60_000,
revalidateOnMount: false,
onSuccess: () =>
setState({
...state,
submitted: false,
}),
},
);
React.useEffect(() => {
if (
!/^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test(
state.url,
) &&
state.url.length
) {
setState({
...state,
error: new InvalidUrlError('Invalid TikTok URL'),
});
} else {
// submit event trigger.
if (state.submitted && !state.error) {
mutate();
}
try {
const u = getTikTokURL(state.url);
if (!u) {
setState({
...state,
error: new InvalidUrlError('Invalid TikTok URL'),
});
return;
}
setState({
...state,
url: u,
});
} catch {
setState({
...state,
error: new InvalidUrlError('Invalid TikTok URL'),
});
}
setState({
...state,
error: undefined,
});
}
}, [state.submitted, state.url]);
return (
<React.Fragment>
<section className="inline-block">
<h1 className="text-lg leading-relaxed">
Fill TikTok's Video URL below:
</h1>
<p className="text-red-400 font-sans font-semibold">
{state.error instanceof Error
? state.error.name.concat(
': '.concat(state.error.message),
)
: state.error
? state.error
: ''}
</p>
<form
className="flex flex-col md:flex-row"
onSubmit={(event) => {
event.preventDefault();
if (!state.url.length) {
setState({
...state,
error: 'Please fill the URL!',
});
return;
}
!state.error &&
setState({
...state,
submitted: true,
});
}}
>
<div>
<input
type="url"
onChange={(event) =>
setState({
...state,
url: event.target.value,
})
}
value={state.url}
placeholder="e.g: "
className="p-3 border border-gray-300 font-sans h-auto w-auto outline-solid-blue-500"
/>
</div>
<div>
<button
className="p-3 lg:ml-2 mt-1 bg-sky-400 uppercase text-white shadow-sm"
disabled={state.submitted}
>
download
</button>
</div>
</form>
<section className="mt-3 mb-3">
{state.submitted && !data && (
<p className={'text-base font-sans text-blue-500'}>
Wait a minute
</p>
)}
{data && data.video && data.video.urls.length && (
<VideoComponent data={data} />
)}
</section>
</section>
</React.Fragment>
);
};
export default FormInputComponent;

View File

@@ -1,36 +0,0 @@
/**
* Next Head Component.
* @param {{title: string, children: JSX.Element }} param0 NextHeadComponent args.
* @return {JSX.Element}
*/
export const NextHeadComponent = ({
title,
children,
}: {
title?: string;
children?: JSX.Element;
}): JSX.Element => {
return (
<>
<title>{title ?? 'TikTok Downloader'}</title>
<meta
name="title"
content="TikTok Downloader | Non&Watermark Support"
/>
<meta
name="description"
content="An Open-Source Project where it could download TikTok's Video without annoying ads!"
/>
<meta
name="keywords"
content="tiktok-downloader, tiktokdl, tiktok, download video tiktok, tiktok no watermark"
/>
<meta
name="author"
content="Hanif Dwy Putra S <github.com/hansputera>"
/>
<meta name="robots" content="index, follow" />
{children}
</>
);
};

View File

@@ -1,38 +0,0 @@
import React from 'react';
import type {ExtractedInfoWithProvider} from './FormInput';
export const VideoComponent = ({data}: {data: ExtractedInfoWithProvider}) => {
const copyUrl = (url: string) => {
navigator.clipboard.writeText(url);
if (typeof window !== 'undefined') {
window.alert('URL Copied');
}
};
return (
<React.Fragment>
This video is downloaded from{' '}
<span className="font-semibold">{data.provider}</span>.
{data.caption && <pre>{data.caption}</pre>}
<div className="md:grid md:grid-cols-3 md:gap-4">
<video
controls={true}
autoPlay={false}
className="rounded-md h-64 w-80"
>
<source src={data.video?.urls[0]} />
</video>
<div className="flex flex-row font-sans basis-8 mt-2">
{data.video?.urls.map((url, index) => (
<button
key={index.toString()}
className="mr-1 bg-teal-400 md:p-2 p-1 rounded-md shadow"
onClick={() => copyUrl(url)}
>
LINK {index + 1}
</button>
))}
</div>
</div>
</React.Fragment>
);
};

View File

@@ -1,23 +0,0 @@
/**
* @class HttpError
*/
export class HttpError extends Error {
public statusCode = 400;
/**
* @constructor
* @param {string} message HttpError message,
*/
constructor(message: string) {
super(message);
}
/**
* Set http status code.
* @param {number} code HttpError status code.
* @return {HttpError}
*/
setCode(code: number): HttpError {
this.statusCode = code;
return this;
}
}

View File

@@ -1 +0,0 @@
export * from './httpError';

View File

@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

View File

@@ -1,3 +0,0 @@
export * from './redis';
export * from './rotator';
export * from './url';

View File

@@ -1,31 +0,0 @@
import {NextApiRequest, NextApiResponse} from 'next';
type Middleware = (
request: NextApiRequest,
response: NextApiResponse,
) => Promise<undefined>;
export const applyRoute = (
route: (req: NextApiRequest, res: NextApiResponse) => Promise<void>,
middlewares: Middleware[],
) => {
return async (req: NextApiRequest, res: NextApiResponse) => {
const middleware:
| {
message: string;
status: number;
}
| undefined = await Promise.all(middlewares.map((m) => m(req, res)))
.catch((e) => ({
message: e.message,
status: 500,
}))
.then(() => undefined);
if (middleware)
return res.status(middleware.status).json({
message: middleware.message,
statusCode: middleware.status,
});
else return route(req, res);
};
};

View File

@@ -1,33 +0,0 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {rateLimitConfig} from '../config';
import {HttpError} from '../errors';
import {client} from '../lib';
export const ratelimitMiddleware = async (
req: NextApiRequest,
_res: NextApiResponse,
) => {
const ip = req.headers['x-real-ip'] || req.headers['x-forwarded-for'];
if (!rateLimitConfig.enable || process.env.NODE_ENV === 'development')
return undefined;
else if (!ip)
throw new HttpError("Couldn't find your real ip address!").setCode(401);
const result = await client.get('rate-'.concat(ip.toString()));
if (result) {
if (parseInt(result) > rateLimitConfig.maxRatelimitPerXSeconds) {
throw new HttpError(
'Please try again, you are getting ratelimit!',
).setCode(429);
}
client.incr('rate-'.concat(ip.toString()));
return undefined;
} else {
client.set(
'rate-'.concat(ip.toString()),
'1',
'EX',
rateLimitConfig.ratelimitTime,
);
return undefined;
}
};

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,20 +0,0 @@
const webpack = require('webpack');
const withTM = require('next-transpile-modules')(['tiktok-dl-core']);
const WindiCSSWebpackPlugin = require('windicss-webpack-plugin');
const {parsed: cusEnv} = require('dotenv').config({
path: require('path').resolve(__dirname, '..', '..', '.env'),
});
module.exports = withTM({
reactStrictMode: true,
experimental: {esmExternals: true},
webpack(config) {
// adding windicss plugin
config.plugins.push(new WindiCSSWebpackPlugin());
if (typeof cusEnv !== 'undefined') {
config.plugins.push(new webpack.EnvironmentPlugin(cusEnv));
}
return config;
},
});

8
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ['tiktok-dl-core'],
serverExternalPackages: ['vm2'],
};
export default nextConfig;

View File

@@ -1,35 +1,39 @@
{
"name": "tiktok-dl-web",
"description": "TikTok-DL Project Website",
"version": "1.4.1",
"license": "MIT",
"dependencies": {
"dotenv": "16.6.1",
"ioredis": "5.7.0",
"next": "12.2.4",
"ow": "0.28.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"swr": "1.3.0",
"tiktok-dl-config": "*",
"tiktok-dl-core": "*"
},
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"format": "prettier . --write",
"start": "next start --port process.env.PORT"
},
"devDependencies": {
"@next/eslint-plugin-next": "12.2.4",
"@types/node": "17.0.35",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"next-transpile-modules": "9.0.0",
"prettier": "2.7.1",
"tiktok-dl-config": "*",
"typescript": "^5.9.2",
"windicss-webpack-plugin": "1.7.5"
}
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ioredis": "^5.7.0",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"ow": "^2.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
}
}

View File

@@ -1,11 +0,0 @@
import 'windi.css';
import type {AppProps} from 'next/app';
/**
* TikTokApp
* @param {AppProps} arg0 App properties.
* @return {JSX.Element}
*/
export default function TikTokApp({Component, pageProps}: AppProps) {
return <Component {...pageProps} />;
}

View File

@@ -1,28 +0,0 @@
import Document, {Head, Html, Main, NextScript} from 'next/document';
import {BodyComponent} from '../components/Body';
import {NextHeadComponent} from '../components/Header';
/**
* @class TikTokDocument
*/
export default class TikTokDocument extends Document {
/**
* Render TikTokDocument
* @return {JSX.Element}
*/
render(): JSX.Element {
return (
<Html lang="en">
<Head>
<NextHeadComponent />
</Head>
<body>
<BodyComponent>
<Main />
</BodyComponent>
<NextScript />
</body>
</Html>
);
}
}

View File

@@ -1,103 +0,0 @@
import useSWR from 'swr';
import Head from 'next/head';
export default () => {
const {data, error} = useSWR(
'https://api.github.com/repos/hansputera/tiktok-dl',
(...args) => fetch(...args).then((r) => r.json()),
);
return (
<>
<Head>
<title>About</title>
</Head>
<section className="p-5">
<h1 className="text-center text-2xl">About TikTok-DL</h1>
<div className="mt-3">
{error ? (
<p className="text-red-500">{error}</p>
) : (
data && (
<>
<p className="text-center font-sans">
{data.description}
</p>
<ul className="mb-3 mt-3">
<li>
This project is based on{' '}
<span className="font-semibold">
{data.license.name}
</span>
</li>
<li>
Currently, we have{' '}
{data.stargazers_count.toLocaleString()}{' '}
stars, {data.forks.toLocaleString()}{' '}
forks, and {data.open_issues} opened
issues.
</li>
<li>
Also, this project is created at{' '}
<span className="font-semibold">
{new Date(
data.created_at,
).toLocaleDateString('en-US', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'Asia/Jakarta',
})}
</span>{' '}
by{' '}
<span className="font-medium">
{data.owner.login}
</span>
</li>
<li>
Last update:{' '}
<span className="font-semibold">
{new Date(
data.updated_at,
).toLocaleDateString('en-US', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'Asia/Jakarta',
})}
</span>
</li>
</ul>{' '}
If you want see the source code,{' '}
<a
href={data.html_url}
className="font-semibold text-blue-500"
target="_blank"
>
click here
</a>
. And feel free to open an{' '}
<a
href={data.html_url.concat(
'/issues/new/choose',
)}
className="text-blue-500"
target="_blank"
>
issue
</a>{' '}
if you have a problem or find a bug. Thank you!
</>
)
)}
</div>
</section>
</>
);
};

View File

@@ -1,87 +0,0 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import ow from 'ow';
import {getProvider, Providers, BaseProvider} from 'tiktok-dl-core';
import {getTikTokURL} from '../../lib';
import {rotateProvider} from '../../lib/rotator';
import {applyRoute} from '../../middleware/apply';
import {ratelimitMiddleware} from '../../middleware/ratelimit';
export default applyRoute(
async (req: NextApiRequest, res: NextApiResponse) => {
try {
if (req.method === 'POST' && typeof req.body === 'string') {
req.body = JSON.parse(req.body);
}
const providersType = Providers.map((p) => p.resourceName());
ow(
req.body || req.query,
ow.object.partialShape({
url: ow.string.url.validate((v) => ({
validator: !!getTikTokURL(v),
message: 'Expected (.*).tiktok.com',
})),
type: ow.optional.string.validate((v) => ({
validator:
typeof v === 'string' &&
providersType.includes(v.toLowerCase()),
message:
'Invalid Provider, available provider is: ' +
Providers.map((x) => x.resourceName()).join(', '),
})),
rotateOnError:
req.method === 'POST'
? ow.optional.boolean
: ow.optional.string,
}),
);
let provider = getProvider(
(req.query.type || req.body.type) ?? 'random',
);
if (!provider) {
return res.status(400).json({
error: 'Invalid provider',
providers: providersType,
});
}
const params = provider.getParams();
if (
params &&
provider.resourceName() ===
(req.query.type?.toString() || req.body.type)?.toLowerCase()
) {
ow(req.query || req.body, ow.object.partialShape(params));
} else if (params) {
provider = getProvider('random');
}
const url = getTikTokURL(req.query.url || req.body.url);
const result = await rotateProvider(
provider as BaseProvider,
url!,
req.method === 'POST'
? req.body.rotateOnError
: !!req.query.rotateOnError,
params
? Object.fromEntries(
Object.keys(params!).map((p) => [
p,
(req.query[p] as string) ?? req.body[p],
]),
)
: {},
);
return res.status(200).json(result);
} catch (e) {
return res
.status((e as Error).name === 'ArgumentError' ? 400 : 500)
.json({
error: (e as Error).name + '|' + (e as Error).message,
});
}
},
[ratelimitMiddleware],
);

View File

@@ -1,5 +0,0 @@
import type {NextApiRequest, NextApiResponse} from 'next';
export default async (_: NextApiRequest, res: NextApiResponse) => {
return res.redirect('https://github.com/hansputera/tiktok-dl.git');
};

View File

@@ -1,24 +0,0 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {Providers} from 'tiktok-dl-core';
import {ratelimitMiddleware} from '../../middleware/ratelimit';
import type {Shape} from 'ow';
import {applyRoute} from '../../middleware/apply';
export default applyRoute(
async (req: NextApiRequest, res: NextApiResponse) => {
const providers = Providers.map((p) => ({
name: p.resourceName(),
url: p.client?.defaults.options.prefixUrl,
maintenance: p.maintenance,
params: p.getParams()
? Object.keys(p.getParams()!).map((x) => ({
name: x,
type: (p.getParams()![x] as Shape).type,
}))
: {},
}));
return res.send(providers);
},
[ratelimitMiddleware],
);

View File

@@ -1,14 +0,0 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {client} from '../../lib';
import {ratelimitMiddleware} from '../../middleware/ratelimit';
import {matchLink} from 'tiktok-dl-core';
import {applyRoute} from '../../middleware/apply';
export default applyRoute(
async (req: NextApiRequest, res: NextApiResponse) => {
await ratelimitMiddleware(req, res);
const keys = await client.keys('*');
return res.status(200).json(keys.filter((x) => matchLink(x)));
},
[ratelimitMiddleware],
);

View File

@@ -1,25 +0,0 @@
import dynamic from 'next/dynamic';
import {Footer} from '../components/Footer';
const FormInputComponentDynamic = dynamic(
() => import('../components/FormInput'),
{
ssr: false,
},
);
export default () => {
return (
<section className="p-5">
<h1 className="align-middle text-4xl font-sans font-medium">
TikTok-DL{' '}
<span className="font-normal md:break-words text-2xl">
Download TikTok Video without watermark and free ads.
</span>
</h1>
<FormInputComponentDynamic />
<Footer />
</section>
);
};

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1,57 @@
import { downloadValidator } from "@/app/validators/download.validator";
import { rotateProvider } from "@/services/rotator";
import { NextRequest } from "next/server";
import { getProvider } from "tiktok-dl-core";
export async function POST(request: NextRequest) {
try {
const json = await request.json();
const safeData = await downloadValidator.safeParseAsync(json);
if (safeData.error || !safeData.success) {
return Response.json({
errors: safeData.error,
message: 'Validation errors',
}, {
status: 400,
});
}
const provider = getProvider(safeData.data.type);
if (!provider) {
return Response.json({
message: 'Provider not found',
}, { status: 404 });
}
const params = provider?.getParams();
if (params) {
const safeDataParams = await params.safeParseAsync(safeData.data.params);
if (safeDataParams.error || !safeDataParams.success) {
return Response.json({
errors: safeDataParams.error,
message: 'Validation errors in params!',
}, {
status: 400,
});
}
}
const result = await rotateProvider(provider, safeData.data.url, safeData.data.rotateOnError, safeData.data.params);
return Response.json({
data: result,
});
} catch (e) {
return Response.json({
message: (e as Error).message,
}, {
status: 500,
});
}
}
export async function GET() {
return Response.json({
message: 'Currently we moved to POST method only.',
});
}

View File

@@ -0,0 +1,22 @@
import { Shape } from "ow";
import { BaseProvider, Providers } from "tiktok-dl-core";
const transformProvider = (provider: BaseProvider) => {
const params = provider.getParams();
return {
name: provider.resourceName(),
url: provider.client?.defaults.options.prefixUrl,
maintenance: provider.maintenance,
params: params ? Object.keys(params).map(key => ({
name: key,
type: (params?.[key] as Shape | undefined)?.type,
})) : {},
}
}
export async function GET() {
return Response.json({
data: Providers.map(transformProvider),
});
}

View File

@@ -0,0 +1,9 @@
import { redisClient } from "@/lib/redis";
import { matchLink } from "tiktok-dl-core";
export async function GET() {
const keys = await redisClient.keys('*');
return Response.json({
data: keys.filter(key => matchLink(key)),
});
}

View File

@@ -0,0 +1,74 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(94.27% 0.0268 242.57);
--secondary-background: oklch(100% 0 0);
--foreground: oklch(0% 0 0);
--main-foreground: oklch(0% 0 0);
--main: oklch(66.9% 0.18368 248.8066);
--border: oklch(0% 0 0);
--ring: oklch(0% 0 0);
--overlay: oklch(0% 0 0 / 0.8);
--shadow: 4px 4px 0px 0px var(--border);
--chart-1: #0099FF;
--chart-2: #FF4D50;
--chart-3: #FACC00;
--chart-4: #05E17A;
--chart-5: #7A83FF;
--chart-active-dot: #000;
}
.dark {
--background: oklch(27.08% 0.0336 240.69);
--secondary-background: oklch(23.93% 0 0);
--foreground: oklch(92.49% 0 0);
--main-foreground: oklch(0% 0 0);
--main: oklch(61.9% 0.16907 248.5982);
--border: oklch(0% 0 0);
--ring: oklch(100% 0 0);
--shadow: 4px 4px 0px 0px var(--border);
--chart-1: #008AE5;
--chart-2: #FF6669;
--chart-3: #E0B700;
--chart-4: #04C86D;
--chart-5: #7A83FF;
--chart-active-dot: #fff;
}
@theme inline {
--color-main: var(--main);
--color-background: var(--background);
--color-secondary-background: var(--secondary-background);
--color-foreground: var(--foreground);
--color-main-foreground: var(--main-foreground);
--color-border: var(--border);
--color-overlay: var(--overlay);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--spacing-boxShadowX: 4px;
--spacing-boxShadowY: 4px;
--spacing-reverseBoxShadowX: -4px;
--spacing-reverseBoxShadowY: -4px;
--radius-base: 10px;
--shadow-shadow: var(--shadow);
--font-weight-base: 500;
--font-weight-heading: 800;
}
@layer base {
body {
@apply text-foreground font-base bg-background;
}
h1, h2, h3, h4, h5, h6{
@apply font-heading;
}
}

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "TikTok-DL",
description: "An Open-Source Project where it could download TikTok's Video without annoying ads!",
keywords: 'tiktok-downloader, tiktokdl, tiktok, download video tiktok, tiktok no watermark',
authors: {
name: 'Hanif Dwy Putra S.',
url: 'https://hanifu.id',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body suppressHydrationWarning>
{children}
</body>
</html>
);
}

102
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { getTikTokURL } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import z from "zod";
export default function Home() {
const formSchema = z.object({
url: z.url().refine((val) => getTikTokURL(val), {
error: 'Invalid VT Tiktok URL',
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
url: '',
},
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
}
return (
<div className="min-h-screen flex items-center justify-center lg:justify-center">
<div className="w-full max-w-6xl mx-auto px-4">
{/* Mobile Layout */}
<div className="lg:hidden">
<h1 className="text-4xl font-sans text-black mb-6">
Download TikTok Videos!
</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="url" render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">VT URL</FormLabel>
<FormControl>
<Input placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)" {...field} />
</FormControl>
<FormDescription>
Please provide the tiktok video URL to download
</FormDescription>
<FormMessage />
</FormItem>
)} />
<Button type="submit">
Download
</Button>
</form>
</Form>
</div>
{/* Large Screen Hero Layout */}
<div className="hidden lg:flex lg:items-center lg:gap-12">
{/* Hero Title */}
<div className="flex-shrink-0">
<h1 className="text-6xl font-sans text-black leading-tight">
Download<br />
TikTok<br />
Videos!
</h1>
</div>
{/* Form Section */}
<div className="flex-1 max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="url" render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">VT URL</FormLabel>
<FormControl>
<Input
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
className="text-lg py-3"
{...field}
/>
</FormControl>
<FormDescription className="text-base">
Please provide the tiktok video URL to download
</FormDescription>
<FormMessage />
</FormItem>
)} />
<Button type="submit" size="lg" className="w-full">
Download
</Button>
</form>
</Form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { getTikTokURL } from "@/lib/utils";
import { Providers } from "tiktok-dl-core";
import z from "zod";
export const downloadValidator = z.object({
url: z.url().refine((url) => getTikTokURL(url), {
error: 'Invalid VT URL',
}),
type: z.enum(Providers.map(provider => provider.resourceName()).concat('random')),
rotateOnError: z.boolean().default(true),
nocache: z.boolean().default(false),
params: z.object().optional(),
});

View File

@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-base text-sm font-base ring-offset-white transition-all gap-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"text-main-foreground bg-main border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none",
noShadow: "text-main-foreground bg-main border-2 border-border",
neutral:
"bg-secondary-background text-foreground border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none",
reverse:
"text-main-foreground bg-main border-2 border-border hover:translate-x-reverseBoxShadowX hover:translate-y-reverseBoxShadowY hover:shadow-shadow",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,167 @@
"use client"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("font-heading", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-sm font-base text-foreground", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-sm font-base text-red-500", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-10 w-full rounded-base border-2 border-border bg-secondary-background selection:bg-main selection:text-main-foreground px-3 py-2 text-sm font-base text-foreground file:border-0 file:bg-transparent file:text-sm file:font-heading placeholder:text-foreground/50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,25 @@
"use client"
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"text-sm font-heading leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
)
}
export { Label }

View File

@@ -23,4 +23,4 @@ export const providerCache = 3600;
* How much rotate retries is allowed?
* Default: 5x
*/
export const maxRotateCount = 3;
export const maxRotateCount = 3;

View File

@@ -1,5 +1,5 @@
import Redis from 'ioredis';
export const client = new Redis(
export const redisClient = new Redis(
process.env.REDIS_URL ?? 'redis://:@localhost:6379',
);

View File

@@ -1,3 +1,10 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Get TikTok Video URL.
* @param {string} url Video
* @return {string}
@@ -14,4 +21,4 @@ export function getTikTokURL(url: string): string | undefined {
} catch {
return undefined;
}
}
}

View File

@@ -1,6 +1,6 @@
import {maxRotateCount, providerCache} from '../config';
import {maxRotateCount, providerCache} from '@/config/config';
import {BaseProvider, ExtractedInfo, getRandomProvider} from 'tiktok-dl-core';
import {client as redisClient} from './redis';
import {redisClient} from '@/lib/redis';
/**
* Rotate provider.
@@ -87,4 +87,4 @@ export const rotateProvider = async (
} else {
return JSON.parse(cachedData);
}
};
};

View File

@@ -1,37 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"strict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"outDir": "dist",
"target": "ESNext",
"module": "ESNext",
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"incremental": true,
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,8 +0,0 @@
import {defineConfig} from 'windicss/helpers';
export default defineConfig({
extract: {
include: ['**/*.{jsx,tsx,css}'],
exclude: ['node_modules', '.git', '.next'],
},
});

View File

@@ -6,8 +6,8 @@
"description": "TikTok-DL Project Core",
"dependencies": {
"got": "^12.6.1",
"ow": "0.28.1",
"vm2": "^3.9.19"
"vm2": "^3.9.19",
"zod": "^4.1.5"
},
"scripts": {
"lint": "eslint \"+(src)/**/*.ts\" --fix",

View File

@@ -1,5 +1,5 @@
import {Got} from 'got';
import type {Shape} from 'ow';
import { ZodObject } from 'zod';
export interface ExtractedInfo {
error?: string;
@@ -43,7 +43,7 @@ export interface MaintenanceProvider {
*/
export abstract class BaseProvider {
abstract client?: Got;
abstract getParams(): Shape | undefined;
abstract getParams(): ZodObject | undefined;
abstract maintenance?: MaintenanceProvider;
abstract resourceName(): string;
abstract fetch(

View File

@@ -1,7 +1,7 @@
import {BaseProvider, ExtractedInfo} from './base';
import {getFetch} from '../fetch';
import {matchCustomDownload, matchLink, runObfuscatedScript} from './utils';
import type {Shape} from 'ow';
import { ZodObject } from 'zod';
/**
* @class DownTikProvider
@@ -99,10 +99,10 @@ export class DownTikProvider extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params.
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

View File

@@ -1,6 +1,5 @@
import {getFetch} from '../fetch';
import {BaseProvider, ExtractedInfo} from './base';
import type {Shape} from 'ow';
import { matchLink } from './utils';
/**
@@ -73,11 +72,7 @@ export class MusicalyDown extends BaseProvider {
};
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
*/
public getParams(): Shape | undefined {
public getParams() {
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import {BaseProvider, ExtractedInfo} from './base';
import {getFetch} from '../fetch';
import {matchTikTokData} from './utils';
import ow, {Shape} from 'ow';
import z from 'zod';
/**
* @class NativeProvider
@@ -92,15 +92,9 @@ export class NativeProvider extends BaseProvider {
}
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
*/
public getParams(): Shape | undefined {
return {
'user-agent': ow.string.not.empty
.minLength(5)
.message('Invalid user-agent'),
};
public getParams() {
return z.object({
'user-agent': z.string().min(5),
})
}
}

View File

@@ -1,7 +1,6 @@
import {getFetch} from '../fetch';
import {BaseProvider, ExtractedInfo, MaintenanceProvider} from './base';
import {deObfuscateSaveFromScript} from './utils';
import type {Shape} from 'ow';
/**
* @class saveFromProvider
@@ -75,11 +74,7 @@ export class SaveFromProvider extends BaseProvider {
};
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
*/
public getParams(): Shape | undefined {
public getParams() {
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import {BaseProvider, ExtractedInfo} from './base';
import {getFetch} from '../fetch';
import { ZodObject } from 'zod';
// import {matchLink, runObfuscatedReplaceEvalScript} from './utils';
import type {Shape} from 'ow';
/**
* @class SaveTikProvider
@@ -97,10 +97,10 @@ export class SaveTikProvider extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { ZodObject } from 'zod';
import {getFetch} from '../fetch';
import {BaseProvider, ExtractedInfo, MaintenanceProvider} from './base';
import {matchLink, runObfuscatedReplaceEvalScript} from './utils';
import type {Shape} from 'ow';
/**
* @class SnaptikProvider
@@ -66,10 +66,10 @@ export class SnaptikProvider extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

View File

@@ -1,6 +1,6 @@
import {BaseProvider, ExtractedInfo} from './base';
import {getFetch} from '../fetch';
import type {Shape} from 'ow';
import { ZodObject } from 'zod';
/**
* @class TikDownProvider
@@ -69,10 +69,10 @@ export class TikDownProvider extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { ZodObject } from 'zod';
import {getFetch} from '../fetch';
import {BaseProvider, ExtractedInfo} from './base';
import {deObfuscate, matchLink} from './utils';
import type {Shape} from 'ow';
/**
* @class TikmateProvider
@@ -81,10 +81,10 @@ export class TikmateProvider extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { ZodObject } from 'zod';
import {getFetch} from '../fetch';
import {BaseProvider, ExtractedInfo} from './base';
import {matchLink} from './utils';
import type {Shape} from 'ow';
/**
* @class TTDownloader
@@ -61,10 +61,10 @@ export class TTDownloader extends BaseProvider {
}
/**
* Get ow.Shape params.
* @return {Shape | undefined}
* Get zod params
* @return {ZodObject | undefined}
*/
public getParams(): Shape | undefined {
public getParams(): ZodObject | undefined {
return undefined;
}
}

4071
yarn.lock

File diff suppressed because it is too large Load Diff