287 lines
9.6 KiB
JavaScript
287 lines
9.6 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { stringify } = require("csv-stringify/sync");
|
|
|
|
/*
|
|
仕様書
|
|
https://developers.worksmobile.com/jp/docs/group-list
|
|
|
|
使い方:
|
|
node getGroupList.js <tenantDomainId> [outputCsvPath]
|
|
|
|
例:
|
|
node getGroupList.js 10000001
|
|
node getGroupList.js 10000001 ./groups-10000001.csv
|
|
|
|
環境変数:
|
|
LW_CLIENT_ID
|
|
LW_CLIENT_SECRET
|
|
LW_SERVICE_ACCOUNT
|
|
LW_PRIVATE_KEY
|
|
LW_PRIVATE_KEY_FILE
|
|
LW_SCOPE 既定: group.read
|
|
*/
|
|
|
|
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
|
|
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
|
|
|
|
const PRIVATE_KEY_FILE = process.env.LW_PRIVATE_KEY_FILE
|
|
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
|
|
: path.join(__dirname, "private_20260307184804.key");
|
|
|
|
const LW_CLIENT_ID = process.env.LW_CLIENT_ID || "tre8J_Tk8RblfsMSyZRh";
|
|
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET || "O5_R5gcHxg";
|
|
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT || "wh4k8.serviceaccount@next-hd.co.jp";
|
|
const LW_SCOPE = process.env.LW_SCOPE || "group.read";
|
|
const LW_COUNT_PER_PAGE = 100;
|
|
|
|
const LW_PRIVATE_KEY = (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim())
|
|
? process.env.LW_PRIVATE_KEY
|
|
: (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, "utf8") : "");
|
|
|
|
function parseArgs() {
|
|
const domainIdRaw = process.argv[2];
|
|
const outputPathRaw = process.argv[3];
|
|
|
|
if (!domainIdRaw) {
|
|
throw new Error("tenantDomainId を引数で指定してください。例: node getGroupList.js 10000001");
|
|
}
|
|
|
|
const domainId = Number(domainIdRaw);
|
|
if (!Number.isInteger(domainId) || domainId <= 0) {
|
|
throw new Error(`tenantDomainId が不正です: ${domainIdRaw}`);
|
|
}
|
|
|
|
const outputPath = outputPathRaw
|
|
? path.resolve(outputPathRaw)
|
|
: path.join(__dirname, `groups-${domainId}-${formatDateForFile(new Date())}.csv`);
|
|
|
|
return { domainId, outputPath };
|
|
}
|
|
|
|
function formatDateForFile(date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
const hh = String(date.getHours()).padStart(2, "0");
|
|
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
const ss = String(date.getSeconds()).padStart(2, "0");
|
|
return `${y}${m}${d}-${hh}${mm}${ss}`;
|
|
}
|
|
|
|
function ensureRequiredEnvForAuth() {
|
|
const missing = [];
|
|
if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID");
|
|
if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET");
|
|
if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT");
|
|
if (!LW_PRIVATE_KEY) missing.push("LW_PRIVATE_KEY");
|
|
|
|
if (missing.length > 0) {
|
|
if (missing.includes("LW_PRIVATE_KEY") && !fs.existsSync(PRIVATE_KEY_FILE)) {
|
|
throw new Error(`秘密鍵が見つかりません: ${PRIVATE_KEY_FILE}`);
|
|
}
|
|
throw new Error(`環境変数が不足しています: ${missing.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
function base64UrlEncode(value) {
|
|
return Buffer.from(value)
|
|
.toString("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
}
|
|
|
|
function createJwtAssertion() {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = { alg: "RS256", typ: "JWT" };
|
|
const payload = {
|
|
iss: LW_CLIENT_ID,
|
|
sub: LW_SERVICE_ACCOUNT,
|
|
aud: LW_TOKEN_URL,
|
|
iat: now,
|
|
exp: now + 300,
|
|
};
|
|
|
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
|
|
const signer = crypto.createSign("RSA-SHA256");
|
|
signer.update(signingInput);
|
|
signer.end();
|
|
|
|
const signature = signer
|
|
.sign(LW_PRIVATE_KEY)
|
|
.toString("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
|
|
return `${signingInput}.${signature}`;
|
|
}
|
|
|
|
async function getAccessToken(scope = LW_SCOPE) {
|
|
ensureRequiredEnvForAuth();
|
|
|
|
const assertion = createJwtAssertion();
|
|
const form = new URLSearchParams({
|
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
assertion,
|
|
client_id: LW_CLIENT_ID,
|
|
client_secret: LW_CLIENT_SECRET,
|
|
scope,
|
|
});
|
|
|
|
const response = await fetch(LW_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: form,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const detail = await response.text();
|
|
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data.access_token) {
|
|
throw new Error("アクセストークン取得失敗: access_token が返却されませんでした");
|
|
}
|
|
|
|
return data.access_token;
|
|
}
|
|
|
|
async function fetchAllGroups(accessToken, domainId) {
|
|
const groups = [];
|
|
let cursor = "";
|
|
|
|
while (true) {
|
|
const params = new URLSearchParams();
|
|
params.set("domainId", String(domainId));
|
|
params.set("count", String(LW_COUNT_PER_PAGE));
|
|
if (cursor) {
|
|
params.set("cursor", cursor);
|
|
}
|
|
|
|
const url = `${LW_API_BASE_URL}/groups?${params.toString()}`;
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
const raw = await response.text();
|
|
let data;
|
|
try {
|
|
data = raw ? JSON.parse(raw) : {};
|
|
} catch (_err) {
|
|
throw new Error(`グループ一覧レスポンスがJSONではありません: ${raw}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`グループ一覧取得失敗: ${response.status} ${JSON.stringify(data)}`);
|
|
}
|
|
|
|
const pageGroups = Array.isArray(data.groups) ? data.groups : [];
|
|
groups.push(...pageGroups);
|
|
|
|
const nextCursor = data?.responseMetaData?.nextCursor || "";
|
|
if (!nextCursor || nextCursor === cursor) {
|
|
break;
|
|
}
|
|
cursor = nextCursor;
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
function csvSafeJoin(values) {
|
|
if (!Array.isArray(values) || values.length === 0) {
|
|
return "";
|
|
}
|
|
return values.join(";");
|
|
}
|
|
|
|
function toCsvRows(groups) {
|
|
return groups.map((group) => {
|
|
const administrators = Array.isArray(group.administrators) ? group.administrators : [];
|
|
const members = Array.isArray(group.members) ? group.members : [];
|
|
const recipientAllowed = Array.isArray(group.membersAllowedToUseGroupEmailAsRecipient)
|
|
? group.membersAllowedToUseGroupEmailAsRecipient
|
|
: [];
|
|
const senderAllowed = Array.isArray(group.membersAllowedToUseGroupEmailAsSender)
|
|
? group.membersAllowedToUseGroupEmailAsSender
|
|
: [];
|
|
|
|
return {
|
|
domainId: group.domainId ?? "",
|
|
groupId: group.groupId || "",
|
|
groupName: group.groupName || "",
|
|
description: group.description || "",
|
|
visible: group.visible ?? "",
|
|
useServiceNotification: group.useServiceNotification ?? "",
|
|
serviceManageable: group.serviceManageable ?? "",
|
|
groupExternalKey: group.groupExternalKey || "",
|
|
administratorsCount: administrators.length,
|
|
administratorsUserIds: csvSafeJoin(administrators.map((a) => a.userId || "")),
|
|
membersCount: members.length,
|
|
memberUsersCount: members.filter((m) => m?.type === "USER").length,
|
|
memberOrgUnitsCount: members.filter((m) => m?.type === "ORGUNIT").length,
|
|
memberGroupsCount: members.filter((m) => m?.type === "GROUP").length,
|
|
membersSummary: csvSafeJoin(members.map((m) => `${m.type || ""}:${m.id || ""}`)),
|
|
useMessage: group.useMessage ?? "",
|
|
useNote: group.useNote ?? "",
|
|
useCalendar: group.useCalendar ?? "",
|
|
useTask: group.useTask ?? "",
|
|
useFolder: group.useFolder ?? "",
|
|
useMail: group.useMail ?? "",
|
|
groupEmail: group.groupEmail || "",
|
|
aliasEmails: csvSafeJoin(group.aliasEmails || []),
|
|
canReceiveExternalMail: group.canReceiveExternalMail ?? "",
|
|
toExternalEmails: csvSafeJoin(group.toExternalEmails || []),
|
|
mailRecipientAllowedCount: recipientAllowed.length,
|
|
mailRecipientAllowedUserIds: csvSafeJoin(recipientAllowed.map((x) => x.userId || "")),
|
|
mailSenderAllowedCount: senderAllowed.length,
|
|
mailSenderAllowedUserIds: csvSafeJoin(senderAllowed.map((x) => x.userId || "")),
|
|
};
|
|
});
|
|
}
|
|
|
|
function writeCsv(outputPath, rows) {
|
|
const outputDir = path.dirname(outputPath);
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const csv = stringify(rows, {
|
|
header: true,
|
|
bom: true,
|
|
});
|
|
|
|
fs.writeFileSync(outputPath, csv, "utf8");
|
|
}
|
|
|
|
async function main() {
|
|
if (typeof fetch !== "function") {
|
|
throw new Error("この Node.js 実行環境は fetch に未対応です。Node.js 18 以上を使用してください。");
|
|
}
|
|
|
|
const { domainId, outputPath } = parseArgs();
|
|
const accessToken = await getAccessToken();
|
|
const groups = await fetchAllGroups(accessToken, domainId);
|
|
const rows = toCsvRows(groups);
|
|
writeCsv(outputPath, rows);
|
|
|
|
console.log(`取得件数: ${groups.length}`);
|
|
console.log(`CSV出力先: ${outputPath}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("グループリスト出力に失敗しました。");
|
|
console.error(err.message);
|
|
process.exit(1);
|
|
});
|