Skip to content
Merged
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: 9 additions & 8 deletions apps/server/src/lib/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { secureHeaders } from 'hono/secure-headers';

import { notFound, onError } from '../middlewares/common.js';
import { pinoLogger } from '../middlewares/pino-logger.js';
import { rateLimit } from '../middlewares/rate-limit.js';

import { env } from './env-config.js';
import { errorResponse, HttpStatus } from './response.js';
Expand Down Expand Up @@ -68,15 +69,15 @@ export default function createApp() {
)
);

// if (!env.isTest) {
// app.use('*', async (c, next) => {
// if (c.req.path === '/health') {
// return next();
// }
if (!env.isTest) {
app.use('*', async (c, next) => {
if (c.req.path === '/health') {
return next();
}

// return rateLimit(c as any, next);
// });
// }
return rateLimit(c, next);
});
}

app.notFound(notFound);
app.onError(onError);
Expand Down
32 changes: 24 additions & 8 deletions apps/server/src/middlewares/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Context } from 'hono';
import type { RedisClient } from 'hono-rate-limiter';
import { RedisStore, rateLimiter } from 'hono-rate-limiter';
import { Redis } from 'ioredis';

import redisConfig from '@/config/redis.config.js';

import type { AppBindings } from '@/lib/types.js';

const redisClient = new Redis(redisConfig);

