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
17 changes: 0 additions & 17 deletions .env.example

This file was deleted.

4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

# next.js
/.next/
/apps/web/.next/
/apps/ds/.next/
/out/

# production
Expand Down Expand Up @@ -44,4 +46,4 @@ next-env.d.ts
.turbo

# generated locale map
/lib/locales.ts
/packages/i18n/src/locales.ts
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
> A privacy-first, weekly-focused open-source calendar built for clarity and control.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="/public/Banner-dark.jpg">
<source media="(prefers-color-scheme: light)" srcset="/public/Banner.jpg">
<img src="/public/Banner.jpg" alt="Image">
<source media="(prefers-color-scheme: dark)" srcset="./apps/web/public/Banner-dark.jpg">
<source media="(prefers-color-scheme: light)" srcset="./apps/web/public/Banner.jpg">
<img src="./apps/web/public/Banner.jpg" alt="Image">
</picture>

- [Live Product](https://calendar.xyehr.cn)
Expand Down Expand Up @@ -96,6 +96,34 @@ This project is built for individuals and small teams who value clarity over com

## Getting Started

## Monorepo Structure

This repository uses a standard **Turborepo** layout:

- `apps/web`: Next.js application
- `apps/ds`: decentralized storage server (Next.js API + PostgreSQL)
- `packages/config`: shared ts/postcss/tailwind config
- `packages/ui`: shared shadcn ui components
- `packages/i18n`: locales + i18n runtime helpers
- `turbo.json`: task pipeline and cache settings

Useful commands:

```bash
# run the web app through Turbo
bun run dev

# run decentralized storage server
bun run dev:ds

# build all workspaces
bun run build

# run a task only for web
bun run generate:locales
```


### Prerequisites

Required Versions:
Expand All @@ -115,13 +143,15 @@ bun install

# Start the app
bun run dev
# or run directly in the web workspace
bun run --cwd apps/web dev
```

Then visit `http://localhost:3000`

### Environment Variables

Copy `.env.example` to `.env` and fill in.
Copy `apps/web/.env.example` to `apps/web/.env` and fill in.

Key variables:

Expand Down
93 changes: 0 additions & 93 deletions app/(app)/app/page.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions app/(auth)/at-oauth/page.tsx

This file was deleted.

75 changes: 75 additions & 0 deletions apps/ds/app/api/blob/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

function statusForError(error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
if (
message.includes("Missing signature headers") ||
message.includes("Expired timestamp") ||
message.includes("Invalid signature") ||
message.includes("Failed to resolve DID") ||
message.includes("Missing DID public key")
) {
return 401;
}
return 500;
}

export async function GET(request: Request) {
try {
await ensureTables();
const { did } = await requireSignedRequest(request);
const result = await pool.query(
"SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1 LIMIT 1",
[did],
);
return NextResponse.json({ data: result.rows[0] ?? null });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}

export async function POST(request: Request) {
const raw = await request.text();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const payload = JSON.parse(raw) as {
encrypted_data: string;
iv: string;
timestamp?: number;
};

await pool.query(
`INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET encrypted_data = EXCLUDED.encrypted_data, iv = EXCLUDED.iv, timestamp = EXCLUDED.timestamp, updated_at = NOW()`,
[did, payload.encrypted_data, payload.iv, payload.timestamp ?? Date.now()],
);

return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}

export async function DELETE(request: Request) {
try {
await ensureTables();
const { did } = await requireSignedRequest(request);
await pool.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}
24 changes: 24 additions & 0 deletions apps/ds/app/api/migrate/cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
const client = await pool.connect();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);

await client.query("BEGIN");
await client.query("DELETE FROM shares WHERE user_id = $1", [did]);
await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
await client.query("COMMIT");

return NextResponse.json({ success: true });
} catch (error) {
await client.query("ROLLBACK");
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
} finally {
client.release();
}
}
19 changes: 19 additions & 0 deletions apps/ds/app/api/migrate/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const [backups, shares] = await Promise.all([
pool.query("SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1", [did]),
pool.query("SELECT share_id, data, timestamp FROM shares WHERE user_id = $1", [did]),
]);

return NextResponse.json({ did, backups: backups.rows, shares: shares.rows });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
}
}
42 changes: 42 additions & 0 deletions apps/ds/app/api/migrate/import/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
const client = await pool.connect();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const payload = JSON.parse(raw) as {
backups: Array<{ encrypted_data: string; iv: string; timestamp: number }>;
shares: Array<{ share_id: string; data: string; timestamp: number }>;
};

await client.query("BEGIN");
await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
await client.query("DELETE FROM shares WHERE user_id = $1", [did]);

for (const row of payload.backups || []) {
await client.query(
"INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp) VALUES ($1, $2, $3, $4)",
[did, row.encrypted_data, row.iv, row.timestamp],
);
}

for (const row of payload.shares || []) {
await client.query(
"INSERT INTO shares (user_id, share_id, data, timestamp) VALUES ($1, $2, $3, $4)",
[did, row.share_id, row.data, row.timestamp],
);
}

await client.query("COMMIT");
return NextResponse.json({ success: true });
} catch (error) {
await client.query("ROLLBACK");
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
} finally {
client.release();
}
}
16 changes: 16 additions & 0 deletions apps/ds/app/api/share/[shareId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";

export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
void request;
await ensureTables();
const { shareId } = await params;
const result = await pool.query(
"SELECT user_id, share_id, data, timestamp FROM shares WHERE share_id = $1 LIMIT 1",
[shareId],
Comment on lines +9 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope DS share lookup to owner DID

This query looks up shares only by share_id, but writes are keyed by (user_id, share_id), so identical IDs from different users on the same DS are valid. In that case, public reads can return the wrong row, allowing one user to shadow/spoof another user's shared link data.

Useful? React with 👍 / 👎.

);
if (result.rowCount === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ share: result.rows[0] });
}
Loading