< Back

【コピーで使える】短縮URLサイト作ったので使ってくれ【自社ホスティング】

はじめに

最近短縮URLの事故とかよく見るので、エンジニアとしてなんだかなあと思っていて、Firebaseで短縮URLサービス作ったので、クローンして使ってほしいです。
よいと思ったらいいね、保存、同僚とかに拡散していただけると。。。:bow:

2023年11月現在、短縮URLを取り巻く現状

フィッシング詐欺に利用された?として世間を騒がせた

現状フリーの短縮URLサイト使って多くの被害事例が出ている状況で、多くの人に見てほしいので拡散されやすそうなタイトルになってます。

今回の先日起きたフィッシング詐欺の問題は短縮URLそのものではなく、短縮URLを運営しているサイトさんが「サーバー費用捻出などのため」などに利用していたGoogle広告にフィッシング詐欺広告が紛れていた、という認識です。

なので今回の件、Google広告が悪いという認識。認識ずれていたらすみません。

私も法人契約で月額数千円の委託料で短縮URLサイト作ってくれと言われたらホスティング作れますが、一般ユーザーが作う急にアクセス過多になったりするサービスを運営するのはちょっと気が引けます。それを加味しても国産の短縮URLサイト運営している人たちはすごいな、、と思います。

短縮URLサイトはどんどん移り変わる

2019年にサービスが終了したgoo.gl

Firebase Dynamic Links は2025 年 8 月 25 日を持って使えなくなる

その他有名な短縮サイト

  • Bitly
    • 英語でいいなら一番古くて安心感ある

今回の記事の動機まとめ

なので直接は関係ないですが、法人で営業しているなら短縮URLのサーバー代金は自社でもちましょう(自社でソースコードのホスティング等しましょうよ!)というのが今回の記事です。

今回の技術は、一日5万クリックとかだと無料で使えるはず(ツイッターに使っても万バズにも耐えられる!)で、超えても100,000クリックあたり$0.06(9円くらい)とかの料金体系になると思います。なので検討の余地はあるかと思います。(Firestoreの料金のみの計算、functions側の計算は別途必要)

以下ソースコードもMITライセンスでGitHub上で公開していきますし、Firestore等使うので料金体系の簡単な説明もします、DNS設定できたら自社ドメインでも運用できます。この記事を見た非エンジニアの方は、自社の社内SEの方やエンジニアの方に一度記事を投げてみてください。検討はしてくれるかもしれません。

MITライセンスで、作成者はコードの責任は取れません。自社内でよく検討のうえ利用してください。ライセンス

お願い

現状の短縮URL界隈にどうにかならないかなあと思っているエンジニアの方は、ぜひレビューお願いしたいです。遠慮なくGitHubでPR等だしてください。イシューください。

もしくはTwitterのDMに連絡ください。

よろしくお願いします。

スクショしてもfirestoreのUI変更されたら追従できないので結構端折っている箇所あったりします。
わかりにくいところは気軽にQiitaへの編集リクエストなど出していただけると助かります。

セキュリティ上気をつける点とかあればQiita上でコメントくだれると、後日見た人が参考になると思うので、ぜひ優しい口調でコメントいただけると助かります。

短縮URLをfirebaseでホスティングできる用にコード書いた

Firebase周り

GoogleのアカウントもっていたらOK

CLIの設定

$ npm install -g firebase-tools
$ firebase login

i  Firebase optionally collects CLI and Emulator Suite usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI and Emulator Suite usage and error reporting information? (Y/n) 

上記メッセージはエラーレポートの話なので Y でも n でもどちらでもいいのでエンター。
Chromeが開きGoogleのサインイン画面になります。使うアカウントでログインしましょう。
FirebaseCLIに許可を与えてください。

プロジェクトの作成

ここからプロジェクトを作ります。

プロジェクトを追加

まずプロジェクトに名前を付けましょう

  • こちらは短縮URLのIDになるので自社名などをつけましょう

スクリーンショット 2023-11-20 13.02.29.png

今回は qiita-link をIDとして設定しました。
これは後ほど https://qiita-link.web.app/ でサイトを作成できます。もちろん自社ドメインとしてDNS設定いじるならここにはこだわる必要ないかもしれません。

アナリティクスの設定

どちらでもよし。後から設定できる。

プロジェクト作成後

リダイレクトされて以下のようなページでいろいろ設定できると思います

スクリーンショット 2023-11-20 13.06.10.png

Firestoreの作成

「構築」→「Cloud Firestore」→「データベースを作成」しましょう。

