Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ TELEGRAM_BOT_START_DELAY_MS="1000"
ARX_SERVER_AUTHORIZATION_TOKEN=""
CURSIVE_BBJJ_PRIVATE_KEY_SEED=""
CURSIVE_SIGNING_PUBLIC_KEY_X=""
CURSIVE_SIGNING_PUBLIC_KEY_Y=""
CURSIVE_SIGNING_PUBLIC_KEY_Y=""

# Reclaim protocol - Twitter
RECLAIM_APP_ID = ""
RECLAIM_APP_SECRET = ""
RECLAIM_PROVIDER_ID = ""
3 changes: 2 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"unique-names-generator": "^4.7.1",
"uuid": "^10.0.0",
"zod": "^3.23.8",
"zod-validation-error": "^3.4.0"
"zod-validation-error": "^3.4.0",
"@reclaimprotocol/js-sdk": "2.1.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import lannaRoutes from "./routes/lanna";
import notificationRoutes from "./routes/notification";
import dataHashRoutes from "./routes/dataHash";
import graphRoutes from "./routes/graph";
import reclaimRoutes from "./routes/reclaim";
import { FRONTEND_URL } from "./constants";
import { Server, Socket } from "socket.io";
import {
Expand All @@ -33,6 +34,11 @@ const corsOptions = {
origin: `${FRONTEND_URL}`,
};
app.use(cors(corsOptions));

// for reclaim callback routes
app.use(express.text({ type: '*/*', limit: '500mb' }));
app.use("/api/reclaim", reclaimRoutes);

app.use(express.json());

// Routes
Expand Down
17 changes: 16 additions & 1 deletion apps/backend/src/lib/controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ErrorResponse,
} from "@types";
import { Chip } from "@/lib/controller/chip/types";
import { TwitterReclaimController } from "@/lib/controller/reclaim/twitter";
import { iChipClient } from "@/lib/controller/chip/interfaces";
import { ManagedChipClient } from "@/lib/controller/chip/managed/client";
import { SESEmailClient } from "@/lib/controller/email/ses/client";
Expand All @@ -45,6 +46,8 @@ import { iEnclaveClient } from "@/lib/controller/enclave/interfaces";
import { iGraphClient } from "@/lib/controller/graph/interfaces";
import { PrismaGraphClient } from "@/lib/controller/graph/prisma/client";
import { EdgeData } from "@/lib/controller/graph/types";
import { TwitterReclaimCallbackResponse } from "./reclaim/interface";
import { TwitterReclaimUrlResponse } from "./reclaim/interface";

