$ps.file と添付ファイル
このページでは、サーバースクリプトでファイルを扱う方法を 3 つの話題でまとめます。
modelの添付ファイル項目は JSON 文字列なので、まとめてJSON.parseしておくと扱いやすくなります。$ps.fileには 1.4.19.0 でcopyFileが追加されました。$ps.fileからネットワーク共有(UNC パス、ネットワークドライブ)は直接操作できません。共有への反映は別プロセスに任せる構成にします。
対象バージョン
$ps.file のメソッドとパス検証は 1.5.8.1 のソースで確認しています。$ps.file を使うには、Script.json の DisableServerScriptFile(既定値 true)を false にし、ServerScriptFilePath(既定値 null)にルートのパスを設定します(Script.json、ServerScriptUtilities.cs)。
添付ファイル項目をまとめてパースする
model の添付ファイル項目には、次のような JSON 文字列がそのまま入っています。
[{"Guid":"47BFA611B94B4703865019110F4B3DFD","Name":"ED-038-datasheet.pdf","Size":1520972,"Deleted":true,"HashCode":"23pz2IFY4jenPCGgU7L2ci8Z/Uth2eReoBer8Sm+wVQ="}]毎回 JSON.parse するのは手間なので、Attachments で始まるプロパティだけをパースして model_attachments に入れておきます。条件は「画面表示の前」や「行表示の前」など、model にアクセスできる条件であればどこでも使えます。
const regex = new RegExp('^Attachments');
let model_attachments = {};
for (const [key, value] of Object.entries(model)) {
if (regex.test(key)) {
model_attachments[key] = JSON.parse(value || '[]');
}
}AttachmentsA〜AttachmentsZ(Enterprise Edition の項目拡張を使っている場合は Attachments001〜 も)のうち、画面に表示されているもの(= model に含まれているもの)だけが対象になります。表示されていない項目も扱いたい場合は、view.AlwaysGetColumns で対象を追加すればそのまま使えます。
拡張サーバースクリプトにする
全サイトで使う場合は拡張サーバースクリプトにします。条件は必要に応じて追加・変更してください。
{
"Name": "modelの添付ファイルのJSONをオブジェクトに変換する",
"BeforeOpeningPage": true,
"BeforeOpeningRow": true,
"Body": "// Write an arbitrary script."
}let model_attachments = {};
let success_parse = false;
//エラーが出ると面倒なのでtry-catchで囲っておく
try {
const regex = new RegExp('^Attachments');
for (const [key, value] of Object.entries(model)) {
if (regex.test(key)) {
model_attachments[key] = JSON.parse(value || '[]');
}
}
success_parse = true;
} catch(e) {
success_parse = false;
context.Error(e.stack);
}拡張サーバースクリプトでエラーが出ると処理全体が止まるため、try-catch で囲み、成否を success_parse に入れています。catch で受け取る JavaScript のエラーオブジェクトのスタックトレースは小文字の e.stack です(e.Stack と書くと undefined になり、エラーメッセージが空になります)。context.Error() はメッセージを受け取ってエラーを設定するメソッドです(ServerScriptModelContext.cs)。
添付ファイルの中身(バイナリ)を読む
サーバースクリプトには添付ファイルの中身を読む API がありません($ps.file はテキストファイルを読む readAllText などを持ちますが、対象は ServerScriptFilePath 配下のファイルに限られ、バイナリを読むメソッドはありません。また $ps.file 自体が既定で無効です)。実体は Binaries テーブルにあるので、拡張 SQL で取り出します。
SELECT TOP (1)
b.[FileName], b.[ContentType], b.[Size],
CAST(N'' AS xml).value('xs:base64Binary(sql:column("b.Bin"))', 'varchar(max)') AS [Base64]
FROM [Binaries] b
WHERE b.[ReferenceId] = @ReferenceId
AND b.[BinaryType] = 'Attachments'
ORDER BY b.[BinaryId] DESC;select "FileName", "ContentType", "Size", encode("Bin", 'base64') as "Base64"
from "Binaries"
where "ReferenceId" = @ReferenceId
and "BinaryType" = 'Attachments'
order by "BinaryId" desc
limit 1select `FileName`, `ContentType`, `Size`, TO_BASE64(`Bin`) as `Base64`
from `Binaries`
where `ReferenceId` = @ReferenceId
and `BinaryType` = 'Attachments'
order by `BinaryId` desc
limit 1BinaryType は添付のしかたで変わります。
| 添付のしかた | BinaryType |
|---|---|
| 添付ファイル項目にドロップ | Attachments |
| 長文項目(Markdown)に画像を貼り付け | Images |
WARNING
BinaryStorage.json の Provider が Rds(既定)の場合の話です。ファイルシステムや Azure Blob に保存している環境では Bin が空になります。また、サーバースクリプトから呼ぶ拡張 SQL には "Api": true が必要です(httpClient と外部 API 呼び出しの落とし穴 を参照)。
$ps.file.copyFile 1.4.19.0 以降
$ps.file は、特定のフォルダ内のファイル・ディレクトリを操作するサーバースクリプトのメソッド群です。移動・削除・作成はできましたが、コピーができなかったため、1.4.19.0(2025/8/12 リリース)で $ps.file.copyFile が追加されました。$ps.file.moveFile をベースに実装されているので、使い方もよく似ています。
$ps.file.copyFile(section, source_path, dest_path) : bool| 引数 | 説明 |
|---|---|
section | セクション名 |
source_path | コピー元のファイル名 |
dest_path | コピー先のファイル名 |
| 戻り値 | 成功したら true |
次の場合は false が返ります(ServerScriptFile.cs)。
- コピー元のファイルが存在しない
- コピー先に既にファイルが存在する
コピー先のフォルダが無い場合は自動で作成されます。パスの検証エラーやコピー中の例外は false ではなく、JavaScript の例外として投げられます($ps.file の各メソッドは C# 側の例外を $ps._utils._f0 で Error に変換して投げ直します。ServerScriptJsLibraries.cs)。
$ps.file とネットワーク共有
結論
Windows 版の標準的な構成では、$ps.file から別サーバーの共有フォルダを直接操作することはできません。
- ネットワークドライブ(
Z:など)はプリザンターのプロセスから見えないことがあります。 - UNC パス(
\\fileserver\share)は$ps.fileのパス検証で明確に拒否されます。 - 実行アカウントに共有アクセス権・NTFS アクセス権を付けても解決しません。
ネットワークドライブが見えない理由
エクスプローラーで割り当てた Z: などのネットワークドライブは、割り当てたユーザーのログオンセッションに属します。IIS のアプリケーションプールや Windows サービスとして動くプリザンターは、通常、対話ログオン中のユーザーとは別のアカウント・別のセッションで動くため、管理者が見えている Z: を Script.json に設定してもプリザンターからは見えないことがあります。これはプリザンター固有ではなく、Windows サービスからマップ済みドライブを参照するときの一般的な問題です。
UNC パスが拒否される理由
図を読み込み中…
$ps.file は Script.json の ServerScriptFilePath をルートとして動作しますが、次のように UNC パスを設定しても使えません。
{
"DisableServerScriptFile": false,
"ServerScriptFileSizeMax": 1,
"ServerScriptFilePath": "\\\\file01\\pleasanter-data"
}パスを検証しているのは ServerScriptFile.cs の NormalizePath です。1.5.8.1 のソースでは次のようになっています(例外メッセージの組み立ては省略しています)。
var rootPath = Parameters.Script.ServerScriptFilePath;
if (rootPath == null) throw new ArgumentException(...);
var rootPath1 = rootPath.Replace(Path.DirectorySeparatorChar, '/');
var rootPath2 = rootPath.Replace('/', Path.DirectorySeparatorChar);
if (rootPath2 != Path.GetFullPath(rootPath2)) throw new ArgumentException(...);
var regexBase = new Regex("^([a-zA-Z]:/$|//.*|/)$");
if (regexBase.IsMatch(rootPath1)) throw new ArgumentException(...);
// ~中略(section と path の文字種の検査)~
var sectionPath = Path.Combine(rootPath2, section);
var fullPath = Path.GetFullPath(Path.Combine(sectionPath, path?.Replace('/', Path.DirectorySeparatorChar) ?? ""));
if (!fullPath.StartsWith(sectionPath)) throw new ArgumentException(...);処理の流れは次のとおりです。
ServerScriptFilePathがnullなら例外にするPath.GetFullPathで正規化した結果と一致しない(相対パスや..を含むなど)なら例外にする- 区切り文字を
/にそろえたパスが、ドライブのルート(C:/)、//で始まるパス(UNC)、/のいずれかなら例外にする sectionとpathに使える文字を制限し、最終的なパスがセクション配下から出ていないことを確認する
UNC パス(\\file01\pleasanter-data)は 3 番目の検査で拒否されます。また regexSection と regexPath で入力できる文字が制限されており、UNC パスに必要な \ やドライブ指定に必要な : は section や path で使えません。最後の fullPath.StartsWith(sectionPath) で、セクション配下から出ていないことも確認されます。したがって次の指定はいずれも回避策になりません。
ServerScriptFilePathに UNC パスを設定する$ps.fileのpath引数に UNC パス、ドライブ文字、..を渡す
検証の内容は版によって異なる
以前の版の NormalizePath は、ルートがプリザンターのカレントディレクトリ配下にあることを検査していました。1.5.8.1 のソースにはこの検査が無く、代わりに ServerScriptFilePath を正規化済みの絶対パスで指定する必要があります。そのため 1.5.8.1 では、App_Data/ServerScriptFiles のような相対パスは 2 番目の検査で拒否され、Z:\share のようにドライブのルート以外を指す絶対パスは検査では拒否されません(ネットワークドライブの場合は、前述のとおりプロセスから見えるかどうかが別の問題として残ります)。
DANGER
UNC パスが拒否されるのはアクセス権不足ではなく、$ps.file 自体のパス制限です。共有アクセス権・NTFS アクセス権を追加しても、実行アカウントを専用のドメインサービスアカウントや gMSA に変えても、この制限は解除されません。
net use ではフォルダにマウントできない
net use は SMB 共有をドライブ文字に割り当てるか、ドライブ文字なしで接続を確立するだけで、既存のローカルフォルダにマウントする機能はありません。
rem ドライブ文字へ割り当てる
net use Z: \\file01\pleasanter-data
rem ドライブ文字を付けずに接続だけを確立する
net use \\file01\pleasanter-data- 前者の
Z:は、割り当てが実行ユーザーのログオンセッションに属するため、IIS のアプリケーションプールから見えるとは限りません(1.5.8.1 のパス検証ではZ:\shareのような絶対パスそのものは拒否されません)。 - 後者は SMB セッションを確立するだけで、アクセス時には UNC パスを使うため検査を通りません。
$ps.file でできる範囲
ServerScriptFilePath にはローカルの絶対パスを設定し、サーバースクリプトではそのルートからのセクション名と相対パスを指定します。
var section = "exports";
var path = "2026/09";
try {
$ps.file.createDirectory(section, path);
} catch (e) {
context.Log("フォルダを作成できませんでした: " + e.message);
}この例で作成できるのは、ローカルルート配下の exports\2026\09 です。
確認したソースでは、createDirectory は同名のフォルダが既にある場合も true を返し、パスの検証エラーやアクセス権不足などで失敗した場合は false を返さずに例外を投げます(ServerScriptFile.cs)。そのため失敗の検出は戻り値ではなく try / catch で行います。
1.5.8.1 の $ps.file で使えるメソッドは次のとおりです(ServerScriptFile.cs)。
| 分類 | メソッド |
|---|---|
| ファイル | readAllText / writeAllText / getFileList / removeFile / moveFile / copyFile |
| フォルダ | getDirectoryList / createDirectory / removeDirectory / moveDirectory |
| セクション | createSection / removeSection |
| インポート・エクスポート | import / export |
ネットワーク共有へ反映する構成
方式の選び方
| 要件 | 向いている方式 |
|---|---|
| 作成直後から共有上に存在する必要がある | rclone・WinFsp によるディレクトリマウント |
| 数秒〜数分の反映遅延を許容できる | Syncthing、robocopy、外部ワーカー |
| 処理結果、再実行、承認、監査を管理したい | 社内 API、外部ワーカー |
$ps.file のアクセス範囲を広げたくない | Syncthing、robocopy、外部ワーカー |
作成したファイルを共有へ届けるだけなら、プリザンターとネットワーク共有を直接結合しない構成(Syncthing や外部ワーカー)のほうが、障害と権限の境界を分けやすくなります。
共有の操作を別プロセスに任せる(推奨)
- 社内 API: 共有先にアクセスできるサーバーに HTTPS API を用意し、サーバースクリプトの
httpClientから依頼します。API 側で入力値を検証し、操作できる共有とフォルダを固定します。 - Windows サービスやタスクスケジューラ: プリザンターには「フォルダ作成依頼」のレコードだけを登録し、別サーバーのサービスや定期タスクが API で未処理の依頼を取得して処理します。結果をレコードに書き戻せば、再実行や監査もしやすくなります。
- ローカルフォルダの監視:
$ps.fileで許可されたローカルフォルダに依頼ファイルを出力し、専用サービスが監視して共有先へ反映します。依頼ファイルの改ざん対策、二重処理の防止、失敗時の再実行を設計する必要があります。
Syncthing でサーバー間を同期する
両方のサーバーに Syncthing を導入し、プリザンター側のローカルフォルダとファイルサーバー側のローカルフォルダを同期します。$ps.file は最後までローカルフォルダだけを操作するので、UNC パスを扱う必要がありません。
図を読み込み中…
- プリザンター側の共有フォルダタイプを 送信専用、ファイルサーバー側を 受信専用 にすると運用意図が明確になります。
- 同期はバックアップではなく、送信元での変更や削除は同期先にも反映されます。ファイルサーバー側でファイルバージョン管理を有効にし、別途バックアップも用意します。
- Windows では、ログオンユーザーのスタートアップではなく、専用アカウントの Windows サービスとして実行すると安定します。
| 実行主体 | 必要なアクセス先 |
|---|---|
| プリザンターのアプリケーションプール | プリザンター側のローカル出力フォルダ |
| プリザンター側の Syncthing | 同じローカル出力フォルダ |
| ファイルサーバー側の Syncthing | 同期先のローカルフォルダ |
| 共有フォルダの利用者 | ファイルサーバー側で公開した共有 |
この構成なら、プリザンターのアプリケーションプールに SMB の資格情報を持たせずに済みます。
WARNING
Syncthing の管理画面を無認証のまま社内ネットワークへ公開しないでください。管理画面の認証とアクセス元の制限、デバイス ID による接続相手の確認、通信ポートの限定を行います。
robocopy で転送する
Windows 標準機能だけで構成するなら、タスクスケジューラから robocopy を定期実行します。共有フォルダへのアクセス権は robocopy の実行アカウントにだけ付与します。
$source = 'C:\web\pleasanter\App_Data\ServerScriptFiles\exports'
$destination = '\\file01\pleasanter-data\exports'
robocopy $source $destination /E /Z /R:3 /W:5 /COPY:DAT /DCOPY:DAT /LOG+:C:\Logs\PleasanterSync.log
if ($LASTEXITCODE -ge 8) {
throw "robocopy failed. ExitCode=$LASTEXITCODE"
}robocopy の終了コードは 0〜7 が正常系(コピー済み、差分ありなど)を含むため、この例では 8 以上を失敗として扱っています。/MIR などのミラーオプションは削除も同期し、コピー元での誤削除が共有先にも反映されるため、要件が明確でないうちは /E など削除を伴わない設定から検証します。
同期方式では次の点を決めておきます。
- 同期方向: 一方向か双方向か
- 削除の扱い: ローカルで消えたファイルを共有先でも削除するか
- 書き込み途中の扱い: 一時ファイルに出力してからリネームし、未完成ファイルの転送を防ぐ
- 競合時の扱い: 同名ファイルがあるときに上書き、世代保存、エラーのどれにするか
- 実行アカウント: 同期処理専用のアカウントに必要最小限の権限を付与する
- 再実行と監視: 終了コード、ログ、失敗通知を記録し、ネットワーク断から復旧できるようにする
- マルウェア対策: 暗号化や誤削除が同期先へ即時反映されるリスクに備えてバックアップを分ける
rclone mount と WinFsp でディレクトリにマウントする(Windows)
ユーザーモードファイルシステムの WinFsp と rclone mount(SMB バックエンド)を使うと、SMB 共有をドライブ文字ではなくプリザンターの許可範囲内のディレクトリにマウントできます。NormalizePath が検査するのはローカルパスなので検査を通り、その後のファイル操作を WinFsp と rclone が SMB 共有へ転送します。
図を読み込み中…
rclone で file-server という SMB リモートを設定済みとした場合の例です。
$mountPoint = 'C:\web\pleasanter\App_Data\ServerScriptFiles\remote'
rclone mount 'file-server:pleasanter-data' $mountPoint `
--vfs-cache-mode writes `
--log-file 'C:\Logs\rclone-pleasanter.log' `
--log-level INFO{
"DisableServerScriptFile": false,
"ServerScriptFilePath": "C:\\web\\pleasanter\\App_Data\\ServerScriptFiles\\remote"
}1.5.8.1 のソースでは ServerScriptFilePath を正規化済みの絶対パスで指定する必要があるため、相対パスではなく絶対パスで書いています。
WARNING
これは構成例です。rclone・WinFsp のバージョン、マウントポイントの要件、使用する Windows アカウントでディレクトリにマウントできるかを検証環境で確認してください。$ps.file がネットワーク共有を明示的に許可しているわけではなく、OS 層でローカルに見えるマウントポイントを用意している構成です。
本番運用では、少なくとも次を確認します。
- サービス化: 対話ユーザーのターミナルではなく Windows サービスとして起動する
- 起動順序: rclone のマウント完了後にプリザンターを起動する
- 実行アカウント: IIS と rclone の両方からマウントポイントにアクセスできるか
- 認証情報: rclone の設定ファイルを専用サービスアカウントだけが読めるようにする
- 停止時の動作: マウント解除中にローカルの同名フォルダへ書き込まないよう監視する
- キャッシュ: 書き込みキャッシュ、反映タイミング、障害時の未転送データ
- ファイル操作: リネーム、上書き、削除、ロック、同時アクセスの挙動
- 障害復旧: SMB 切断、rclone 停止、Windows 再起動後に自動復旧できるか
- 製品更新: プリザンター、rclone、WinFsp の更新後に回帰テストする
特に、マウントプロセスが止まるとマウントポイントが通常のローカルディレクトリとして見え、共有へ送ったつもりのデータがローカルに書き込まれる可能性があります。また、マウントポイント配下は $ps.file の読み取り・書き込み・削除・移動・コピーの対象になるため、サーバースクリプトを編集できる利用者を制限し、rclone の SMB アカウントにも必要最小限の権限だけを付与します。
Linux の場合
Linux でも NormalizePath はパス文字列だけを検査しており、マウントポイントかどうかは見ていません。そのため SMB や NFS をマウントしたディレクトリを ServerScriptFilePath に指定すれば検査を通ります。1.5.8.1 のソースでは / 以外の正規化済みの絶対パスであれば指定できるため、/mnt/pleasanter-data のような場所でも構いません(以前の版ではルートがカレントディレクトリ配下である必要があったため、次の例ではどちらでも動くようカレントディレクトリ配下にマウントしています)。
カレントディレクトリが /opt/pleasanter の場合の例です。
/opt/pleasanter/
└── App_Data/
└── ServerScriptFiles/
└── remote/ ← このディレクトリへ SMB または NFS をマウントsudo mkdir -p /opt/pleasanter/App_Data/ServerScriptFiles/remote
sudo mount -t cifs //file01/pleasanter-data \
/opt/pleasanter/App_Data/ServerScriptFiles/remote \
-o credentials=/root/.smb-pleasanter,uid=pleasanter,gid=pleasanter,dir_mode=0750,file_mode=0640{
"DisableServerScriptFile": false,
"ServerScriptFilePath": "/opt/pleasanter/App_Data/ServerScriptFiles/remote"
}コンテナ環境でも考え方は同じで、ホスト側でマウントした共有をコンテナ内のプリザンターのカレントディレクトリ配下に bind mount します。
WARNING
これは $ps.file が SMB や NFS を正式にサポートするという意味ではありません。プリザンターの更新でリパースポイントやマウントポイントを考慮した検査が追加されれば、動作が変わる可能性があります。
Linux で採用する場合は次の点も設計します。
- 認証情報はコマンドラインや
Script.jsonに書かず、root だけが読める資格情報ファイルに置く _netdevや systemd の依存関係で、ネットワークとマウントの完了後にプリザンターを起動する- マウント解除時に同名のローカルディレクトリへ誤って書き込まないよう監視する
uid、gid、file_mode、dir_modeでプリザンターの実行ユーザーに最小権限を与える- 通信断、再接続、タイムアウト、ファイルロック、同名ファイルの競合を検証する
- Web アプリケーションからファイルサーバーへ直接到達できる範囲が広がるリスクを評価する
シンボリックリンクは使わない
許可されたローカルルート内に共有フォルダへのシンボリックリンクやジャンクションを置くと、Path.GetFullPath はリンク先まで解決しないため検査を通ってしまう可能性があります。しかし次の理由から採用すべきではありません。
- アクセス境界を迂回する:
NormalizePathのパス検査(セクション配下かを確かめるStartsWithなど)による制限を実質的に無効にします。 - フォルダ作成以外にも影響する: 読み取り・書き込み・削除・移動・コピーなど
$ps.fileのすべての処理が同じパス正規化を使うため、共有先への操作範囲が広がります。 - リンク先の差し替えの危険がある: 検査と実際のファイル操作は同時ではないため、検査後にリンク先を差し替えられる余地があります。
- 実行条件に依存する: リンクの作成権限、IIS の実行アカウント、共有・NTFS アクセス権、SMB の認証状態に依存し、保守しにくくなります。
- 将来動かなくなる可能性がある: リパースポイントの検出などが追加されれば動作しなくなります。公式に保証された経路でもありません。