ロケーションは nam5 一択です。
hostingをrewriteしてfunctionsに飛ばすやりかたはfunctionsがus-central1 のみ対応しているので、firestore側もus-central1に対応するnam5を設定します。

rewriteせずにfucionsだけで使う場合はURL長くなるので短縮・・とは?となります

https://s-xxxxxxxxxxx-uc.a.run.app → https://yourid.web.app

スクリーンショット 2023-11-20 13.22.30.png

次へ行き、 本番環境モードで開始する を選択してデータベースを作成してください。

基本は全員に書き込み、読み込み権限を与えずフロント側からのclient sdkからの取得をすべて塞ぎます。

allow read, write: if false;

リンクの作成等は、後ほどBearer認証でAPI受け口を作ります。

作成したら次はローカルからコードをいじっていきます。

課金体系を変更

functionsを使うにはblazeにする必要があります。
これを設定しないと firebase deployのときに落ちます。

スクリーンショット 2023-11-20 13.41.30.png

課金体系はFirebase公式を参考に、現状はスクショ。

スクリーンショット 2023-11-20 13.44.21.png

今回はFirestoreとCloud Functionsを使います。

Cloud Firestoreは「ドキュメントの読み取り」が一番使う予定ですが「読み取り 5 万件/日までは無料。その後は Google Cloud の料金」です。

Cloud Functionsは「呼び出し」のあたりでしょうか。呼び出しだけなら月に200万件まで無料です。
「GB-秒数」「CPU-秒数」でいくらかは課金される可能性はあります。
Functionsのメモリ割り当てが128MBで、Functions側の処理が重くて8秒かかったとき 0.128GB * 8 = 約1GB秒と計算されます。
これが40万回分無料なので、128MBのfunctionsだと320万秒に実行が無料です。みたいな考え方です、詳細は公式ドキュメントみて計算してみてください。

ちなみに私がメモリ1GBのバッチサーバーを4台並列で1日18時間くらい走らせてたとき今年4月の料金です。重いメモリを使い、時間のかかる処理をすると課金されますが、低メモリで軽い処理をするくらいならGPU時間とかは影響してこない気はしますが、自社で料金プラン等は検討してから導入してください。

スクリーンショット 2023-11-20 13.50.02.png

npm, nodejs環境のインストール

他記事に任せます。
https://nodejs.org/en からLTSバージョンのダウンロードでよい気がします。

ソースコードのダウンロード

https://github.com/ykhirao/short-url からソースコードをダウンロードしてくる。画面から Code → Download Zipgit clone で。

git clone https://github.com/ykhirao/short-url.git

ダウンロードしたレポジトリにterminalから入って、firebaseと接続します。
選択は以下のような感じ。

yk@yk-2 qiita-sample % firebase use --add
? Which project do you want to add? qiita-link
? What alias do you want to use for this project? (e.g. staging) staging

Created alias shortlink for qiita-link.
Now using alias shortlink (qiita-link)

functionsに行ってデプロイします。

$ cd functions
$ npm ci