export class Controller {
postgresClient: iPostgresClient; // Use interface so that it can be mocked out
Expand All @@ -54,6 +57,7 @@ export class Controller {
notificationClient: iNotificationClient;
enclaveClient: iEnclaveClient;
graphClient: iGraphClient;
reclaimTwitterClient: TwitterReclaimController;

constructor() {
// Default client, could also pass through mock
Expand All @@ -76,7 +80,9 @@ export class Controller {
// Graph client -- uses primsa but in the future may use graph DB of some sort...
this.graphClient = new PrismaGraphClient();

// Over time more clients will be added...
// Initialize Twitter Reclaim client
this.reclaimTwitterClient = new TwitterReclaimController();

}

NotificationInitialize(): Promise<void> {
Expand Down Expand Up @@ -332,4 +338,13 @@ export class Controller {
PollProofResults(): Promise<void> {
return this.chipClient.PollProofResults();
}

// Twitter Reclaim methods
async GetTwitterReclaimUrl(userId: string, redirectUrl: string): Promise<TwitterReclaimUrlResponse> {
return this.reclaimTwitterClient.getTwitterReclaimUrl(userId, redirectUrl);
}

async HandleTwitterReclaimCallback(proof: string): Promise<TwitterReclaimCallbackResponse> {
return this.reclaimTwitterClient.handleCallback(proof);
}
}
20 changes: 20 additions & 0 deletions apps/backend/src/lib/controller/reclaim/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface TwitterProofData {
claimData: {
context: any;
identifier: string;
provider: string;
parameters: Record<string, unknown>;
};
contextMessage?: string;
contextAddress?: string;
publicData?: object;
}

export interface TwitterReclaimUrlResponse {
url: string;
status: string;
}

export interface TwitterReclaimCallbackResponse {
message: string;
}
75 changes: 75 additions & 0 deletions apps/backend/src/lib/controller/reclaim/twitter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
import { TwitterProofData, TwitterReclaimUrlResponse, TwitterReclaimCallbackResponse } from "../interface";

export class TwitterReclaimController {
private readonly backendUrl: string;
private readonly appId: string;
private readonly appSecret: string;
private readonly providerId: string;

constructor() {
this.backendUrl = process.env.BACKEND_URL!;
this.appId = process.env.RECLAIM_APP_ID!;
this.appSecret = process.env.RECLAIM_APP_SECRET!;
this.providerId = process.env.RECLAIM_PROVIDER_ID!;
}

public async getTwitterReclaimUrl(userId: string, redirectUrl: string): Promise<TwitterReclaimUrlResponse> {
try {
const reclaimProofRequest = await ReclaimProofRequest.init(
this.appId,
this.appSecret,
this.providerId
);
// @dev: add context Address and message to the claim response
await reclaimProofRequest.addContext(
'0x0',
userId // to add multiple context message use JSON.stringify({ key1: value1, key2: value2 })
);

// set the callback url (where the proof will be submitted)
await reclaimProofRequest.setAppCallbackUrl(
`${this.backendUrl}/api/reclaim/twitter/callback`
);

// set the redirect url (where the user will be redirected to after the proof is submitted)
await reclaimProofRequest.setRedirectUrl(
redirectUrl
);

const url = await reclaimProofRequest.getRequestUrl();
// get the status of the user session
const status = await reclaimProofRequest.getStatusUrl();
return { url, status };
} catch (error) {
console.error("Error getting Twitter Reclaim URL:", error);
throw new Error("Failed to get Twitter Reclaim URL");
}
}

public async handleCallback(proof: string): Promise<TwitterReclaimCallbackResponse> {
try {
const proofData: TwitterProofData = JSON.parse(decodeURIComponent(proof));
// verify the proof
const isProofVerified = await verifyProof(proofData);

if (!isProofVerified) {
throw new Error("Proof verification failed");
}

// to retrieve the context data
const contextData = proofData.claimData.context;
console.log(contextData);

console.log(proofData);
// to retrieve the public data (from the proof)
console.log(proofData?.publicData);
// your business logic here
// ...
return { message: 'proof submitted' };
} catch (error) {
console.error("Error handling Twitter Reclaim callback:", error);
throw new Error("Failed to handle Twitter Reclaim callback");
}
}
}
70 changes: 70 additions & 0 deletions apps/backend/src/routes/reclaim/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import express, { Request, Response } from "express";
import { Controller } from "@/lib/controller";

const router = express.Router();
const controller = new Controller();


/**
* @route GET /api/reclaim/
* @desc Returns the Reclaim URL and status url for the current session
* @returns { url: string, status: string }
*/
router.get("/", async (req: Request, res: Response) => {
try {
const redirectUrl = req.query.redirectUrl as string;
if (!redirectUrl) {
return res.status(400).json({
error: "Redirect URL is required",
});
}

const userId = req.query.userId as string;
if (!userId) {
return res.status(400).json({
error: "User ID is required",
});
}

const user = await controller.GetUserById(userId);
if (!user) {
return res.status(401).json({
error: "Invalid auth token",
});
}

const { url, status } = await controller.GetTwitterReclaimUrl(user.id, redirectUrl);
return res.json({ url, status });
} catch (error) {
console.error("Error generating Twitter Reclaim URL:", error);
return res.status(500).json({
error: "Failed to generate Twitter Reclaim URL",
});
}
});

/**
* @route POST /api/reclaim/callback
* @desc Handles the callback from Reclaim protocol with proof
* @returns { message: string }
*/
router.post("/callback", async (req: Request, res: Response) => {
try {
const { proof } = req.body;
if (!proof) {
return res.status(400).json({
error: "Proof is required",
});
}

const result = await controller.HandleTwitterReclaimCallback(proof);
return res.json(result);
} catch (error) {
console.error("Error handling Twitter Reclaim callback:", error);
return res.status(500).json({
error: "Failed to handle Twitter Reclaim callback",
});
}
});

export default router;
19 changes: 14 additions & 5 deletions apps/frontend/src/features/community/LannaCommunityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Tag } from "@/components/ui/Tag";
import { BASE_API_URL } from "@/config";
import ImportGithubButton from "@/features/oauth/ImportGithubButton";
import ImportStravaButton from "@/features/oauth/ImportStravaButton";
import ImportTwitterButton from "@/features/oauth/ImportTwitterButton";
import {
getTopLeaderboardEntries,
getUserLeaderboardDetails,
Expand Down Expand Up @@ -548,11 +549,19 @@ export default function LannaCommunityPage({
user &&
(!user.oauth ||
(user.oauth && !Object.keys(user?.oauth).includes("github"))) && (
<div
className="w-full"
onClick={() => logClientEvent("community-github-clicked", {})}
>
<ImportGithubButton fullWidth />
<div className="flex flex-col gap-2">
<div
className="w-full"
onClick={() => logClientEvent("community-github-clicked", {})}
>
<ImportGithubButton fullWidth />
</div>
<div
className="w-full"
onClick={() => logClientEvent("community-twitter-clicked", {})}
>
<ImportTwitterButton fullWidth />
</div>
</div>
)
}
Expand Down
33 changes: 33 additions & 0 deletions apps/frontend/src/features/oauth/ImportTwitterButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Icons } from "@/components/icons/Icons";
import { Tag } from "@/components/ui/Tag";
import { BASE_API_URL, FRONTEND_URL } from "@/config";
import { cn } from "@/lib/frontend/util";
import Link from "next/link";

const ImportTwitterButton = ({
addElement = true,
fullWidth = false,
}: {
addElement?: boolean;
fullWidth?: boolean;
}) => {
// replace with user id or any other identifier
const userId = "0x0";
return (
<Link
href={`${BASE_API_URL}/reclaim?redirectUrl=${FRONTEND_URL}/home&userId=${userId}`}
>
<Tag
emoji={<Icons.GitHub />}
variant="gray"
text="GitHub"
className={cn("pl-4 pr-8", fullWidth ? "w-full" : "min-w-max")}
addElement={addElement}
refresh={!addElement}
fullWidth={fullWidth}
/>
</Link>
);
};

export default ImportTwitterButton;
11 changes: 11 additions & 0 deletions apps/frontend/src/pages/profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { logoutUser } from "@/lib/auth";
import { logClientEvent } from "@/lib/frontend/metrics";
import ImportGithubButton from "@/features/oauth/ImportGithubButton";
import ImportDevconButton from "@/features/oauth/ImportDevconButton";
import ImportTwitterButton from "@/features/oauth/ImportTwitterButton";
import ToggleSwitch from "@/components/ui/Switch";
import useSettings from "@/hooks/useSettings";
import { storeAddChipRequest } from "@/lib/chip/addChip";
Expand Down Expand Up @@ -83,6 +84,7 @@ const ProfilePage: React.FC = () => {
);
}

const hasTwitterToAdd = !user.oauth // add twitter to add
const hasGithubToAdd =
!user.oauth || !Object.keys(user.oauth).includes(DataImportSource.GITHUB);
const hasDevconToAdd = !Object.keys(user.userData).includes("devcon");
Expand Down Expand Up @@ -199,6 +201,15 @@ const ProfilePage: React.FC = () => {
<ImportGithubButton />
</div>
)}
{hasTwitterToAdd && (
<div
onClick={() =>
logClientEvent("user-profile-twitter-clicked", {})
}
>
<ImportTwitterButton />
</div>
)}
{hasDevconToAdd && <ImportDevconButton />}
</div>
</div>
Expand Down
Loading