Skip to content

Commit f424361

Browse files
committed
Update
1 parent 073a2df commit f424361

File tree

5 files changed

+922
-32
lines changed

5 files changed

+922
-32
lines changed

src/dirsync/config.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@ const SecretsConfigSchema = z.object({
1313
entraClientCertificate: z
1414
.string()
1515
.min(1, "entraClientCertificate is required"),
16-
// googleDelegatedUser: z
17-
// .string()
18-
// .email("googleDelegatedUser must be a valid email"),
19-
// googleServiceAccountJson: z
20-
// .string()
21-
// .min(1, "googleServiceAccountJson is required"),
16+
googleDelegatedUser: z.email("googleDelegatedUser must be a valid email"),
17+
googleServiceAccountJson: z
18+
.string()
19+
.min(1, "googleServiceAccountJson is required"),
2220
deleteRemovedContacts: z.boolean().default(false),
2321
});
2422

src/dirsync/gsuite.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { google } from "googleapis";
2+
import { GoogleAuth } from "google-auth-library";
3+
4+
export interface GoogleContact {
5+
email: string;
6+
upn: string;
7+
givenName: string;
8+
familyName: string;
9+
displayName: string;
10+
}
11+
12+
export interface ExistingContact {
13+
id: string; // Contact ID for updates/deletes
14+
contact: GoogleContact;
15+
}
16+
17+
/**
18+
* Creates a Google API client with domain-wide delegation
19+
*/
20+
export const createGoogleClient = (
21+
serviceAccountJson: string,
22+
delegatedUser: string,
23+
): GoogleAuth => {
24+
const serviceAccountInfo = JSON.parse(serviceAccountJson);
25+
26+
const auth = new google.auth.GoogleAuth({
27+
credentials: serviceAccountInfo,
28+
scopes: ["https://www.google.com/m8/feeds"], // Domain Shared Contacts API scope
29+
clientOptions: {
30+
subject: delegatedUser,
31+
},
32+
});
33+
34+
return auth;
35+
};
36+
37+
/**
38+
* Fetches all domain shared contacts
39+
*/
40+
export const getAllDomainContacts = async (
41+
auth: GoogleAuth,
42+
domain: string,
43+
): Promise<Map<string, ExistingContact>> => {
44+
console.log("Fetching existing domain shared contacts...");
45+
const contacts = new Map<string, ExistingContact>();
46+
47+
try {
48+
const client = await auth.getClient();
49+
const accessToken = await client.getAccessToken();
50+
51+
if (!accessToken.token) {
52+
throw new Error("Failed to get access token");
53+
}
54+
55+
// Use the Domain Shared Contacts API
56+
const feedUrl = `https://www.google.com/m8/feeds/contacts/${domain}/full`;
57+
58+
let startIndex = 1;
59+
const maxResults = 1000;
60+
let hasMore = true;
61+
62+
while (hasMore) {
63+
const url = `${feedUrl}?max-results=${maxResults}&start-index=${startIndex}&alt=json`;
64+
65+
const response = await fetch(url, {
66+
headers: {
67+
Authorization: `Bearer ${accessToken.token}`,
68+
"GData-Version": "3.0",
69+
},
70+
});
71+
72+
if (!response.ok) {
73+
throw new Error(`Failed to fetch contacts: ${response.statusText}`);
74+
}
75+
76+
const data = await response.json();
77+
const entries = data.feed?.entry || [];
78+
79+
for (const entry of entries) {
80+
const contact = parseContactEntry(entry);
81+
if (contact) {
82+
const key = getPrimaryEmail(contact);
83+
contacts.set(key, {
84+
id: getContactId(entry),
85+
contact,
86+
});
87+
}
88+
}
89+
90+
// Check if there are more results
91+
hasMore = entries.length === maxResults;
92+
startIndex += maxResults;
93+
}
94+
95+
console.log(`Found ${contacts.size} existing domain shared contacts`);
96+
return contacts;
97+
} catch (error) {
98+
console.error("Error fetching domain contacts:", error);
99+
throw error;
100+
}
101+
};
102+
103+
/**
104+
* Creates a new domain shared contact
105+
*/
106+
export const createDomainContact = async (
107+
auth: GoogleAuth,
108+
domain: string,
109+
contact: GoogleContact,
110+
): Promise<boolean> => {
111+
try {
112+
const client = await auth.getClient();
113+
const accessToken = await client.getAccessToken();
114+
115+
if (!accessToken.token) {
116+
throw new Error("Failed to get access token");
117+
}
118+
119+
const feedUrl = `https://www.google.com/m8/feeds/contacts/${domain}/full`;
120+
const atomEntry = contactToAtomXml(contact);
121+
122+
const response = await fetch(feedUrl, {
123+
method: "POST",
124+
headers: {
125+
Authorization: `Bearer ${accessToken.token}`,
126+
"GData-Version": "3.0",
127+
"Content-Type": "application/atom+xml",
128+
},
129+
body: atomEntry,
130+
});
131+
132+
if (!response.ok) {
133+
throw new Error(`Failed to create contact: ${response.statusText}`);
134+
}
135+
136+
console.log(`Created contact: ${getPrimaryEmail(contact)}`);
137+
return true;
138+
} catch (error) {
139+
console.error(`Error creating contact ${getPrimaryEmail(contact)}:`, error);
140+
return false;
141+
}
142+
};
143+
144+
/**
145+
* Updates an existing domain shared contact
146+
*/
147+
export const updateDomainContact = async (
148+
auth: GoogleAuth,
149+
domain: string,
150+
contactId: string,
151+
contact: GoogleContact,
152+
): Promise<boolean> => {
153+
try {
154+
const client = await auth.getClient();
155+
const accessToken = await client.getAccessToken();
156+
157+
if (!accessToken.token) {
158+
throw new Error("Failed to get access token");
159+
}
160+
161+
const editUrl = `https://www.google.com/m8/feeds/contacts/${domain}/full/${contactId}`;
162+
const atomEntry = contactToAtomXml(contact);
163+
164+
const response = await fetch(editUrl, {
165+
method: "PUT",
166+
headers: {
167+
Authorization: `Bearer ${accessToken.token}`,
168+
"GData-Version": "3.0",
169+
"Content-Type": "application/atom+xml",
170+
"If-Match": "*", // Overwrite regardless of version
171+
},
172+
body: atomEntry,
173+
});
174+
175+
if (!response.ok) {
176+
throw new Error(`Failed to update contact: ${response.statusText}`);
177+
}
178+
179+
console.log(`Updated contact: ${getPrimaryEmail(contact)}`);
180+
return true;
181+
} catch (error) {
182+
console.error(`Error updating contact ${getPrimaryEmail(contact)}:`, error);
183+
return false;
184+
}
185+
};
186+
187+
/**
188+
* Deletes a domain shared contact
189+
*/
190+
export const deleteDomainContact = async (
191+
auth: GoogleAuth,
192+
domain: string,
193+
contactId: string,
194+
email: string,
195+
): Promise<boolean> => {
196+
try {
197+
const client = await auth.getClient();
198+
const accessToken = await client.getAccessToken();
199+
200+
if (!accessToken.token) {
201+
throw new Error("Failed to get access token");
202+
}
203+
204+
const editUrl = `https://www.google.com/m8/feeds/contacts/${domain}/full/${contactId}`;
205+
206+
const response = await fetch(editUrl, {
207+
method: "DELETE",
208+
headers: {
209+
Authorization: `Bearer ${accessToken.token}`,
210+
"GData-Version": "3.0",
211+
"If-Match": "*",
212+
},
213+
});
214+
215+
if (!response.ok) {
216+
throw new Error(`Failed to delete contact: ${response.statusText}`);
217+
}
218+
219+
console.log(`Deleted contact: ${email}`);
220+
return true;
221+
} catch (error) {
222+
console.error(`Error deleting contact ${email}:`, error);
223+
return false;
224+
}
225+
};
226+
227+
/**
228+
* Converts a contact to Atom XML format for Google's API
229+
*/
230+
const contactToAtomXml = (contact: GoogleContact): string => {
231+
const emails: string[] = [];
232+
233+
if (contact.email) {
234+
emails.push(
235+
`<gd:email rel="http://schemas.google.com/g/2005#work" address="${escapeXml(contact.email)}" primary="true" />`,
236+
);
237+
}
238+
239+
if (
240+
contact.upn &&
241+
contact.upn.toLowerCase() !== contact.email.toLowerCase()
242+
) {
243+
emails.push(
244+
`<gd:email rel="http://schemas.google.com/g/2005#other" address="${escapeXml(contact.upn)}" />`,
245+
);
246+
}
247+
248+
return `<?xml version="1.0" encoding="UTF-8"?>
249+
<atom:entry xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gd="http://schemas.google.com/g/2005">
250+
<atom:category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact" />
251+
<gd:name>
252+
<gd:givenName>${escapeXml(contact.givenName)}</gd:givenName>
253+
<gd:familyName>${escapeXml(contact.familyName)}</gd:familyName>
254+
<gd:fullName>${escapeXml(contact.displayName)}</gd:fullName>
255+
</gd:name>
256+
${emails.join("\n ")}
257+
</atom:entry>`;
258+
};
259+
260+
/**
261+
* Parses a contact entry from the API response
262+
*/
263+
const parseContactEntry = (entry: any): GoogleContact | null => {
264+
const emails = entry.gd$email || [];
265+
if (emails.length === 0) {
266+
return null;
267+
}
268+
269+
const primaryEmail =
270+
emails.find((e: any) => e.primary === "true")?.address || emails[0].address;
271+
const otherEmail =
272+
emails.find((e: any) => e.rel?.includes("other"))?.address || "";
273+
274+
const name = entry.gd$name || {};
275+
276+
return {
277+
email: primaryEmail || "",
278+
upn: otherEmail || "",
279+
givenName: name.gd$givenName?.$t || "",
280+
familyName: name.gd$familyName?.$t || "",
281+
displayName: name.gd$fullName?.$t || primaryEmail || "",
282+
};
283+
};
284+
285+
/**
286+
* Extracts the contact ID from an entry
287+
*/
288+
const getContactId = (entry: any): string => {
289+
const id = entry.id?.$t || "";
290+
// Extract the last part of the ID (after the last /)
291+
return id.split("/").pop() || id;
292+
};
293+
294+
/**
295+
* Gets the primary email identifier for a contact
296+
*/
297+
const getPrimaryEmail = (contact: GoogleContact): string => {
298+
return (contact.email || contact.upn).toLowerCase();
299+
};
300+
301+
/**
302+
* Escapes XML special characters
303+
*/
304+
const escapeXml = (str: string): string => {
305+
return str
306+
.replace(/&/g, "&amp;")
307+
.replace(/</g, "&lt;")
308+
.replace(/>/g, "&gt;")
309+
.replace(/"/g, "&quot;")
310+
.replace(/'/g, "&apos;");
311+
};

src/dirsync/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"@azure/identity": "^4.12.0",
2424
"@microsoft/microsoft-graph-client": "^3.0.7",
2525
"@types/pino": "^7.0.5",
26+
"google-auth-library": "^10.4.0",
27+
"googleapis": "^161.0.0",
2628
"pino": "^10.0.0",
2729
"zod": "^4.1.11"
2830
}

0 commit comments

Comments
 (0)