Build a shortened URL application with NextJS + Firebase

nqhd3v

Huy Nguyen

Posted on January 21, 2023

Build a shortened URL application with NextJS + Firebase

Hello everyone, have you ever shortened a link and sent it to your friends? It’s really easy to use, simple, short, easy to remember, and easy to share,…

But, is it safe? In this post, I will share my idea about how to create a shortened URL application, security issues, and my solutions to solve them.

First, let’s build it.

In this post, I will share how to create features, and how it works. I will skip the init steps, below is some tech I use in this project.

  • NextJS (ReactJS framework, read more here)
  • Firestore
  • Tailwindcss
  • and some supported libraries likes axios, crypt-js,…

Create & Access shortened URL flow

Create a shortened link

In my flow, with a long link input from users, when they input to my app, that will be mapped 1–1 with a random code and saved to Firestore.

Below is shortened link model:

// utils/types/models.ts
import { Timestamp } from "firebase/firestore";

export type tShortenLink = {
  shortId: string; // Random code
  longLink: string;
  passCode: string | null;
  availableUntil: Timestamp | null;
}


export type tShortenLinkD2O = {
  longLink: string;   // Long-link need to be shortened
  passCode?: string;  // Passcode to access this link
  expiredTime?: Date; // Time to not accessible this link
}
Enter fullscreen mode Exit fullscreen mode

In create form, I have an input element, to allow the user to input their long link. Below is ShortenURLCreate component

// component/ShortenLink/ShortenURLCreate.tsx

const ShortenURLCreate: React.FC = () => {
  const [linkOutput, setLinkOutput] = useState<string>('');
  const [longLink, setLongLink] = useState<string>('');
  const [passCode, setPassCode] = useState<string>('');
  const [expireTime, setExpireTime] = useState<Date | null>(null);
  const [linkSafeState, setLinkSafeState] = useState<boolean | undefined>(undefined);
  const [linkSafeCheck, setLinkSafeCheck] = useState<boolean>(false);
  const [linkCreating, setLinkCreating] = useState<boolean>(false);

  const handleCheckLinkSafe = async () => {
    if (!URL_REGEX.test(longLink)) {
      setLongLink('');
      return;
    }
    setLinkSafeCheck(true);
    try {
      const res = await axios.post('/api/safecheck', { url: longLink });
      const { isSafe } = res.data;
      setLinkSafeState(isSafe);
      if (isSafe === true) {
        setLinkSafeCheck(false);
        return true;
      }
    } catch (err) {
      console.error('Error when checking link is safe:', longLink, err);
    }
    setLongLink('');
    setLinkSafeCheck(false);
    return false;
  }

  const handlePassCodeBlur = async () => {
    await handleCheckLinkSafe();
  }

  const handleCreateLink = async () => {
    // Re-check
    setLinkCreating(true);
    const isPassed = await handleCheckLinkSafe();
    if (!isPassed) {
      setLinkCreating(false);
      return;
    }
    try {
      const res = await createLink({
        longLink,
        passCode,
        expiredTime: expireTime || undefined,
      });
      if (res.isError) {
        return;
      }
      if (res.data && res.data.data) {
        const shortenLink = `https://a.nqhuy.dev/l/${res.data.data.shortId}`;
        navigator.clipboard.writeText(shortenLink);
        setLinkOutput(shortenLink);
      }
    } catch (err) {
      console.error('Error when creating shorten link:', [longLink, passCode, expireTime], err);
    }
    setLinkCreating(false);
  }

  const linkSafeIcon = () => {
    if (linkSafeCheck) {
      return <i className="fa-solid fa-spinner fa-spin" />;
    }
    if (linkSafeState === undefined) {
      return undefined;
    }
    if (linkSafeState) {
      return <i className="fa-solid fa-check" />
    }
    return <i className="fa-solid fa-times" />;
  }

  return (
    <div className="rounded-md shadow border bg-light dark:bg-dark dark:border-light p-5 mb-5">
      <h2 className="text-2xl code font-bold">
        <span className="var">url</span>
        {"."}
        <span className="func">create</span>
        {"();"}
      </h2>
      <div className="code comment mb-5">
        Input your link, customize pass-code or expire-time, and click &apos;Create&apos; button to create your shorten link.
      </div>
      <div className="code comment">-- Input your link here</div>
      <Input
        placeholder="long_link ="
        value={longLink}
        onChange={v => setLongLink(v)}
        onBlur={() => handlePassCodeBlur()}
        appendIcon={linkSafeIcon()}
        disabled={linkSafeCheck}
      />

      <div className="code comment">-- Input your pass-code here (disabled)</div>
      <Input
        placeholder="pass_code ="
        value={passCode}
        onChange={v => setPassCode(v)}
      />
      <div className="code comment mb-3">Your pass-code can only contain letters and numbers</div>

      <div className="code comment">-- Input your time to expire your link here (optional)</div>
      <ReactDatePicker
        selected={expireTime}
        onChange={d => setExpireTime(d)}
        minDate={moment().add(1, 'd').startOf('d').toDate()}
        dateFormat="dd/MM/yyyy"
        placeholderText="dd/mm/yyyy"
      />
      <div className="code comment mb-3">empty if no expire this link</div>

      <button
        className="run"
        onClick={() => handleCreateLink()}
        disabled={linkCreating}
      >
        {linkCreating ? 'creating...' : 'create'}
      </button>

      {linkOutput ? (
        <div className="mt-5">
          <div className="code comment">Your shorten link here (Copied)</div>
          <code>{linkOutput}</code>
        </div>
      ) : null}
    </div>
  )
}

