Xin chào 👋
Hôm nay, hãy cùng khám phá cách xây dựng cuộc trò chuyện được mã hóa hai đầu bằng Pubnub và SDK kín.
👉 Bạn có thể tìm thấy ví dụ hoạt động đầy đủ về cách sử dụng PubNub và Seald tại đây:
Pubnub là gì?
PubNub là dịch vụ liên lạc thời gian thực có thể được tích hợp vào hầu hết các ứng dụng. Đáng tin cậy và có thể mở rộng, nó có thể dễ dàng được tích hợp với các framework phổ biến nhất.
Seal là gì?
Seald.io cung cấp SDK cho phép bạn thực hiện mã hóa đầu cuối , với các tính năng quản lý nâng cao mà không cần bất kỳ kiến thức về mật mã nào trước đó. SDK này có thể được tích hợp vào các ứng dụng web, phụ trợ, thiết bị di động hoặc máy tính để bàn.
Mã hóa đầu cuối cung cấp mức độ riêng tư và bảo mật cao nhất. Nó cho phép mã hóa dữ liệu nhạy cảm ngay khi chúng được thu thập. Mã hóa sớm sẽ làm giảm bề mặt tấn công của ứng dụng của bạn. Một ưu điểm khác là bạn có thể quản lý chính xác ai có thể truy cập dữ liệu. Điều này cũng sẽ bảo vệ khi nó không nằm trong phạm vi của bạn, chẳng hạn như các dịch vụ của bên thứ ba.
Mã hóa hai đầu cho phép bạn giữ quyền kiểm soát mọi lúc, khi dữ liệu của bạn đang được truyền, khi dữ liệu ở trạng thái nghỉ và ngay cả khi dữ liệu không nằm trong tay bạn. Do đó, nó cung cấp khả năng bảo vệ rộng hơn nhiều so với các công nghệ mã hóa khác (TLS, mã hóa toàn bộ đĩa, ...).
Bất cứ khi nào bạn gặp phải trường hợp cần phải tuân thủ (GDPR, HIPAA, SOC-2,...) hoặc khi bạn có dữ liệu nhạy cảm (y tế, quốc phòng,...) thì mã hóa hai đầu là điều bắt buộc. Nhưng ngay cả đối với những dữ liệu phổ biến hơn thì đó cũng là một cách thực hành tốt. Vi phạm dữ liệu là một sự kiện tàn khốc ngày càng trở nên thường xuyên hơn.
PubNub SDK cung cấp một móc mã hóa đơn giản, sử dụng đối số cipherKey
khi khởi tạo SDK của nó. Làm như vậy sẽ đảm bảo rằng tất cả tin nhắn tải lên đều được mã hóa trước khi gửi đi. Tuy nhiên, bạn phải tự mình thực hiện việc quản lý khóa, đây là phần khó nhất của hệ thống mã hóa hai đầu.
Các lỗ hổng hiếm khi đến từ chính quá trình mã hóa mà thường là do các khóa bị rò rỉ thông qua các sai sót trong mô hình bảo mật.
Việc sử dụng dịch vụ của bên thứ ba để bảo mật cho phép bạn không gặp phải một điểm lỗi nào. Seald.io đề xuất một mô hình bảo mật mạnh mẽ, được ANSSI chứng nhận, với khả năng kiểm soát quản lý truy cập theo thời gian thực, thu hồi và khôi phục người dùng, 2FA, v.v.
Bài viết này giải thích cách tích hợp Seald với PubNub từng bước để bảo mật cuộc trò chuyện của bạn bằng mã hóa hai đầu. Chúng ta sẽ xây dựng một ứng dụng nhắn tin mẫu với các tính năng sau:
Phòng trò chuyện cá nhân và nhóm.
Mỗi thành viên có một phòng trò chuyện riêng với mọi người dùng khác.
Bất kỳ ai cũng có thể tạo phòng trò chuyện nhóm với nhiều người dùng khác.
Sử dụng mã hóa đầu cuối cho mọi tin nhắn và tập tin được gửi.
Cho phép quản lý quyền truy cập theo thời gian thực vào các cuộc trò chuyện.
Để bắt đầu, bạn sẽ cần có tài khoản PubNub. Bạn có thể đăng ký
Chọn bộ phím demo và cuộn đến tab cấu hình. Đối với bản demo của chúng tôi, chúng tôi cần kích hoạt quyền Files
và Objects
. Đối với quyền Object
, chúng tôi sẽ sử dụng các sự kiện sau: User Metadata Events
, Channel Metadata Events
và Membership Events
.
Sau khi bộ khóa được tạo và định cấu hình, chúng ta cần sao chép nó vào giao diện người dùng của mình.
Hãy tạo một tệp JSON trên thư mục src/
, được gọi là settings.json
. Chúng tôi sẽ sử dụng tệp này cho tất cả các khóa API mà chúng tôi cần. Bắt đầu với bộ khóa PubNub:
{ "PUBNUB_PUB_KEY": "pub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "PUBNUB_SUB_KEY": "sub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" }
Chúng tôi sẽ sử dụng PubNub cho hầu hết mọi tác vụ phụ trợ. Phần phụ trợ của chúng tôi sẽ chỉ xử lý việc đăng ký/đăng nhập của người dùng và sử dụng mô hình người dùng tối giản chỉ có ID, tên và email.
Ở mặt trước, chúng ta cần một giao diện xác thực nhỏ:
Sau khi người dùng có tài khoản, điều đầu tiên họ cần là một phiên bản SDK PubNub.
Để xác định người dùng trên Pubnub, chúng tôi cần cung cấp UUID.
Để đơn giản hóa mọi thứ, chúng tôi sẽ sử dụng cùng một ID như trên phần phụ trợ của mình:
/* frontend/src/App.js */ import settings from './settings.json' // our settings file for API keys /* ... */ const pubnub = new PubNub({ publishKey: settings.PUBNUB_PUB_KEY, subscribeKey: settings.PUBNUB_SUB_KEY, uuid: currentUser.id })
Để giữ cho phần phụ trợ của chúng tôi đơn giản nhất có thể, chúng tôi sẽ sử dụng siêu dữ liệu người dùng của PubNub để trao đổi thông tin của người dùng.
Ngay sau khi khởi tạo SDK, chúng ta chỉ cần gọi hàm PubNub setUUIDMetadata
:
/* frontend/src/App.js */ await pubnub.objects.setUUIDMetadata({ uuid: currentUser.id, data: { email: currentUser.emailAddress, name: currentUser.name } })
Điều đầu tiên cần làm với PubNub là truy xuất tất cả các thành viên hiện có và lưu trữ chúng trong kho dữ liệu cục bộ của chúng tôi:
/* frontend/src/App.js */ const existingMembers = await pubnub.objects.getAllUUIDMetadata() dispatch({ type: SET_USERS, payload: { users: existingMembers.data.map(u => new User({ id: u.id, name: u.name, emailAddress: u.email })) } })
Mỗi phòng chat sẽ tương ứng với một kênh PubNub. Chúng tôi cũng sẽ thêm một số siêu dữ liệu vào từng kênh:
ownerId
: ID của người dùng đã tạo phòng.
one2one
: Một boolean để phân biệt phòng nhắn tin trực tiếp và phòng nhóm.
archived
: Một boolean, để ẩn phòng nhóm đã bị xóa.
Siêu dữ liệu ownerId
sẽ được sử dụng sau này khi thêm SDK Seald. PubNub không có khái niệm quyền sở hữu, nhưng Seald thì có. Nó sẽ xác định ai có thể thêm hoặc xóa người dùng khỏi kênh. Về cơ bản nó xác định một quản trị viên nhóm.
Chúng tôi sẽ bắt đầu bằng cách lấy các phòng trò chuyện hiện có. Chúng tôi cũng sẽ cần siêu dữ liệu của phòng, vì vậy chúng tôi cần bao gồm các trường tùy chỉnh. Sau đó, chúng ta cần lọc ra các phòng đã lưu trữ và gửi mọi thứ đến kho dữ liệu của mình.
Cuối cùng chúng ta subscribe
kênh PubNub liên kết với phòng nên sẽ nhận được tin nhắn mới:
/* frontend/src/App.js */ // Retrieve rooms of which we are members const memberships = await pubnub.objects.getMemberships({ include: { customChannelFields: true } }) const knownRooms = [] // For each room, retrieve room members for (const room of memberships.data.filter(r => !r.channel.custom.archived)) { const roomMembers = await pubnub.objects.getChannelMembers({ channel: room.channel.id }) knownRooms.push(new Room({ id: room.channel.id, name: room.channel.name, users: roomMembers.data.map(u => u.uuid.id), ownerId: room.channel.custom.ownerId, one2one: room.channel.custom.one2one })) } // Store rooms in our data store dispatch({ type: SET_ROOMS, payload: { rooms: knownRooms } }) // Subscribe to channels to get new messages pubnub.subscribe({ channels: knownRooms.map(r => r.id) })
Bây giờ chúng tôi đã tìm nạp tất cả các phòng mà chúng tôi đang ở. Chúng tôi cần một điều cuối cùng để hoàn tất quá trình khởi tạo ứng dụng: đảm bảo chúng tôi có phòng one2one
với mọi thành viên khác, kể cả những người mới đăng ký.
Đối với mỗi người dùng mới được tìm thấy, chúng tôi sẽ tạo một phòng mới và gửi tin nhắn xin chào.
Sau đó, chúng tôi sẽ đặt siêu dữ liệu của phòng và đăng ký siêu dữ liệu đó:
/* frontend/src/App.js */ // Ensure that we have a one2one room with everyone const one2oneRooms = knownRooms.filter(r => r.one2one) for (const m of existingMembers.data.filter(u => u.id!== currentUser.id)) { if (!one2oneRooms.find(r => r.users.includes(m.id))) { // New user found: generating a new one2one room const newRoomId = PubNub.generateUUID() const newRoom = new Room({ id: newRoomId, users: [currentUser.id, m.id], one2one: true, name: m.name, ownerId: currentUser.id }) // Add the new room to our local list dispatch({ type: EDIT_OR_ADD_ROOM, payload: { room: new Room({ id: newRoomId, users: [currentUser.id, m.id], one2one: true, name: m.name, ownerId: currentUser.id }) } }) // Publish a "Hello" message in the room await pubnub.publish({ channel: newRoomId, message: { type: 'message', data: (await sealdSession.encryptMessage('Hello 👋')) } }) // Subscribe to the new room pubnub.subscribe({ channels: [newRoomId] }) await pubnub.objects.setChannelMetadata({ channel: newRoomId, data: { name: 'one2one', custom: { one2one: true, ownerId: currentUser.id, }, } }) await pubnub.objects.setChannelMembers({ channel: newRoomId, uuids: [currentUser.id, m.id] }) } }
Khi tất cả đã hoàn tất, trạng thái ứng dụng ban đầu của chúng tôi được xác định đầy đủ. Tuy nhiên, chúng ta cần phải cập nhật nó.
Việc này có thể được thực hiện đơn giản bằng cách thêm trình xử lý sự kiện cho các sự kiện membership
:
/* frontend/src/App.js */ pubnub.addListener({ objects: async function(objectEvent) { if (objectEvent.message.type === 'membership') { if (objectEvent.message.event === 'delete') { // User is removed from a room /* Removing the room from store... */ } if (objectEvent.message.event === 'set') { // User is added to a room const metadata = await pubnub.objects.getChannelMetadata({ channel: objectEvent.message.data.channel.id }) const roomMembers = (await pubnub.objects.getChannelMembers({ channel: objectEvent.message.data.channel.id })).data.map(u => u.uuid.id) /* Adding new room to store + subscribing to new room channel... */ } } } }) pubnub.subscribe({ channels: [currentUser.id] }) // channel on which events concerning the current user are published
Bây giờ chúng ta có thể xem chính phòng trò chuyện one2one
. Sau đó chúng tôi sẽ xử lý các phòng nhóm.
Trong file chat.js
, chúng ta sẽ có đầy đủ logic để hiển thị tin nhắn của phòng chat.
Để khởi tạo phòng này, điều duy nhất chúng ta cần làm là lấy tất cả các tin nhắn có sẵn.
Nó có thể được thực hiện đơn giản bằng cách biết ID phòng:
/* frontend/src/components/Chat.jsx */ const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId]
Chúng ta có thể đăng ký kênh để nhận tin nhắn mới và thêm người nghe để hiển thị chúng theo thời gian thực:
/* frontend/src/components/Chat.jsx */ pubnub.addListener({ message: handleReceiveMessage }) pubnub.subscribe({ channels: [currentRoomId] })
Để gửi tin nhắn, chúng ta chỉ cần xuất bản nó trên kênh:
/* frontend/src/components/Chat.jsx */ const handleSubmitMessage = async e => { /* Some checks that the room is in a correct state... */ await pubnub.publish({ channel: state.room.id, message: { type: 'message', data: state.message } }) }
Để gửi tệp, trước tiên chúng tôi sẽ tải tệp đó lên PubNub. Sau đó, chúng tôi sẽ lấy URI tệp đã tải lên và xuất bản nó dưới dạng tin nhắn trong phòng trò chuyện:
/* frontend/src/components/UploadButton.jsx */ // Upload Encrypted file const uploadData = await pubnub.sendFile({ channel: room.id, file: myFile, storeInHistory: false }) const fileURL = await pubnub.getFileUrl({ id: uploadData.id, name: uploadData.name, channel: room.id }) await pubnub.publish({ channel: state.room.id, message: { type: 'file', url: fileURL, fileName: await sealdSession.encryptMessage(selectedFiles[0].name) } })
Để tạo và quản lý nhóm, chúng ta sẽ cần một giao diện để chọn người dùng:
Khi các thành viên nhóm đã được chọn, chúng tôi có thể tạo kênh PubNub cho phòng của mình, sau đó đặt siêu dữ liệu và tư cách thành viên cho kênh. Mã này rất giống với mã được thực hiện cho phòng one2one, vì vậy chúng tôi sẽ không lặp lại ở đây.
Bây giờ chúng tôi có một ứng dụng trò chuyện đầy đủ. Hãy thêm mã hóa đầu cuối cho mọi tin nhắn!
Để bắt đầu với Seald, hãy tạo tài khoản dùng thử miễn phí
Khi truy cập bảng điều khiển Seald, một số URL và mã thông báo API sẽ được hiển thị.
Nhận các yếu tố sau:
Chúng tôi sẽ thêm các khóa này vào settings.json
:
{ "PUBNUB_PUB_KEY": "pub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "PUBNUB_SUB_KEY": "sub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "SEALD_APP_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "SEALD_API_URL": "https://api.staging-0.seald.io", "SEALD_KEYSTORAGE_URL": "https://ssks.staging-0.seald.io" }
Để có thể sử dụng Seald SDK, mọi người dùng đều cần có giấy phép JWT khi đăng ký.
Những JWT này cần được tạo ở phần phụ trợ bằng cách sử dụng bí mật và ID bí mật.
Từ trang đích của bảng điều khiển, sao chép bí mật JWT và ID được liên kết của nó trong backend/settings.json
:
{ "SEALD_JWT_SECRET_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "SEALD_JWT_SECRET": "XXXXXXXXXXXXXXXXXXXXXXXX" }
Trong cuộc gọi API đăng ký, chúng tôi sẽ tạo giấy phép Seald JWT và trả lại:
/* backend/routes/account.js */ const token = new SignJWT({ iss: settings.SEALD_JWT_SECRET_ID, jti: uuidv4(), /// Random string with enough entropy to never repeat. iat: Math.floor(Date.now() / 1000), // JWT valid only for 10 minutes. `Date.now()` returns the in milliseconds, this needs it in seconds. scopes: [3], // PERMISSION_JOIN_TEAM join_team: true }) .setProtectedHeader({ alg: 'HS256' }) const signupJWT = await token.sign(Buffer.from(settings.SEALD_JWT_SECRET, 'ascii'))
Để biết thêm thông tin về điều này, xem
Sau đó, chúng ta cần cài đặt Seald SDK.
Chúng ta cũng cần cài đặt một plugin để nhận dạng người dùng trên máy chủ Seald. Để làm như vậy, chúng tôi sẽ sử dụng gói sdk-plugin-ssks-password
.
Plugin này cho phép xác thực mật khẩu đơn giản của người dùng:
npm i -S @seald-io/sdk @seald-io/sdk-plugin-ssks-password
Sau đó, chúng ta sẽ tạo một tệp seald.js
. Trong tệp này, chúng ta sẽ bắt đầu bằng cách tạo một hàm để khởi tạo Seald SDK:
/* frontend/src/services/seald.js */ import SealdSDK from '@seald-io/sdk-web' import SealdSDKPluginSSKSPassword from '@seald-io/sdk-plugin-ssks-password' import settings from './settings.json' let sealdSDKInstance = null const instantiateSealdSDK = async () => { sealdSDKInstance = SealdSDK({ appId: settings.SEALD_APP_ID, apiURL: settings.SEALD_API_URL, plugins: [SealdSDKPluginSSKSPassword(settings.SEALD_KEYSTORAGE_URL)] }) }
Trong seald.js
, chúng ta cũng sẽ thêm hai hàm: một để tạo danh tính và một để truy xuất một danh tính. Để tạo danh tính, chúng ta cũng cần có giấy phép JWT được trả về khi tạo tài khoản:
/* frontend/src/services/seald.js */ export const createIdentity = async ({ userId, password, signupJWT }) => { await instantiateSealdSDK() await sealdSDKInstance.initiateIdentity({ signupJWT }) await sealdSDKInstance.ssksPassword.saveIdentity({ userId, password }) } export const retrieveIdentity = async ({ userId, password }) => { await instantiateSealdSDK() await sealdSDKInstance.ssksPassword.retrieveIdentity({ userId, password }) }
Trong quá trình đăng ký và đăng nhập, chúng tôi chỉ cần gọi các chức năng này sau khi người dùng đăng nhập.
Tại thời điểm này, mỗi khi người dùng của chúng tôi được kết nối, anh ấy có SDK Seald đang hoạt động, sẵn sàng mã hóa và giải mã!
Cảnh báo: Để bảo mật thích hợp, mật khẩu phải được băm trước trước khi gửi đến máy chủ xác thực. Để biết thêm chi tiết về điều này, vui lòng xem
Mỗi phòng trò chuyện sẽ được liên kết với một encryptionSession
trên SDK Seald.
Mỗi lần tạo phòng trò chuyện, chúng ta chỉ cần thêm một dòng để tạo phiên mã hóa, sau đó sử dụng:
/* frontend/src/App.js */ // Create a Seald session const sealdSession = await getSealdSDKInstance().createEncryptionSession( { userIds: [currentUser.id, m.id] }, { metadata: newRoomId } ) // Publish a "Hello" message in the room await pubnub.publish({ channel: newRoomId, message: { type: 'message', data: (await sealdSession.encryptMessage('Hello 👋')) } })
Lưu ý rằng theo mặc định, người dùng của chúng tôi được bao gồm là người nhận encryptionSession.
Khi truy cập vào một phòng, chúng ta cần lấy encryptionSession
tương ứng. Nó có thể được lấy ra từ bất kỳ tin nhắn hoặc tập tin được mã hóa nào. Sau khi có nó, chúng tôi sẽ giữ nó trong tài liệu tham khảo thành phần.
Sau đó, chúng ta có thể chỉ cần sử dụng các hàm session.encryptMessage
, session.encryptFile
, session.decryptMessage
và session.decryptFile
.
Hãy bắt đầu với những tin nhắn. Gửi tin nhắn:
/* frontend/src/components/Chat.js */ const handleSubmitMessage = async m => { /* Some validation that we are in a valid room... */ // if there is no encryption session set in cache yet, create one // (should never happen, as a "Hello" is sent on room creation) if (!sealdSessionRef.current) { sealdSessionRef.current = await getSealdSDKInstance().createEncryptionSession( { userIds: state.room.users }, { metadata: state.room.id } ) } // use the session to encrypt the message we are trying to send const encryptedMessage = await sealdSessionRef.current.encryptMessage(state.message) // publish the encrypted message to pubnub await pubnub.publish({ channel: state.room.id, message: { type: 'message', data: encryptedMessage } }) /* Some cleanup... */ }
Và khi chúng tôi nhận được tin nhắn:
/* frontend/src/components/Chat.js */ const decryptMessage = async m => { /* Filter out files... */ let encryptedData = m.message.data if (!sealdSessionRef.current) { // no encryption session set in cache yet // we try to get it by parsing the current message sealdSessionRef.current = await getSealdSDKInstance().retrieveEncryptionSession({ encryptedMessage: encryptedData }) // now that we have a session loaded, let's decrypt } const decryptedData = await sealdSessionRef.current.decryptMessage(encryptedData) // we have successfully decrypted the message return { ...m, uuid: m.uuid || m.publisher, value: decryptedData } /* Some error handling... */ } /* Other stuff... */ const handleReceiveMessage = async m => { const decryptedMessage = await decryptMessage(m) setState(draft => { draft.messages = [...draft.messages, decryptedMessage] }) }
Ngoài ra, chúng tôi sử dụng chức năng decryptMessage
này để giải mã tất cả các tin nhắn đã có trong phiên khi mở phòng:
/* frontend/src/components/Chat.js */ const fetchedMessages = (await pubnub.fetchMessages({ channels: [currentRoomId] })).channels[currentRoomId] const clearMessages = fetchedMessages ? await Promise.all(fetchedMessages.map(decryptMessage)) : []
Và bây giờ cho các tập tin. Để tải lên một tập tin:
/* frontend/src/components/UploadButton.js */ // Encrypt file const encryptedBlob = await sealdSession.encryptFile( selectedFiles[0], selectedFiles[0].name, { fileSize: selectedFiles[0].size } ) const encryptedFile = new File([encryptedBlob], selectedFiles[0].name) // Upload Encrypted file const uploadData = await pubnub.sendFile({ channel: room.id, file: encryptedFile, storeInHistory: false })
Và để giải mã một tập tin:
/* frontend/src/components/Message.js */ const onClick = async () => { if (state.data.type === 'file') { const response = await fetch(state.data.url) const encryptedBlob = await response.blob() const { data: clearBlob, filename } = await sealdSession.decryptFile(encryptedBlob) const href = window.URL.createObjectURL(clearBlob) /* Create an <a> element and simulate a click on it to download the created objectURL */ } }
Các cuộc trò chuyện nhóm cũng sẽ có encryptionSession
. Mỗi khi một nhóm được tạo, chúng ta cần tạo một nhóm:
/* frontend/src/components/ManageDialogRoom.js.js */ // To create the encryptionSession const sealdSession = await getSealdSDKInstance().createEncryptionSession( { userIds: dialogRoom.selectedUsersId }, { metadata: newRoomId } )
Sau đó, mỗi khi chúng tôi sửa đổi thành viên nhóm, chúng tôi sẽ cần thêm hoặc xóa họ khỏi nhóm:
/* frontend/src/components/ManageDialogRoom.js.js */ // we compare old and new members to figure out which ones were just added or removed const usersToRemove = dialogRoom.room.users.filter(id => !dialogRoom.selectedUsersId.includes(id)) const usersToAdd = dialogRoom.selectedUsersId.filter(id => !dialogRoom.room.users.includes(id)) if (usersToAdd.length > 0) { // for every added user, add them to the Seald session await dialogRoom.sealdSession.addRecipients({ userIds: usersToAdd }) // then add them to the pubnub channel await pubnub.objects.setChannelMembers({ channel: dialogRoom.room.id, uuids: usersToAdd }) } if (usersToRemove.length > 0) { // for every removed user, revoke them from the Seald session await dialogRoom.sealdSession.revokeRecipients({ userIds: usersToRemove }) // then remove them from the pubnub channel for (const u of usersToRemove) { await pubnub.objects.removeMemberships({ channels: [dialogRoom.room.id], uuid: u }) } }
Khi đã xong, chúng ta đã hoàn thành!
Chúng tôi đã tích hợp được Seald vào PubNub chỉ bằng một vài dòng mã.
Giờ đây, cuộc trò chuyện đã được mã hóa hai đầu, bạn có thể đảm bảo với người dùng rằng dữ liệu của họ sẽ được giữ bí mật, ngay cả trong trường hợp dữ liệu bị vi phạm.
Như mọi khi, đừng ngần ngại
Rất nóng lòng muốn xem những gì bạn đã xây dựng được 🥳.
Cũng được xuất bản ở đây.