Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add read-only permission for organizations and dynamic token check at current block #4

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Some sane defaults provided
NEXT_PUBLIC_URL="http://localhost:3000"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="NpUFdWakhCjbuIIogCvj"
GITHUB_CLIENT_ID=""
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ npm run dev

## Environment variables

1. `NEXTAUTH_URL`: Site link, `http://localhost:3000` if developing locally, `https://gaterepo.com` for this deployed instance
1. `NEXTAUTH_URL` and `NEXT_PUBLIC_URL`: Set both as site link, `http://localhost:3000` if developing locally, `https://gaterepo.com` for this deployed instance
2. `NEXTAUTH_SECRET`: Any randomly generated string as a secret, e.g.: `NpUFdWakhCjbuIIogCvj`
3. `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`: Follow the instructions [here](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app) for spinning up a new GitHub OAuth application. When asked, the authorization callback URL is `http://localhost:3000/api/auth/callback/github` (local) or `https://your_domain.com/api/auth/callback/github` (deployed). Once setup, your OAuth applications `Client ID` is your `GITHUB_CLIENT_ID` and your `Client Secret` is your `GITHUB_CLIENT_SECRET`
4. `DATABASE_URL`: Postgres database connection URL
Expand Down
42 changes: 35 additions & 7 deletions pages/api/gates/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
address, // Ethereum address with tokens
signature, // Signature verifying Ethereum address ownership
gateId, // Gated repo ID
}: { address: string; signature: string; gateId: string } = req.body;
readOnly, // Read-only permission
dynamicCheck, // Dynamic token check
}: {
address: string;
signature: string;
gateId: string;
readOnly: boolean;
dynamicCheck: boolean;
} = req.body;
if (!address || !signature || !gateId) {
res.status(500).send({ error: "Missing parameters." });
return;
Expand Down Expand Up @@ -145,12 +153,31 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
}

// Check if address held necessary tokens
const numTokensHeld: number = await collectVotesForToken(
address,
gate.contract,
gate.contractDecimals,
gate.blockNumber
);
let numTokensHeld: number;