# たちあがるか検証
$ firebase serve    
(中略)
(http://localhost:5001/qiita-link/us-central1/s).

# ↑立ち上がりが確認できたら、CMD + C か CMD + D でタスクキルする

# 初回はhostingもあるのでこちらでデプロイします
$ firebase deploy

# 次回からは以下でfunctionsだけデプロイでOK
$ npm run deploy
(中略)
Hosting URL: https://qiita-link.web.app

これでもう使えるようになりました!! :tada: :tada: :tada: :tada: :tada:

Hosting URL: https://qiita-link.web.app となっていたURLにアクセスしましょう。

スクリーンショット 2023-11-20 13.58.18.png

こんな感じになっていたらOK。
このページは public/index.html を編集したら自社サイトへのリンクを持ってきたりいろいろできるので必要なかたはいじってください。

あとはfirebaseのコンソールから以下のように設定したらOK

スクリーンショット 2023-11-20 13.59.40.png

この設定の場合は https://qiita-link.web.app/s/1https://www.google.com/ にリダイレクトされたら完成です。qiita-linkは最初に設定した自社のIDなどが入ります。

以下開発者向け

VSCodeのRestClient拡張機能向けツールは設定済みです。
request.http を.gitignoreしているのでコピーして使ってください。

$ cp request.http.sample request.http

以下が参考になります

Admin(トークン作成)

https://qiita-link.web.app/s/init にアクセスするもしくはhttpファイルから叩く

request.http
@local_token = 
@production_token = 
@project_id = qiita-link
@local_url = http://http://127.0.0.1:5002
@production_url = https://{{project_id}}.web.app

### INIT
GET {{production_url}}/s/init

スクリーンショット 2023-11-20 14.05.07.png

そしたらFirebase上に96桁のTokenが生成されているのでそれをコピーして使ってください。

スクリーンショット 2023-11-20 14.06.16.png

@production_token = ここに今作ったトークンをコピペ
@project_id = qiita-link
@production_url = https://{{project_id}}.web.app

### コンテンツ作成
# POST {{production_url}}/s
POST {{production_function_url}}/s
Content-Type: application/json
Authorization: Bearer {{production_token}}

{
    "id": "3",
    "url": "https://www.google.com/",
    "ogps": [
        "<meta property='og:title' content='我々のWebサイトです' />",
    ]
}

そしたら次はここのコンテンツ作成の部分でpostします。

Qiitaのこの記事へのリンクにワンちゃんのOGP付きのリンクを貼りたいときは以下のようなポストをすると使えます。

### コンテンツ作成
POST {{production_url}}/s/links
Content-Type: application/json
Authorization: Bearer {{production_token}}

{
    "id": "6",
    "url": "https://qiita.com/ykhirao/items/b4a15ffbe12a4251ae91",
    "ogps": [
        "<meta property='og:url' content='https://qiita.com/ykhirao/items/b4a15ffbe12a4251ae91' />",
        "<meta property='og:type' content='website' />",
        "<meta property='og:title' content='自社Qiitaへのリンク' />",
        "<meta property='og:description' content='Firebaseで使える短縮URLサイト作ったので使ってほしいという記事です。' />",
        "<meta property='og:site_name' content='Qiita | ブログ' />",
        "<meta name='twitter:card' content='summary_large_image' />",
        "<meta property='og:image' content='https://imgur.com/6xGMXaM.jpg' />"
    ]
}

スクリーンショット 2023-11-20 21.18.53.png

ツイッターに投稿するとこんな感じで表示されます。いい感じですね。
リダイレクト先はQiitaのOGPが設定されているはずですが、リダイレクト元に設定できるようにしているのでいい感じに上書きできると思います。

https://qiita-link.web.app/s/6

以下QiitaとTwitterでのOGPの見え方。

スクリーンショット 2023-11-20 21.25.25.png

スクリーンショット 2023-11-20 21.19.38.png

ちなみにFirebaseはGoogle運営(グーグル合同会社の日本法人)なので、短縮リンク使って詐欺とかやって警察沙汰になったらもちろん情報開示されるので、短縮リンク使っていろいろやるのはやめたほうがいいと思います。qiita-link.web.appもすぐに利用停止にさせられると思います。

Qiitaの下書き中のURLが https://qiita.com/drafts/b4a15ffbe12a4251ae91/edit なので 公開URLは https://qiita.com/ykhirao/items/b4a15ffbe12a4251ae91 として決め打ちしたがあっているかな :thinking:

ローカル開発について

PRとか投げてくださる人はある程度 README.md に書いていますが、以下2つターミナルで実行

$ firebase emulators:start
$ cd functions && npm run build:watch

http://127.0.0.1:4000/ でエミュレーターが立ち上がるので操作します。

スクリーンショット 2023-11-20 21.30.07.png

開発のログとかもここに出ます。

Firestoreのデータもここに格納され、エミュレーター立ち上げるたびにデータの初期化は必要です。

index.tsのソースコード全文
import { onRequest } from "firebase-functions/v2/https";
import * as logger from "firebase-functions/logger";
import * as express from 'express';
import * as admin from 'firebase-admin';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
admin.initializeApp();
const db = admin.firestore();
const LINKS_COL = db.collection('links');
const USERS_COL = db.collection('users');

const {
  NODE_ENV,
  FUNCTIONS_EMULATOR,
  FIREBASE_DEBUG_MODE,
  GCLOUD_PROJECT,
} = process.env;
const isDevelopment = NODE_ENV === 'development' || FUNCTIONS_EMULATOR === 'true' || FIREBASE_DEBUG_MODE === 'true';

const NOT_EXISTS_URL = 'https://google.com'; // リンク切れなど自社のHPなどに飛ばす場合はここを変更
const CACHE_CONTROL_TIME = isDevelopment ? 0 : 300; // 本番は5分間キャッシュさせる
const REDIRECT_TIME = isDevelopment ? 3 : 0; // 本番はリダイレクトまで0秒
const BASE_URL = isDevelopment ? `http://localhost:5002` : `https://${GCLOUD_PROJECT}.web.app`;

type LinkDocType = {
  url?: string;
  ogps?: string[];
}

/** Adminユーザーの初期化 */
const initAdminFunc = async (_req: express.Request, res: express.Response) => {
  const adminDoc = USERS_COL.doc('admin');
  const user = await adminDoc.get();
  if (user.exists) {
    res.send('Already initialized');
  } else {
    // Tokenの強さはお好みで変更お願いします。
    // Link作成時にヘッダーの `Authorization: Bearer <token>` で認証する
    const token = require('crypto').randomBytes(48).toString('hex');
    await adminDoc.set({ name: 'admin', token });
    res.send('Initialized!');
  }
}

/** リダイレクト処理 */
const redirectFunc = async (req: express.Request, res: express.Response): Promise<any> => {
  const id = req.params.id || '0';
  const record = await LINKS_COL.doc(id).get();
  const data = { id: record.id, ...record.data() } as LinkDocType;

  const url = data?.url || NOT_EXISTS_URL;
  if (!url) return res.status(404).send('Not Found');

  const ogps = (data?.ogps || []).map(ogp => ogp).join('\n');

  // レスポンスは5分間キャッシュさせる
  res.set("Cache-Control", `public, max-age=${CACHE_CONTROL_TIME}`)
  res.send(`
  <!DOCTYPE html>
  <html lang="ja">
  <head prefix="og: https://ogp.me/ns#">
    <title>Redirect...</title>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="${REDIRECT_TIME};URL='${url}'" />
    <meta name="robots" content="noindex" />
    ${ogps}
  </head>
  <body>
    <!--
      <noscript>
        Redirect: <a href="${url}"> ${url} </a>
      </noscript>
      <script>
        window.location = "${url}";
      </script>
    -->
  </body>
  </html>
  `);
}

/** Link作成API */
const createFunc = async (req: express.Request, res: express.Response): Promise<any> => {
  logger.info(req.body, { structuredData: true });

  // Adminが作成されてなければエラー
  const admin = await USERS_COL.doc('admin').get();
  if (!admin.exists) return res.status(404).send('Not Found: Admin user is not initialized');

  // Bearer Tokenがなければ認証エラー
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (token == null) return res.status(401).send('Unauthorized: token is required');

  // Bodyのパース等
  const { id: reqId, url, ogps = [] } = req.body;

  // URLの存在チェック
  if (url == null) return res.status(400).send('Bad Request: url is required');

  const id = reqId || Math.random().toString(32).substring(2);

  // AdminのTokenとBearer Tokenが同じでなければエラー
  const adminToken = admin.data()?.token;
  if (token !== adminToken) return res.status(401).send(`Unauthorized: token is not valid`);

  const ref = LINKS_COL.doc(id);
  const record = await ref.get();

  // 該当IDのレコードがすでに存在していればエラー
  if (record.exists) return res.status(403).send(`Already exists: ${id}`);

  await ref.set({
    id,
    url,
    ogps,
  });

  res.status(200).send(`OK, created ${id}: ${BASE_URL}/s/${id} `);
}

// Hoting用のルーティング
// http://127.0.0.1:5002/s
app.get('/s', (_req, res) => res.send(404));
app.get('/s/users/init', initAdminFunc);
app.post('/s/users/init', initAdminFunc);
app.post('/s/links', createFunc);
app.get('/s/:id', redirectFunc);

// Functions側のルーティング, /s をrootとする
// http://127.0.0.1:5001/{_YOUR PROJECT_ID_}/us-central1/s → app.get('/', ()=>{})
// app.get('/', (_req, res) => res.send(404));

export const s = onRequest(app);

Qiita用に1ファイルに書いてますが、普通にfunctionごとに別のファイルにしてよいと思います。
開発進んだら私もわけると思います。

NOT_EXISTS_URL は自社ように置き換えて使ってください。

130行くらいなのでさくっと読んでくれたらわかると思いますが、以下3つの関数くらいしかないので読めると思います。

const initAdminFunc = () => {''}
const redirectFunc = () => {''}
const createFunc = () => {''}

firebase.jsonはrewriteのところが重要かと思いますが、ほとんどのケースでこのまま使えるはずです。

index.htmlを書き換えて使ったりいろいろできるとは思います。
ここにReactのアプリをいれて、ログインして編集できるアドミン画面を作る〜〜等。

firestore.rules は、Firestoreのclient sdkからの書き込みを if false にしているので色々設定しなおしてください。

.firebaserc は人によって変わるので.gitignoreでコミットしないようにしてます。
CIでマージされたときにデプロイしたい、とかある人はコミット外してください。