In my previous article, I explained how to build a censorship-resistant image uploader with the help of Web3Storage. The idea was to upload images to Filecoin via the Web3Storage service so they get a content ID (CID) that allows others to pin them on the IPFS storage provider of their choice without the need to ask for permissions.
What Will We Build?
In this article, we will build an image uploader that lets us encrypt the images so only people with the correct permissions can access them.
The idea is that users can upload files and ensure only people with an NFT or the right amount of a specific token can see them.
This is a simple way to add utility to any token without incurring costs for the owners.
What Tech Will We Use?
We will use React and TypeScript to build the application and a few services that facilitate the more complex part of the architecture.
The first service is Web3Auth, which lets us authenticate users via Web2 methods like social logins or email. It generates a key pair and securely stores it on the decentralized Torus network. This allows people without a crypto wallet to use the service and put the private key into a crypto wallet when they feel ready.
Lit Protocol is the second service, a decentralized access control network. It allows us to encrypt content and define permissions required to decrypt it later. The permissions are based on smart contracts, so we can determine that a user has to own an NFT or enough of a specific token before they get the key to decrypt the file.
The last service we use is the one we know from the previous article, Web3Storage. It allows us to upload our encrypted files to the Filecoin network to access them via IPFS. The cool thing about this service: the accounts are user-owned and not app owned. While Web3Storage allows app-owned accounts, meaning the files will be managed by the app's creator, it also allows user-owned accounts that require the users to manage their uploads. It comes with a generous free tier, so the users can start uploading without paying anything.
How does the App Work?
Look at figure 1 to learn how the different libraries and services fit together.
Web3Auth will use email or social logins to authenticate the user and generate a key pair. The key generation works with a technique called threshold cryptography, which means that the private key will be generated not as one complete key but in three parts, one of which Web3Auth stores on the decentralized Torus network, the other one in the browser storage, and the final part with the social or email login provider. This technique allows only the user to access all three components and assemble them to a private key.
The Lit SDK will generate a symmetric encryption key for every file we want to encrypt and save it to the decentralized Lit Protocol network. It saves access control data with the key. The user needs an Ethereum address to request the encryption key from Lit because the permissions are based on smart contract data.
Web3Storage’s new w3up API is responsible for file storage. Every user can create their own account; all they need is an email address. After that, they can start uploading files which will end up on the Filecoin network.
So the upload flow goes from signin up to Web3Auth (or connecting a wallet) and Web3Storage, encrypts a file with the Lit SDK, and then uploads it with metadata to Filecoin via the w3up API.
The download flow goes from signing up to Web3Auth (or connecting a wallet), downloading the encrypted file and its metadata via IPFS, and decrypting it with the Lit SDK.
Prerequisites
We need a Web3Auth account. While they store the keys decentralized on the Torus network, the social and email logins are managed centralized. This means we don't have to create our own backend.
We also need Node.js.
I also recommend using an online IDE like GitHub Codespaces, GitPod, or AWS Cloud9, if you don't know how to get CORS working on localhost.
Setting Up the Project
First, we need to set up our project with create-react-app. Run the following command:
$ create-react-app encrypted-image-uploader --template typescript
This can take some time.
Adding File Upload
We will start with implementing the upload feature because it doesn’t need much setup, and we can simply plug the encryption before the upload later.
Installing Dependencies
First, we install the packages with this command:
$ npm i @w3ui/react-keyring @w3ui/react-uploader bootswatch reactstrap
@w3ui/react-keyring allows users to log in to Web3Storage and use the w3up API.
@w3ui/react-uploader allows them to upload files via that API.
bootswatch and reactstrap will help us to make the whole thing look more presentable.
Implementing Upload Login
Next, we must create the login component at src/components/UploadLogin.tsx with the following content:
import { useEffect, useState } from "react"
import {
Button,
ButtonGroup,
Input,
Label,
Modal,
ModalBody,
ModalHeader,
} from "reactstrap"
import { useKeyring } from "@w3ui/react-keyring"
export default function UploadLogin() {
const [showModal, setShowModal] = useState(false)
const [
{ space },
{ loadAgent, createSpace, registerSpace, cancelRegisterSpace },
] = useKeyring()
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
loadAgent()
}, [loadAgent])
const uploadEnabled = space?.registered()
async function handleLogin(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) {
e.preventDefault()
setLoading(true)
try {
await createSpace()
await registerSpace(email)
} catch (err) {
throw new Error("failed to register", { cause: err })
} finally {
setLoading(false)
setShowModal(false)
}
}
return (
<>
<Button
color="primary"
outline={!uploadEnabled}
onClick={() => setShowModal(true)}
disabled={uploadEnabled}
>
🗄️ {uploadEnabled ? "W3UP Connected" : "Connect W3UP"}
</Button>
<Modal isOpen={showModal}>
<ModalHeader>Enter W3UP Email</ModalHeader>
<ModalBody className="d-flex flex-column align-items-stretch">
<Label>
Email
<Input
type="email"
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
</Label>
<ButtonGroup>
<Button onClick={handleLogin} color="primary" disabled={loading}>
Submit
</Button>
<Button
onClick={() => {
if (loading) cancelRegisterSpace()
setShowModal(false)
}}
>
Cancel
</Button>
</ButtonGroup>
</ModalBody>
</Modal>
</>
)
}
So much code, whew! But most of it is just UI related, so fret not! Let’s look at the important parts.
The first thing is the many returns of the useKeyring hook. It uses a provider we have to add to src/index.tsx later.
We will call the loadAgent function inside a useEffect hook to set up the current browser as a w3up API agent. This also loads credentials previously stored in the browser, so the user doesn’t have to log in every time they go to our app.
The @w3ui/react-keyring package only comes with hooks. It integrates well with React but has no display components, so we must create them. This gives us more control over the presentation and requires more work.
We create a custom modal for the email input and a button to show this modal.
To trigger the login, the user must click the “Submit” button in the modal. It will call the functions we get from useKeyring: createSpace and registerSpace.
After that, our user is ready to upload some files!
Implementing File Upload
Now that our users can log into Web3Storage, they need a way to upload files. So, we create a component at src/components/FileUpload.tsx with this code:
import { useState } from "react"
import { Button, ButtonGroup, Input, Label, Row, Spinner } from "reactstrap"
import { useKeyring } from "@w3ui/react-keyring"
import { useUploader } from "@w3ui/react-uploader"
import UploadLogin from "./UploadLogin"
export default function FileUpload() {
const [loading, setLoading] = useState(false)
const [{ space }] = useKeyring()
const [, uploader] = useUploader()
const [file, setFile] = useState<File | null>(null)
const [cid, setCid] = useState("")
async function handleUpload() {
if (!file) return
setLoading(true)
// Here would the encryption happen
const cid = await uploader.uploadFile(file)
setLoading(false)
setCid(cid.toString())
}
const uploadEnabled = space?.registered()
return (
<>
<Row>
<ButtonGroup size="lg">
<UploadLogin />
</ButtonGroup>
</Row>
<br />
{uploadEnabled && (
<Row>
<Label>
File
<Input
type="file"
onChange={(e) => {
e.target.files && setFile(e.target.files[0])
}}
/>
</Label>
</Row>
)}
<br />
{uploadEnabled && (
<Row>
<Button
onClick={handleUpload}
color="primary"
block
size="lg"
disabled={!file || loading}
>
📤 Upload {loading && <Spinner size="sm" />}
</Button>
</Row>
)}
<br />
{cid && (
<a href={`https://w3s.link/ipfs/${cid}`}>
https://w3s.link/ipfs/ ${cid}
</a>
)}
</>
)
}
The first version of our FileUpload component doesn’t include any encryption; it just checks if the user is logged into Web3Storage and presents them with a UI for file selection and a button to start the upload. It also uses our UploadLogin component from the last step.
We will get a content ID to download the file via IPFS to IPFS. Since we didn’t implement any encryption yet, everyone with the CID can download and view our files, so we can just show a link to the file to check if the upload worked.
Pulling it All Together
Now, we must put it together in the App component. Create React App already created it for us at src/App.tsx, so we just have to replace its content with the following code:
import { Container, Row } from "reactstrap"
import FileUpload from "./components/FileUpload"
export default function App() {
return (
<Container>
<Row>
<h1>Encrypted Image Uploader</h1>
</Row>
<FileUpload />
</Container>
)
}
We also have to update src/index.tsx to include the w3ui providers and the Bootstrap CSS file:
import React from "react"
import ReactDOM from "react-dom/client"
import { KeyringProvider } from "@w3ui/react-keyring"
import { UploaderProvider } from "@w3ui/react-uploader"
import "bootswatch/dist/quartz/bootstrap.min.css"
import App from "./App"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<KeyringProvider>
<UploaderProvider>
<App />
</UploaderProvider>
</KeyringProvider>
</React.StrictMode>
)
Each w3ui package comes with its own provider, one for the login and one for the upload.
Testing the App
Now that we’re halfway through, we can try things out!
Run the following command:
$ npm start
If we open the page in the browser, we should see something like the one in figure 2.
Nothing impressive here, but we can click the button and complete the email-based login flow to see more, like in figure 3.
Here we can upload files and get IPFS gateway URLs for every upload. We can use and share this just like any other URL.
Adding Encryption
Now that the upload works, we have to encrypt the files before uploading them and create a download page so our users can decrypt the file after downloading it.
We need the Lit SDK for the encryption, but we also need an Ethereum key pair since Lit uses blockchain networks and their smart contracts for access control.
Setting Up Web3Auth
Log into your Web3Auth account and click on “Create Project”. Figure 3.1 shows the required inputs.
Select EVM compatible in the chain-config. After we click “Create”.
Copy the client ID for the EncryptionLogin component and add your dev server to the “Whitelisted URLs”; otherwise, the Web3Auth modal will be blank.
Installing Dependencies
We start by installing the dependencies with the following command:
$ npm i react-app-rewired @lit-protocol/sdk-browser @web3auth/modal ethers crypto-browserify stream-browserify assert stream-http https-browserify os-browserify url buffer process
So many packages!
We need the Lit SDK to connect to the Lit Protocol and the encryption and Ethers to handle the signing of messages.
Then, we need the Web3Auth packages; the modal package comes with everything we need to authenticate users from our app, and the base package comes with a constant we need to configure the proper chain ID for Ethereum.
The rest of the packages are for the build system. create-react-app uses Webpack 5, which doesn’t come with polyfills anymore, so we need to use react-app-rewired to reconfigure the underlying Webpack with the missing polyfills for Web3Auth.
Rewiring Create-React-App
To make create-react-app cares about the new Webpack config, we need to use the react-app-rewrite package. For this, we need to replace the NPM scripts in our package.json file with this:
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"eject": "react-scripts eject"
},
Now, the scripts will pick up a config file that allows more configurations for our build process. We need the actual config code. Create a config-overrides.js in the project root directory and add this code:
const webpack = require("webpack")
module.exports = function override(config) {
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("stream-browserify"),
assert: require.resolve("assert"),
http: require.resolve("stream-http"),
https: require.resolve("https-browserify"),
os: require.resolve("os-browserify"),
url: require.resolve("url"),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: "process/browser",
Buffer: ["buffer", "Buffer"],
}),
])
config.ignoreWarnings = [/Failed to parse source map/]
config.module.rules.push({
test: /\.(js|mjs|jsx)$/,
enforce: "pre",
loader: require.resolve("source-map-loader"),
resolve: {
fullySpecified: false,
},
})
return config
}
This config file will ensure that Webpack includes all the polyfills required for Web3Auth to build.
Implementing Encryption Login
The first thing we need is a private key; if the user doesn’t have a crypto wallet, we will get it from Web3Auth. For this, we create a new component at src/component/EncryptionLogin.tsx with this code:
import { Button } from "reactstrap"
import { useEffect, useState } from "react"
import { Web3Auth, Web3AuthOptions } from "@web3auth/modal"
//@ts-expect-error
import LitJsSdk from "@lit-protocol/sdk-browser"
import { providers } from "ethers"
function useWeb3Auth(options: Web3AuthOptions) {
const [web3auth, setWeb3Auth] = useState<null | Web3Auth>(null)
useEffect(() => {
async function initWeb3Auth() {
const web3auth = new Web3Auth(options)
await web3auth.initModal()
setWeb3Auth(web3auth)
}
initWeb3Auth()
}, [options])
return web3auth
}
function useLitClient() {
const [litNodeClient, setLitClient] = useState<LitJsSdk.LitNodeClient | null>(
null
)
useEffect(() => {
async function initLit() {
const litNodeClient = new LitJsSdk.LitNodeClient()
await litNodeClient.connect()
setLitClient(litNodeClient)
}
initLit()
}, [])
return litNodeClient
}
const web3authOptions = {
clientId: "<WEB3AUTH_CLIENT_ID>",
web3AuthNetwork: "testnet",
chainConfig: {
chainNamespace: "eip155",
chainId: "0x1",
rpcTarget: "https://rpc.ankr.com/eth",
},
}
export type Encrypter = (
file: File,
accessControlConditions: any
) => Promise<Blob>
export type Decrypter = (file: Blob) => Promise<File>
export interface EncryptDecrypt {
encrypt: Encrypter
decrypt: Decrypter
}
export interface EncryptionLoginProps {
onLogin: ({ encrypt, decrypt }: EncryptDecrypt) => void
}
export function EncryptionLogin(props: EncryptionLoginProps) {
const litNodeClient = useLitClient()
const web3auth = useWeb3Auth(web3authOptions)
async function handleLogin() {
if (!web3auth) return
await web3auth.connect()
}
const encryptionEnabled = !!web3auth?.provider
useEffect(() => {
async function initEncryption() {
if (!web3auth || !web3auth.provider) return
const provider = new providers.Web3Provider(web3auth.provider)
const network = await provider.getNetwork()
const userAddress = (
await provider.getSigner().getAddress()
).toLowerCase()
async function encrypt(file: File, accessControlConditions: any) {
const authSig = await LitJsSdk.signAndSaveAuthMessage({
web3: provider,
account: userAddress,
chainId: network.chainId,
})
if (accessControlConditions.length > 0)
accessControlConditions.push({ operator: "or" })
accessControlConditions.push({
contractAddress: "",
standardContractType: "",
chain: "ethereum",
method: "",
parameters: [":userAddress"],
returnValueTest: { comparator: "=", value: userAddress },
})
const { zipBlob } = await LitJsSdk.encryptFileAndZipWithMetadata({
litNodeClient,
chain: "ethereum",
authSig,
accessControlConditions,
file,
})
return zipBlob<Blob>
}
async function decrypt(file: Blob) {
const authSig = await LitJsSdk.signAndSaveAuthMessage({
web3: provider,
account: userAddress,
chainId: network.chainId,
})
const { decryptedFile, metadata } =
await LitJsSdk.decryptZipFileWithMetadata({
litNodeClient,
authSig,
file,
})
return new File([decryptedFile], metadata.name, { type: metadata.type })
}
props.onLogin({ encrypt, decrypt })
}
initEncryption()
}, [encryptionEnabled, litNodeClient, props, web3auth])
return (
<>
<Button onClick={handleLogin} color="info" outline={!encryptionEnabled} disabled={encryptionEnabled}>
👛 {encryptionEnabled ? "Wallet Connected" : "Connect Wallet"}
</Button>
</>
)
}
Quite much. But at least it’s structured in functions, so let’s review them.
The useWeb3Auth function sets up the Web3Auth modal, it configures it, but it doesn’t yet display it, let alone try to log in a user. That will come later.
The useLitClient function does the same for the Lit SDK; it also connects the Lit node client because we don’t need anything else from the user then.
The EncryptionLoginProps interface shows the component's user that a onLogin callback is required; our component will call it after a successful login. The interfaces are just to define the object we will pass to the onLogin function later. After login, a user will get encrypt and decrypt functions to do the encryption.
Our component uses the hooks described above and then runs its own useEffect hook, which will handle the creation of the encrypt and decrypt functions after a user logs in with Web3Auth.
The Web3Auth modal gives us a generic provider, and we wrap it into a Web3Provider from the ethers library, which lets us extract all the data we need for the encryption, which are the user’s address on the Ethereum network and which network the user is actually connected to. These allow us to create authentication signatures that are needed for file encryption.
The encrypt function we create is later called in the FileUpload component. It takes a file to encrypt and several access control conditions. The function adds one extra condition to the list so that every uploader can decrypt their own file regardless of the other conditions.
In the end, the function encrypts the file and zips it, together with the metadata, into a file archive. This archive is returned as a Blob to the caller of the encrypt method so they can upload it somewhere.
The decrypt method is a bit simpler since it just needs the signature. That’s because the Lit SDK already puts the other metadata into the archive and can extract it before decryption.
Finally, we create a button that starts the login and function creation procedure.
Don’t forget to replace the <WEB3AUTH_CLIENT_ID> with your own!
Updating File Upload
We have to integrate the EncryptionLogin component into our FileUpload component so it can use the encryption before every upload.
To do so, let’s add the following code to src/component/FileUpload.tsx.
Importing the new component at the top:
import {
EncryptionLogin,
EncryptDecrypt
} from "./EncryptionLogin"
Adding new hooks to the FileUpload component, right below the already existing ones:
const [contractAddress, setContractAddress] = useState(
"0x25ed58c027921E14D86380eA2646E3a1B5C55A8b"
)
const [tokensRequired, setTokensRequired] = useState(0)
const [nftRequired, setNftRequired] = useState(false)
const [crypto, setCrypto] = useState<EncryptDecrypt | null>(null)
As the beginning explains, the Lit Protocol uses smart contracts for access control. So, we need to ask the user which smart contract to use and what it should check on this contract.
In this case, we use Ethereum smart contracts and check if the user either has an ERC-721 NFT from that contract or a specific amount of ERC-20 tokens from it.
Next, we need to add the actual encryption to our handleUpload function.
async function handleUpload() {
if (!file) return
setLoading(true)
let fileForUpload: Blob = file
if (crypto) {
const accessControlConditions = []
if (tokensRequired > 0)
accessControlConditions.push({
standardContractType: "ERC20",
contractAddress,
chain: "ethereum",
method: "balanceOf",
parameters: [":userAddress"],
returnValueTest: {
comparator: ">",
value: tokensRequired.toString(),
},
})
if (nftRequired && tokensRequired)
accessControlConditions.push({ operator: "or" })
if (nftRequired)
accessControlConditions.push({
standardContractType: "ERC721",
chain: "ethereum",
contractAddress,
method: "balanceOf",
parameters: [":userAddress"],
returnValueTest: { comparator: ">", value: "0" },
})
fileForUpload = await crypto?.encrypt(file, accessControlConditions)
}
if (!fileForUpload) return
const cid = await uploader.uploadFile(fileForUpload)
setLoading(false)
setCid(cid.toString())
}
If we set the crypto state, we will run the encryption function on our file. It needs the prementioned accessControlConditions, so we set them up with the data we will ask from our user. Check out the Lit documentation, for more examples of Ethereum-based access control.
The uploader doesn’t care if it uploads a plaintext or an encrypted file, so we can execute the previous unencrypted file upload feature when the user didn’t log in with Web3Auth.
At the end of the FileUpload component, we need to add the UI for the encryption:
const uploadEnabled = space?.registered()
const encryptionEnabled = !!crypto
return (
<>
<Row>
<ButtonGroup size="lg">
<EncryptionLogin onLogin={(crypto) => setCrypto(crypto)} />
<UploadLogin />
</ButtonGroup>
</Row>
<br />
{uploadEnabled && (
<Row>
<Label>
File
<Input
type="file"
onChange={(e) => {
e.target.files && setFile(e.target.files[0])
}}
/>
</Label>
</Row>
)}
{uploadEnabled && encryptionEnabled && (
<>
<Row>
<Label>
Smart Contract Address
<Input
type="text"
value={contractAddress}
placeholder="0x0"
onChange={(e) => setContractAddress(e.target.value)}
/>
</Label>
</Row>
<Row>
<Label>
Required Token Amount in Wei
<Input
type="number"
placeholder="0"
onChange={(e) => setTokensRequired(parseFloat(e.target.value))}
/>
</Label>
</Row>
<Row>
<Label>
<Input
type="checkbox"
onChange={(e) => setNftRequired(e.target.checked)}
/>
{" NFT Required"}
</Label>
</Row>
</>
)}
<br />
{uploadEnabled && (
<Row>
<Button
onClick={handleUpload}
color="primary"
block
size="lg"
disabled={!file || loading}
>
📤 Upload {loading && <Spinner size="sm" />}
</Button>
</Row>
)}
<br />
{cid && (
<a
href={`${window.location.href}#${cid}`}
rel="noreferrer"
target="_blank"
>
Open the download page!
</a>
)}
</>
)
This updated UI includes the EncryptionLogin button we created in the previous step. We also added input fields to gather the access control conditions from the user, like the smart contract address, the amount of ERC-20 tokens, and the NFT requirement.
The link at the end will open our app with the CID in the URL hash. This way, the app can start in download mode if a hash is present. But right now, it simply ignores the hash.
Testing Encrypted Upload
To test the updated app, we need to run the dev server:
$ npm start
After the build runs, the updated app should look like figure 4.
If everything works correctly, the app should look like in figure 5 after we login to both services.
Now, we can upload encrypted files that only people with the right NFT or token amount can decrypt. But well, right now, they can’t because we need to implement the download and decryption first.
Implement Decryption and Download
We already updated the UploadFile component with a link that adds the CID to the URL hash; now, we have to tell the app what to do if a hash is present.
We already implemented the decrypt function inside the EcryptionLogin component, we can use it here. Let’s create a FileDownload component at src/component/FileDownload.tsx with this implementation:
import { Button, ButtonGroup, Row, Spinner } from "reactstrap"
import { EncryptionLogin, EncryptDecrypt } from "./EncryptionLogin"
import { useState } from "react"
export default function FileDownload() {
const [loading, setLoading] = useState(false)
const [crypto, setDecrypt] = useState<EncryptDecrypt | null>(null)
const [imageUrl, setImageUrl] = useState("")
const [imageName, setImageName] = useState("")
async function handleDownload() {
if (!crypto) return
setLoading(true)
const cid = window.location.hash.substring(1)
const response = await fetch(`https://w3s.link/ipfs/${cid}`)
const encryptedFile = await response.blob()
const decryptedFile = await crypto.decrypt(encryptedFile)
setImageUrl(window.URL.createObjectURL(decryptedFile))
setImageName(decryptedFile.name)
setLoading(false)
}
return (
<>
<Row>
<ButtonGroup size="lg">
<EncryptionLogin onLogin={(crypto) => setDecrypt(crypto)} />
<Button
onClick={handleDownload}
color="primary"
disabled={!crypto || loading}
>
📥 Download & Decrypt File {loading && <Spinner size="sm" />}
</Button>
</ButtonGroup>
</Row>
{imageUrl && (
<Row>
<img src={imageUrl} alt={imageName} />
</Row>
)}
</>
)
}
The user needs to authenticate with Web3Auth. Then we fetch the encrypted file via an IPFS gateway. Next, call the decrypt method, which comes preconfigured with Web3Auth and Lit SDK credentials. The function returns the plaintext file, which we display in an img element.
To link it up, we have to update the src/App.tsx file what to do if the URL hash a hash:
import { Container, Row } from "reactstrap"
import FileUpload from "./components/FileUpload"
import FileDownload from "./components/FileDownload"
export default function App() {
return (
<Container>
<Row>
<h1>Encrypted Image Uploader</h1>
</Row>
{window.location.hash ? <FileDownload /> : <FileUpload />}
</Container>
)
}
A URL with a hash will render the download, and a URL without a hash will render the upload.
Testing it All
To test everything we implemented, we have to start the dev server with NPM:
$ npm start
Again, we have to upload a file and select some encryption options. If the upload worked correctly, the app would show us a link to the URL with the CID in the hash. If we click on it, we will see the DownloadFile component. Click on the “Download & Decrypt” button, which should look like in figure 6.
Conclusion
What a journey! Putting a bunch of libraries together to build something useful sounds quite simple at the start, but as always, the devil is in the details.
Uploading files to Web3Storage is a walk in the park, but getting Web3Auth to work with Webpack and then understanding all the access control conditions and chain configurations of Lit Protocol is easier said than done!
Luckily, they all play well together in the end, and their great flexibility allows us to build different applications with them as a base.
留言