// If dynamic check is enabled, check at current block
if (dynamicCheck) {
const defaultProvider = new ethers.providers.JsonRpcProvider(
process.env.RPC_API
);
const selector = "0x70a08231"; // balanceOf(address)
const data = selector + ethers.utils.hexZeroPad(address, 32).slice(2);
numTokensHeld =
Number(
await defaultProvider.call({
to: gate.contract,
data,
})
) /
10 ** gate.contractDecimals;
} else {
numTokensHeld = await collectVotesForToken(
address,
gate.contract,
gate.contractDecimals,
gate.blockNumber
);
}
if (gate.numTokens > numTokensHeld) {
res.status(500).send({ error: "Insufficient token balance." });
return;
Expand Down Expand Up @@ -230,6 +257,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
owner: gate.repoOwner,
repo: gate.repoName,
username,
permission: readOnly ? "pull" : undefined,
});
// If invitation id exists, update variable
if (id) invitationId = id;
Expand Down
19 changes: 16 additions & 3 deletions pages/api/gates/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const getERC20Details = async (
* @param {string} contract address
* @param {number} numTokens count
* @param {number} numInvites count
* @param {boolean} readOnly permission
* @returns {Promise<string>} gated repository id
*/
const createGatedRepo = async (
Expand All @@ -54,7 +55,9 @@ const createGatedRepo = async (
repo: string,
contract: string,
numTokens: number,
numInvites: number
numInvites: number,
readOnly: boolean,
dynamicCheck: boolean
): Promise<string> => {
// Check if you have permission to repo
const repository: Repo = await getRepo(userId, owner, repo);
Expand All @@ -63,7 +66,9 @@ const createGatedRepo = async (
// Collect ERC20 details
const { name, decimals } = await getERC20Details(contract);
// Collect latest block number to peg balance to
const blockNumber: number = await provider.getBlockNumber();
const blockNumber: number = !dynamicCheck
? await provider.getBlockNumber()
: 0;

// Create and return gated repo entry
const { id }: { id: string } = await db.gate.create({
Expand All @@ -76,6 +81,8 @@ const createGatedRepo = async (
contractDecimals: decimals,
numTokens,
numInvites,
readOnly,
dynamicCheck,
creator: {
connect: {
id: userId,
Expand Down Expand Up @@ -104,12 +111,16 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
contract,
tokens,
invites,
readOnly,
dynamicCheck,
}: {
owner: string;
repo: string;
contract: string;
tokens: number;
invites: number;
readOnly: boolean;
dynamicCheck: boolean;
} = req.body;
if (!owner || !repo || !isValidAddress(contract) || !tokens || !invites) {
res.status(500).send({ error: "Missing parameters." });
Expand All @@ -124,7 +135,9 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
repo,
contract,
tokens,
invites
invites,
readOnly,
dynamicCheck
);
res.status(200).send({ id: gateId });
} catch (e) {
Expand Down
3 changes: 3 additions & 0 deletions pages/api/github/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ export const getRepo = async (
throw new Error("Repo does not exist or no access.");
}

const isOrg = repository.owner.type === "Organization";

return {
fullName: repository.full_name,
htmlURL: repository.html_url,
isOrg,
};
};

Expand Down
3 changes: 3 additions & 0 deletions pages/api/github/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
export type Repo = {
fullName: string;
htmlURL: string;
isOrg: boolean;
};

/**
Expand Down Expand Up @@ -72,11 +73,13 @@ export const getRepos = async (userId: string): Promise<Repo[]> => {
if (repo.full_name in repoExist) {
continue;
}
const isOrg = repo.owner.type === "Organization";

if (!repo.archived && !repo.disabled && repo.permissions?.admin) {
repos.push({
fullName: repo.full_name,
htmlURL: repo.html_url,
isOrg,
});
// Update duplicates check
repoExist[repo.full_name] = true;
Expand Down
26 changes: 18 additions & 8 deletions pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,9 @@ function IndividualGate({
* @param {string} gateId to copy
*/
const copyInvite = (gateId: string) => {
const domain = process.env.NEXT_PUBLIC_URL;
// Copy to clipboard
navigator.clipboard.writeText(`https://gaterepo.com/repo/join/${gateId}`);
navigator.clipboard.writeText(`${domain}/repo/join/${gateId}`);

// Update button
setCopyText("Copied!");
Expand Down Expand Up @@ -237,14 +238,23 @@ function IndividualGate({
</p>
<p>
<strong>Token Check Block Number:</strong>{" "}
<a
href={`https://etherscan.io/block/${gate.blockNumber}`}
target="_blank;"
rel="noopener noreferrer"
>
#{formatNumber(gate.blockNumber)}
</a>
{!gate.dynamicCheck ? (
<a
href={`https://etherscan.io/block/${gate.blockNumber}`}
target="_blank;"
rel="noopener noreferrer"
>
#{formatNumber(gate.blockNumber)}
</a>
) : (
"Current block (dynamic)"
)}
</p>
{gate.readOnly ? (
<p>
<strong>Permission:</strong> Read-only
</p>
) : null}
</div>

{/* Actions */}
Expand Down
33 changes: 32 additions & 1 deletion pages/repo/create/[owner]/[repo].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export default function Create({
const [numTokens, setNumTokens] = useState<number>(1); // Number of required tokens
const [loading, setLoading] = useState<boolean>(false); // Loading
const [numParticipants, setNumParticipants] = useState<number>(10); // Maximum invite count
const [readOnly, setReadOnly] = useState<boolean>(false); // Read only permission
const [dynamicCheck, setDynamicCheck] = useState<boolean>(false); // Dynamic token check

// Input validation
const invalidAddress: boolean = !isValidAddress(address);
Expand All @@ -66,9 +68,12 @@ export default function Create({
contract: address,
tokens: numTokens,
invites: numParticipants,
readOnly,
dynamicCheck,
});
const domain = process.env.NEXT_PUBLIC_URL;
// Copy invite to clipboard
navigator.clipboard.writeText(`https://gaterepo.com/repo/join/${id}`);
navigator.clipboard.writeText(`${domain}/repo/join/${id}`);

// Toast and return to home
toast.success("Successfully created gated repository. Invite copied.");
Expand Down Expand Up @@ -128,6 +133,32 @@ export default function Create({
onChange={(e) => setNumParticipants(Number(e.target.value))}
/>

{/* Dynamic token check */}
<label htmlFor="dynamicCheck" className={styles.checkbox}>
<input
id="dynamicCheck"
type="checkbox"
checked={dynamicCheck}
onChange={() => setDynamicCheck(!dynamicCheck)}
/>
Dynamic token check
</label>

{/* Read only permission - Show only if repo is owned by org */}
{repo.isOrg ? (
<>
<label htmlFor="readOnly" className={styles.checkbox}>
<input
id="readOnly"
type="checkbox"
checked={readOnly}
onChange={() => setReadOnly(!readOnly)}
/>
Read-only access
</label>
</>
) : null}

{/* Create gated repository */}
<button
onClick={() => createGate()}
Expand Down
30 changes: 20 additions & 10 deletions pages/repo/join/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default function Join({ gate }: { gate: GateExtended }) {
const [connectionStarted, setConnectionStarted] = useState<boolean>(false);
// Web3React setup
const { active, account, activate, deactivate, library } = useWeb3React();
const domain = process.env.NEXT_PUBLIC_URL;

// Templated content
const templateDescription: string = gate.creator.name
Expand Down Expand Up @@ -108,6 +109,8 @@ export default function Join({ gate }: { gate: GateExtended }) {
address: account,
signature,
gateId: gate.id,
readOnly: gate.readOnly,
dynamicCheck: gate.dynamicCheck,
});

// If successful, toast and redirect
Expand All @@ -134,7 +137,7 @@ export default function Join({ gate }: { gate: GateExtended }) {
<Meta
title={`GateRepo - @${gate.repoOwner}/${gate.repoName}`}
description={templateDescription}
url={`https://gaterepo.com/repo/join/${gate.id}`}
url={`${domain}/repo/join/${gate.id}`}
/>

{/* Logo */}
Expand All @@ -154,7 +157,8 @@ export default function Join({ gate }: { gate: GateExtended }) {
<div className={styles.join__wallet}>
<h3>{!active ? "Connect Wallet" : "Join Repository"}</h3>
<p>
Accessing this repository requires having held{" "}
Accessing this repository requires{" "}
{gate.dynamicCheck ? "holding " : "having held "}
{formatNumber(gate.numTokens)}{" "}
<a
href={`https://etherscan.io/token/${gate.contract}`}
Expand All @@ -163,14 +167,20 @@ export default function Join({ gate }: { gate: GateExtended }) {
>
{gate.contractName}
</a>{" "}
token{gate.numTokens == 1 ? "" : "s"} at block{" "}
<a
href={`https://etherscan.io/block/${gate.blockNumber}`}
target="_blank;"
rel="noopener noreferrer"
>
#{formatNumber(gate.blockNumber)}
</a>
token{gate.numTokens == 1 ? "" : "s"}
{!gate.dynamicCheck ? (
<>
{" "}
at block{" "}
<a
href={`https://etherscan.io/block/${gate.blockNumber}`}
target="_blank;"
rel="noopener noreferrer"
>
#{formatNumber(gate.blockNumber)}
</a>
</>
) : null}
.
</p>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
Warnings:

- Added the required column `readOnly` to the `Gate` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "Gate" ADD COLUMN "readOnly" BOOLEAN NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Gate" ADD COLUMN "dynamicCheck" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "readOnly" SET DEFAULT false;
8 changes: 5 additions & 3 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ model VerificationToken {
}

model Gate {
id String @id @default(cuid())
id String @id @default(cuid())
creatorId String
repoOwner String
repoName String
Expand All @@ -67,6 +67,8 @@ model Gate {
contractDecimals Int
numTokens Float
numInvites Int
usedInvites Int @default(0)
creator User @relation(fields: [creatorId], references: [id])
readOnly Boolean @default(false)
dynamicCheck Boolean @default(false)
usedInvites Int @default(0)
creator User @relation(fields: [creatorId], references: [id])
}
5 changes: 5 additions & 0 deletions styles/pages/Create.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@
background-color: var(--color-button-green-disabled-bg);
}
}

.checkbox {
display: flex;
align-items: center;
}
}