// node-redis workaround using ioredis to adapt the redis client for hono-rate-limiter
Expand All @@ -18,32 +21,45 @@ const redisAdapter: RedisClient = {
const ONE_HOUR_WINDOW = 60 * 60 * 1000;

function makeStore(prefix: string) {
return new RedisStore({
return new RedisStore<AppBindings>({
client: redisAdapter,
prefix
});
}

function keyByUser(c: any): string {
function keyByIp(c: Context<AppBindings>): string {
return (
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || 'unknown'
);
}

function keyByUser(c: Context<AppBindings>): string {
return c.get('user')?.id ?? 'anon';
}

async function keyByIpAndEmail(c: any): Promise<string> {
async function keyByIpAndEmail(c: Context<AppBindings>): Promise<string> {
let email = 'unknown';

try {
const body = await c.req.json();
email = typeof body?.email === 'string' ? body.email.toLowerCase() : email;
if (typeof body === 'object' && body !== null && 'email' in body) {
const candidateEmail = body.email;
email = typeof candidateEmail === 'string' ? candidateEmail.toLowerCase() : email;
}
} catch {
// keep unknown email key for malformed requests
}

const forwardedFor = c.req.header('x-forwarded-for')?.split(',')[0]?.trim();
const ip = forwardedFor || c.req.header('x-real-ip') || 'unknown';

return `${ip}:${email}`;
return `${keyByIp(c)}:${email}`;
}

export const rateLimit = rateLimiter<AppBindings>({
windowMs: 15 * 60 * 1000,
limit: 300,
keyGenerator: keyByIp,
store: makeStore('rl:global:')
});

export const authRegisterRateLimit = rateLimiter({
windowMs: ONE_HOUR_WINDOW,
limit: 10,
Expand Down
138 changes: 74 additions & 64 deletions apps/server/src/routes/tags/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ function buildClient() {
})
);

return { client, seedGroup: tagsAdapter.seedGroup, seedTag: tagsAdapter.seedTag };
return {
client,
seedGroup: tagsAdapter.seedGroup,
seedTag: tagsAdapter.seedTag
};
}

let client: ReturnType<typeof buildClient>['client'];
Expand Down Expand Up @@ -109,7 +113,13 @@ describe('tags routes', () => {

it('creates a new category', async () => {
const response = await client.tags.groups.$post(
{ json: { name: 'Science', color: '#6366F1', description: 'Science articles' } },
{
json: {
name: 'Science',
color: '#6366F1',
description: 'Science articles'
}
},
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.CREATED);
Expand Down Expand Up @@ -276,44 +286,44 @@ describe('tags routes', () => {
});
});

// describe('PUT /api/tags/:tagId/:groupId', () => {
// it('returns 401 without auth', async () => {
// const response = await client.tags[':tagId'][':groupId'].$put({
// param: { tagId: TAG_ID, groupId: GROUP_ID },
// json: { name: 'Updated' }
// });
// expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
// });

// it('returns 404 for unknown tag', async () => {
// const response = await client.tags[':tagId'][':groupId'].$put(
// { param: { tagId: UNKNOWN_ID, groupId: GROUP_ID }, json: { name: 'Updated' } },
// { headers: { Cookie: authCookie } }
// );
// expect(response.status).toBe(HttpStatus.NOT_FOUND);
// });

// it('returns 400 when no fields provided', async () => {
// const response = await client.tags[':tagId'][':groupId'].$put(
// { param: { tagId: TAG_ID, groupId: GROUP_ID }, json: {} },
// { headers: { Cookie: authCookie } }
// );
// expect(response.status).toBe(HttpStatus.BAD_REQUEST);
// });

// it('updates the tag name', async () => {
// const response = await client.tags[':tagId'][':groupId'].$put(
// { param: { tagId: TAG_ID, groupId: GROUP_ID }, json: { name: 'TypeScript Updated' } },
// { headers: { Cookie: authCookie } }
// );
// expect(response.status).toBe(HttpStatus.OK);
// if (response.status === HttpStatus.OK) {
// const json = await response.json();
// expect(json.result.name).toBe('TypeScript Updated');
// expect(json.result.id).toBe(TAG_ID);
// }
// });
// });
describe('PUT /api/tags/:tagId', () => {
it('returns 401 without auth', async () => {
const response = await client.tags[':tagId'].$put({
param: { tagId: TAG_ID },
json: { name: 'Updated' }
});
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
});

it('returns 404 for unknown tag', async () => {
const response = await client.tags[':tagId'].$put(
{ param: { tagId: UNKNOWN_ID }, json: { name: 'Updated' } },
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.NOT_FOUND);
});

it('returns 400 when no fields provided', async () => {
const response = await client.tags[':tagId'].$put(
{ param: { tagId: TAG_ID }, json: {} },
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.BAD_REQUEST);
});

it('updates the tag name', async () => {
const response = await client.tags[':tagId'].$put(
{ param: { tagId: TAG_ID }, json: { name: 'TypeScript Updated' } },
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.OK);
if (response.status === HttpStatus.OK) {
const json = await response.json();
expect(json.result.name).toBe('TypeScript Updated');
expect(json.result.id).toBe(TAG_ID);
}
});
});

describe('POST /api/tags/:tagId/move', () => {
it('returns 401 without auth', async () => {
Expand Down Expand Up @@ -346,30 +356,30 @@ describe('tags routes', () => {
});
});

// describe('DELETE /api/tags/:tagId/:groupId', () => {
// it('returns 401 without auth', async () => {
// const response = await client.tags[':tagId'][':groupId'].$delete({
// param: { tagId: TAG_ID, groupId: GROUP_ID }
// });
// expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
// });

// it('returns 404 for unknown tag', async () => {
// const response = await client.tags[':tagId'][':groupId'].$delete(
// { param: { tagId: UNKNOWN_ID, groupId: GROUP_ID } },
// { headers: { Cookie: authCookie } }
// );
// expect(response.status).toBe(HttpStatus.NOT_FOUND);
// });

// it('deletes the tag', async () => {
// const response = await client.tags[':tagId'][':groupId'].$delete(
// { param: { tagId: TAG_ID, groupId: GROUP_ID } },
// { headers: { Cookie: authCookie } }
// );
// expect(response.status).toBe(HttpStatus.OK);
// });
// });
describe('DELETE /api/tags/:tagId', () => {
it('returns 401 without auth', async () => {
const response = await client.tags[':tagId'].$delete({
param: { tagId: TAG_ID }
});
expect(response.status).toBe(HttpStatus.UNAUTHORIZED);
});

it('returns 404 for unknown tag', async () => {
const response = await client.tags[':tagId'].$delete(
{ param: { tagId: UNKNOWN_ID } },
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.NOT_FOUND);
});

it('deletes the tag', async () => {
const response = await client.tags[':tagId'].$delete(
{ param: { tagId: TAG_ID } },
{ headers: { Cookie: authCookie } }
);
expect(response.status).toBe(HttpStatus.OK);
});
});

describe('DELETE /api/tags/bulk', () => {
it('returns 401 without auth', async () => {
Expand Down
6 changes: 1 addition & 5 deletions apps/web/src/features/articles/components/article-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,6 @@ function ListCardCompact({
return (
<div className="group bg-card [@media(hover:hover)]:hover:bg-accent/30 relative flex w-full flex-col overflow-hidden rounded-xl border transition-colors">
<div className="flex gap-3 p-3">
{/* Thumbnail */}
<div className="bg-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-lg">
{isLoading ? (
<Skeleton className="absolute inset-0 rounded-none" />
Expand Down Expand Up @@ -581,14 +580,12 @@ function ListCardCompact({
</div>
</div>

{/* Excerpt */}
{isLoading ? (
<Skeleton className="h-3 w-4/5 rounded-md" />
) : (
<p className="text-muted-foreground line-clamp-1 text-xs">{excerpt}</p>
)}

{/* Meta row: priority · source · time · progress · tags */}
{isLoading ? (
<Skeleton className="h-3 w-28 rounded-md" />
) : (
Expand Down Expand Up @@ -672,11 +669,10 @@ function ListCardCompact({
</div>
</div>

{/* Ambient reading progress bar at card bottom */}
{!isLoading && readingProgress > 0 && (
<div className="bg-muted h-0.5 w-full overflow-hidden">
<div
className="bg-primary h-full w-full origin-left motion-safe:transition-[transform] motion-safe:duration-500 motion-safe:[transition-timing-function:cubic-bezier(0.215,0.61,0.355,1)]"
className="bg-primary h-full w-full origin-left motion-safe:transition-[transform] motion-safe:duration-500 motion-safe:ease-[cubic-bezier(0.215,0.61,0.355,1)]"
style={{ transform: `scaleX(${readingProgress / 100})` }}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ function ContinueReadingCard({
type="button"
>
<div className="flex items-center gap-4 p-4">
{/* Cover image — consistent square across all viewports */}
<div className="bg-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-xl">
{coverImage ? (
<img
Expand All @@ -48,7 +47,6 @@ function ContinueReadingCard({
)}
</div>

{/* Content */}
<div className="min-w-0 flex-1">
<p className="text-card-foreground line-clamp-2 text-sm font-semibold leading-snug">
{title}
Expand All @@ -58,18 +56,16 @@ function ContinueReadingCard({
</p>
</div>

{/* Directional affordance */}
<ArrowRightIcon
className="text-muted-foreground shrink-0 motion-safe:transition-transform motion-safe:duration-150 [@media(hover:hover)]:group-hover:translate-x-0.5"
size={16}
weight="bold"
/>
</div>

{/* Visual reading progress */}
<div className="bg-muted h-0.5 w-full overflow-hidden">
<div
className="bg-primary h-full w-full origin-left motion-safe:transition-[transform] motion-safe:duration-400 motion-safe:[transition-timing-function:cubic-bezier(0.215,0.61,0.355,1)]"
className="bg-primary h-full w-full origin-left motion-safe:transition-[transform] motion-safe:duration-400 motion-safe:ease-[cubic-bezier(0.215,0.61,0.355,1)]"
style={{ transform: `scaleX(${progress / 100})` }}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,6 @@ export function ImportProgress() {
return () => clearInterval(interval);
}, []);

// if (loading) {
// return (
// <div className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center gap-6">
// <ArrowsClockwiseIcon className="animate-spin" size={36} />
// <div className="flex flex-col gap-2 text-center">
// <h1 className="text-foreground">Preparing your import</h1>
// <div className="text-muted-foreground">
// We&apos;re getting things ready for your import. This should only
// take a moment.
// </div>
// </div>
// </div>
// )
// }

if (status === 'extraction') {
return (
<ExtractionProgress
Expand Down
Loading