短縮 URL 機能の設計
画面右上のリンクコピーボタンでコピーされる URL には、ビューの設定が JSON のままクエリに入るため、数百文字になることがあります。このページは、ボタンを押したときに短縮 URL(https://{host}/s/{code})を発行してコピーする機能を追加する本体改修の設計メモです。本体の標準機能ではありません。 本体を改修せずに短縮 URL を扱う方法は URL で既定値を渡す・短縮 URL を参照してください。
| 要件 | 内容 | 優先度 |
|---|---|---|
| リンクコピーボタンとの統合 | ボタンを押すと短縮 URL を発行してクリップボードに入れる | 主 |
| コントローラの追加 | リダイレクト用と管理用 | 主 |
| 有効期限 | 無期限・期限付きを選べる。期限付きの既定日数はパラメータで持つ | 主 |
| 期限切れの削除 | バックグラウンドのタイマーで定期的に削除する | 主 |
| 任意の URL | サーバースクリプトや API から任意の URL を短縮できる | 副 |
前提にした現行実装(1.5.8.1)
リンクコピーボタン
パンくずの右に出るボタン(#CopyToClipboards の中の #CopyDirectUrlToClipboard)は、サーバー側で HTML を作るときに URL を確定し、onclick に文字列として埋め込みます(HtmlBreadcrumb.cs)。
- URL は現在のクエリに
View(ビューの JSON)を足したもの。gridrowsのアクションはindexに置き換え、ポート 80 / 443 は省く(HtmlBreadcrumb.cs) - ブラウザ側の
$p.copyDirectUrlToClipboardは、画面外に置いた要素を選択してdocument.execCommand('copy')でコピーし、alertで知らせるだけで、サーバーとは通信しない(clipboard.js) - ボタンはサイト・レコードの画面のほか、組織・グループ・ユーザー管理の画面などにも出る
ルートとコントローラ
MVC のコントローラは Controllers/、API は Controllers/Api/([Route("api/[controller]")])にあります。ルートは FormBinaries → Default({controller}/{action})→ Others → Item({controller}/{id}/{action})→ Binaries({controller}/{guid}/{action})の順に登録されています(Startup.cs)。/s/{code} はこのままだと Default ルート(コントローラ s、アクション {code})に一致してしまうので、専用ルートを先に登録します。
バックグラウンドのタイマー
定期処理は Quartz.NET のジョブで、各タイマーの Param(IExecutionTimerBaseParam)が有効フラグ・ジョブのキー・実行時刻の一覧を返します(IExecutionTimerBaseParam.cs)。
- 実行時刻の一覧は
"02:00"のような HH:mm の文字列で、Service.TimeZoneDefault(未設定なら UTC)のタイムゾーンの毎日その時刻に動く cron トリガーに変換される(TimerBackground.cs) - タイマーは
TimerBackgroundの一覧に並べて登録する(TimerBackground.cs) - 一覧全体は
BackgroundService.TimerEnabledがtrueのときだけ動き、この判定は既存のタイマーの有効フラグの OR(BackgroundService.cs)。新しいタイマーだけを有効にしても動かないので、ここにも条件を足す
URL と短縮コード
https://{host}/s/{code}| 方式 | 推測されにくさ | 衝突 | 採用 |
|---|---|---|---|
| 暗号論的乱数の Base62(8 文字) | 高い | 再生成で回避 | 推奨 |
| SHA-256 の先頭 N 文字 | 低い(元 URL から計算できる) | あり得る | ― |
| 連番の Base62 | 低い | なし | ― |
Base62 の 8 文字は 62^8(約 218 兆)通りです。
private static string GenerateCode(int length = 8)
{
const string chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
return RandomNumberGenerator.GetString(chars, length);
}接頭辞は /s/(短い)、/go/(用途が分かりやすい)などが候補です。
テーブル
図を読み込み中…
| インデックス | 列 | 用途 |
|---|---|---|
| 主キー | TenantId, Code | |
| 一意 | Code | リダイレクト時の検索 |
| 非一意 | ExpiresAt | 期限切れの削除 |
| 非一意 | TenantId, SiteId | サイトごとの一覧 |
| 非一意 | TenantId, Url, SiteId | 重複登録の確認 |
テーブルは CodeDefiner の定義(App_Data/Definitions/)に追加し、SQL Server・PostgreSQL・MySQL のどれでも動くよう Rds の抽象化を使います。
コントローラ
図を読み込み中…
endpoints.MapControllerRoute(
name: "ShortenedUrls",
pattern: "s/{code}",
defaults: new { Controller = "ShortenedUrls", Action = "Redirect" },
constraints: new { code = "[A-Za-z0-9]{1,16}" });リダイレクトは匿名で受け付け、コードが見つからないか期限切れなら 404、有効ならアクセス回数と最終アクセス日時を更新して 302 で転送します。作成・取得・削除の API は既存の API と同じく POST で、認証を必須にします。
| API | 本文・応答 |
|---|---|
POST /api/shortenedurls/create | Url(必須)、SiteId、Permanent、ExpiresAt → Code、ShortUrl、期限 |
POST /api/shortenedurls/{code}/get | Url、AccessCount、LastAccessedTime など |
POST /api/shortenedurls/{code}/delete | 削除 |
{
"StatusCode": 200,
"Response": {
"Code": "aB3xK9mQ",
"Url": "https://pleasanter.example.com/items/12345/index?View=...",
"ShortUrl": "https://pleasanter.example.com/s/aB3xK9mQ",
"Permanent": false,
"ExpiresAt": "2027-03-02T00:00:00"
}
}パラメータ
{
"Enabled": false,
"DefaultExpirationDays": 365,
"CodeLength": 8,
"MaxUrlLength": 2048,
"MaxPerSite": 1000,
"MaxPerTenant": 100000,
"AllowedDomains": [],
"ShowPreview": false
}| パラメータ | 説明 |
|---|---|
Enabled | 機能の有効・無効 |
DefaultExpirationDays | 期限付きのときの既定日数 |
CodeLength | 短縮コードの長さ |
MaxUrlLength | 登録できる URL の最大長 |
MaxPerSite / MaxPerTenant | 登録数の上限 |
AllowedDomains | 転送先を許すドメイン(空なら制限なし) |
ShowPreview | 転送前に転送先を表示する確認画面を挟む |
期限切れの削除は BackgroundService に DeleteExpiredShortenedUrls(有効フラグ)と DeleteExpiredShortenedUrlsTime(例: ["03:00"])を追加します。
期限切れの削除タイマー
既存の DeleteTrashBoxTimer(DeleteTrashBoxTimer.cs)と同じ形で作り、TimerBackground の一覧と TimerEnabled の条件に追加します。
public class DeleteExpiredShortenedUrlsTimer : ClusterExecutionTimerBase
{
public class Param : IExecutionTimerBaseParam
{
public static readonly JobKey jobKey = new JobKey("DeleteExpiredShortenedUrlsTimer", "ExecutionTimerBase");
public Type JobType => typeof(DeleteExpiredShortenedUrlsTimer);
public IEnumerable<string> TimeList => Parameters.BackgroundService.DeleteExpiredShortenedUrlsTime;
public bool Enabled => Parameters.BackgroundService.DeleteExpiredShortenedUrls;
public JobKey JobKey => jobKey;
public string JobName => "DeleteExpiredShortenedUrlsService";
public Task<bool> SetCustomTimer(IScheduler scheduler) => Task.FromResult(false);
}
public override async Task Execute(IJobExecutionContext jobContext)
{
// Permanent = false かつ ExpiresAt < 現在時刻 を物理削除し、SysLogs に件数を残す
await Task.CompletedTask;
}
}ClusterExecutionTimerBase は中身の無い抽象クラス(ClusterExecutionTimerBase.cs)です。複数台構成で多重に動かさないためには Quartz のクラスタリング設定と合わせて検討します。
リンクコピーボタンとの統合
| 方針 | 内容 |
|---|---|
| 置き換え | 操作はそのまま(ボタン 1 回)。中で短縮 URL を発行する |
| Shift + クリック | 短縮せず、従来の長い URL をコピーする |
| 重複の排除 | 同じ URL には同じ短縮コードを返すので、2 回目からは検索だけで済む |
| フォールバック | 機能が無効、または API がエラーのときは従来の URL をコピーする |
サーバー側は、機能が有効なときだけ onclick の呼び出し先を新しい関数に変え、event とサイト ID を渡します。
var directUrl = DirectUrl(context: context, view: view);
var onClick = Parameters.ShortenedUrl?.Enabled == true
? $"$p.copyShortenedUrlToClipboard(event, '{directUrl}', {context.SiteId});"
: $"$p.copyDirectUrlToClipboard('{directUrl}');";ブラウザ側は既存の $p.copyDirectUrlToClipboard を残し、新しい関数を足します。API の呼び出しには既存の $p.apiExec(POST、画面の #Token を付ける。_api.js)を使えます。
$p.copyShortenedUrlToClipboard = function (event, directUrl, siteId) {
if (event && event.shiftKey) {
$p.copyDirectUrlToClipboard(directUrl);
return;
}
$p.apiExec($('#ApplicationPath').val() + 'api/shortenedurls/create', {
data: { Url: directUrl, SiteId: siteId, Permanent: false },
done: function (data) {
var shortUrl = data && data.Response && data.Response.ShortUrl;
if (!shortUrl || !navigator.clipboard) {
$p.copyDirectUrlToClipboard(directUrl);
return;
}
navigator.clipboard.writeText(shortUrl).then(function () {
alert($p.display('ShortenedUrlCopied'));
});
},
fail: function () {
$p.copyDirectUrlToClipboard(directUrl);
}
});
};navigator.clipboard は HTTPS(安全なコンテキスト)でしか使えないため、無い場合も従来の方法に戻します。表示文字列 ShortenedUrlCopied は App_Data/Displays/ に追加します。
図を読み込み中…
同じ URL の重複登録
同じテナント・同じ URL・同じサイト ID の組み合わせは、新しく作らず既存のコードを返します(DB の肥大化を防ぎ、アクセス数も 1 つにまとまる)。
| 既存 | 今回の要求 | 動作 |
|---|---|---|
| 無期限 | 期限付き | 既存をそのまま返す |
| 期限付き | 無期限 | 既存を無期限に変えて返す |
| 期限付き | 期限付き(期限が違う) | 長いほうの期限に更新して返す |
| 期限切れ | ― | 既存を消して新しく作る |
セキュリティ
| 脅威 | 対策 |
|---|---|
| オープンリダイレクト | 転送先は http / https だけ許す。必要なら AllowedDomains で絞る |
javascript: などのスキーム | 拒否する |
| 内部ネットワークへの転送 | ループバック・プライベート IP・リンクローカルを拒否する |
| コードの推測・列挙 | 暗号論的乱数で十分な長さにし、リダイレクトにもレート制限をかける |
| 大量作成 | 作成は認証必須、テナント・サイトごとの上限 |
| 不正な URL | 最大長、Uri.TryCreate での検証 |
| リファラーからの漏洩 | 転送の応答に Referrer-Policy: no-referrer を付ける |
| フィッシング | 外部公開するなら ShowPreview で確認画面を挟む |
| コードの再利用 | 期限切れで消したコードは別の URL に割り当てない |
| 追跡 | 作成・削除・転送を SysLogs に残す |
private static bool IsValidUrl(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return false;
if (IPAddress.TryParse(uri.Host, out var ip))
{
if (IPAddress.IsLoopback(ip)) return false;
var b = ip.GetAddressBytes();
if (b.Length == 4
&& (b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168)
|| (b[0] == 169 && b[1] == 254)))
{
return false;
}
}
return true;
}サーバースクリプトから使う(副)
ホストオブジェクト shortenedUrls を追加し、任意の URL を短縮できるようにします。
var result = shortenedUrls.Create({
url: 'https://external.example.com/document/123',
permanent: true
});
// result.Code, result.ShortUrl
var info = shortenedUrls.Get(result.Code);
shortenedUrls.Delete(result.Code);そのほかの注意点
| 項目 | 内容 |
|---|---|
| 拡張サーバースクリプト | コントローラ名 shortenedurls で条件を書けるようにする |
| テナント | 転送時はコードからテナントを引くので、ログインは要らない |
| キャッシュ | よく使われるコードはメモリにキャッシュする |