export default ShortenURLCreate;
Enter fullscreen mode Exit fullscreen mode

Skip handleCheckLinkSafe function, I’ll talk about it at the end.

And createLink function:

// utils/Firebase/service/shortenLinks.ts

export const createLink = async ({
  longLink,
  passCode,
  expiredTime
}: tShortenLinkD2O): Promise<tFirestoreQueryItemData<tDataTransformed<tShortenLink>>> => {
  try {
    const shortLinkData: tShortenLink = {
      shortId: randomShortenLinkId(),
      longLink,
      passCode: null,
      availableUntil: expiredTime ? date2FsTimestamp(expiredTime) : null,
    }
    if (passCode) {
      // If user input passCode -> encrypt link with cipher `${passCode}+${randomCipher}`.
      const randomCipher = randomPassCode();
      const longLinkEncrypted = encryptAES({ link: longLink }, [passCode, randomCipher]);
      shortLinkData.passCode = randomCipher;
      shortLinkData.longLink = longLinkEncrypted;
    }
    const res = await fsAdd<tShortenLink>(shortLinkData, ROOT_COLLECTION_KEY);
    if (!res) {
      return {
        isError: true,
        messageId: 'shortenLink.unknown'
      };
    }
    return {
      data: res,
    };
  } catch (err: any) {
    console.error('Error when creating a shorten link:', [longLink], err);
    return {
      isError: true,
      messageId: 'shortenLink.unknown'
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In my function, you’ll see fsAdd function, that is a firebase’s function addDoc() .

So, the shortened link was created, how to access it?
In my flow, when the user access to the shortened link, ex: https://a.nqhuy.dev/l/:linkId , we will check linkId ( shortId field).

Below is my [linkId].tsx component.

const ERROR_MAPPING: Record<string, string> = {
  'shortenLink.unknown': 'Unknown error when try to get your link!',
  'shortenLink.require-passCode': 'Input your pass-code to open your link!',
  'shortenLink.notfound': 'Your link is expired or not existed!',
  'cipher.input-invalid': 'Your pass-code is wrong!',
  'cipher.invalid-word-array': 'Your pass-code is wrong!',
};

const Wrapper: React.FC<{
  icon: string,
  children: null | string | JSX.Element | (null | JSX.Element)[]
}> = ({ icon, children }) => {
  return (
    <div className="w-full h-screen flex justify-center items-center">
      <div className="w-[400px] rounded-md shadow border border-light p-5 text-center">
        <i className={`${icon} text-2xl`} />
        <div className="pt-4 text-lg">
          {children}
        </div>
      </div>
    </div>
  )
}

const OpenShortenLink = () => {
  const router = useRouter();
  const [loading, setLoading] = useState<boolean>(true);
  const [data, setData] = useState<tShortenLink | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);

  const linkId: string | undefined = useMemo(() => (router.isReady && router.query.linkId as string) || undefined, [router.isReady, router.query.linkId]);

  const handleGetLinkData = async (id: string, passCode?: string) => {
    setLoading(true);
    try {
      const linkData = await getLinkById(id, passCode);
      if (linkData.isError) {
        setError(linkData.errorMessageId);
        setLoading(false);
        return;
      }
      if (!linkData.data) {
        setError("shortenLink.unknown");
        setData(undefined);
        return;
      }
      setError(undefined);
      setData(linkData.data.data);
      window.location.replace(linkData.data.data.longLink);
    } catch (err) {
      console.error('Error when getting link:', err);
    }
    setLoading(false);
  }

  useEffect(() => {
    if (!linkId || !(/[a-zA-Z0-9]{6}/.test(linkId))) {
      setLoading(false);
      setError("shortenLink.input-invalid");
      return;
    }
    handleGetLinkData(linkId);
  }, [router.isReady]);

  if (loading) {
    return (
      <Wrapper icon="fa-solid fa-spinner fa-spin">Checking your link...</Wrapper>
    )
  }

  if (error) {
    const passCodeInput = !!([
      "shortenLink.require-passCode",
      "cipher.input-invalid",
      "cipher.invalid-world-array"
    ].find(m => m === error))
      ? (
        <div className="text-left">
          <div className="code comment mt-5">
            Input your pass-code here:
          </div>
          <InputWithButton
            placeholder="pass_code ="
            onSubmit={(v) => linkId ? handleGetLinkData(linkId, v) : null}
          />
        </div>
      )
      : null;
    return (
      <Wrapper icon="fa-solid fa-triangle-exclamation">
        <div>{ERROR_MAPPING[error] || ERROR_MAPPING["shortenLink.unknown"]}</div>
        {passCodeInput}
      </Wrapper>
    )
  }

  if (!data) {
    return (
      <Wrapper icon="fa-solid fa-triangle-exclamation">
        <div>{ERROR_MAPPING["shortenLink.unknown"]}</div>
      </Wrapper>
    )
  }

  return (
    <Wrapper icon="fa-solid fa-check">
      <div>Your link is opening...</div>
      <div className="text-sm">
        {"(not open? "}
        <a href={data.longLink} rel="noreferrer">click here!</a>
        {")"}
      </div>
    </Wrapper>
  )
};

export default OpenShortenLink;
Enter fullscreen mode Exit fullscreen mode

My getLinkById function:

// utils/Firebase/service/shortenLinks.ts

const ROOT_COLLECTION_KEY = "shorten_links";

export const getLinkById = async (shortId: string, passCode?: string): Promise<tFirestoreQueryItemData<tDataTransformed<tShortenLink>>> => {
  try {
    // Find with shortId with `availableUntil` is NULL
    const shortenLinkWithNullAvailable = await fsReadWithCond<tShortenLink>(
      [
        where('shortId', '==', shortId),
        where('availableUntil', '==', null),
      ],
      ROOT_COLLECTION_KEY,
    );
    const shortenLinkWithAvailableUntil = await fsReadWithCond<tShortenLink>(
      [ where('shortId', '==', shortId), where('availableUntil', '>=', date2FsTimestamp()) ],
      ROOT_COLLECTION_KEY,
    );
    const shortenLinkTransformed =
      firstDataTransformedItem<tShortenLink>(shortenLinkWithNullAvailable) ||
      firstDataTransformedItem<tShortenLink>(shortenLinkWithAvailableUntil);

    if (!shortenLinkTransformed) {
      return error();
    }

    const { data } = shortenLinkTransformed;

    if (data.passCode && !passCode) {
      // If data has passCode and user not input passCode
      return error('require-passCode');
    }

    if (passCode && data.passCode) {
      const linkNeedOpen = decryptAES(data.longLink, [passCode, data.passCode]);
      if (linkNeedOpen.isError) {
        return {
          isError: true,
          errorMessageId: linkNeedOpen.error,
        };
      }
      if (!linkNeedOpen.data) {
        return {
          isError: true,
          errorMessageId: 'exception.sample.data-empty',
        };
      }
      shortenLinkTransformed.data.longLink = linkNeedOpen.data.link as string;
    }

    return { data: shortenLinkTransformed };
  } catch (err: any) {
    console.error(`Error when getting link by ID (${shortId}):`, err);
    return {
      isError: true,
      errorMessageId: 'sample.unknown-error',
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s talk about some issues when using a shortened link.

Of course, shortened links are simpler, easier to use, and easier to share than unminified links. But, when using a shortened link, the user will not know which website they are visiting. And that will make your application unwittingly support hackers to hide phishing links.

In my flow, you will see [...] box. This box is used to check your link, whether is safe for the user or not. You can put this box in the create flow or access flow, but it corresponds to 2 different purposes. If you put it in the create flow like me, you will prevent hackers from creating fake links. If you put it in the access flow, you will warn users when the link was reported to be fake (of course, hackers can still create phishing links).

See in handleCheckLinkSafe , it calls an API to api/safecheck , below is my API handler:

const CHONGLUADAO_SAFECHECK_API = 'https://api.chongluadao.vn/v1/safecheck';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<{ isSafe: boolean } | tApiError>
) {
  if (req.method === "POST") {
    try {
      const { url } = req.body;
      if (!url) {
        throw new Error('Unknown URL');
      }
      const data = await axios.post(CHONGLUADAO_SAFECHECK_API, { url })
      res.status(200).json({ isSafe: data.data.type !== "unsafe" });
    } catch (err: any) {
      res.status(400).json({
        isError: true,
        errorData: err.message,
      });
    }
  } else {
    res.status(404).json({
      isError: true,
      errorData: 'API not exist to handle your request!'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

If you have a question about API: https://api.chongluadao.vn/... . This is a project was created by HieuPC and his friends to protect people of my country from phishing websites. You can read more about it here. (Maybe it just only support Vietnamese :) )

I don’t know if there is such a project in your country, if possible, comment below so I can improve my project.

Thank you for reading my first post.

💖 💪 🙅 🚩
nqhd3v
Huy Nguyen

Posted on January 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related