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
7 changes: 7 additions & 0 deletions apps/api/src/controllers/url.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AppError } from '../utils/AppError'
import { logActionFromReq } from '../services/audit.service'
import { isBot } from '../utils/botDetection'
import { logger } from '../utils/logger'
import { env } from '../utils/env'
import { db } from '../db'
import { visits } from '../db/schema'
import { count, eq, asc } from 'drizzle-orm'
Expand Down Expand Up @@ -203,6 +204,12 @@ async function performRedirect(code: string, req: Request, res: Response) {
if (url.passwordHash) {
const token = req.query.token as string | undefined
if (!token) {
const accept = req.headers.accept ?? ''
const acceptsHtml = accept.includes('text/html')
if (acceptsHtml) {
res.redirect(302, `${env.APP_URL}/link/${code}`)
return
}
res.status(401).json({
success: false,
error: 'This link is password protected. POST /:code/verify-password to get an access token.',
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async function verifyApiKey(token: string, ip?: string): Promise<AuthenticatedUs
.catch(() => {})

const role = await syncUser(key.userId)
return { id: key.userId, role: role as UserRole, apiKeyName: key.name }
return { id: key.userId, role: role as UserRole, apiKeyName: key.name, scopes: key.scopes ?? undefined }
}

throw new AppError('Invalid API key', 401, 'AUTH_INVALID_KEY')
Expand Down
36 changes: 36 additions & 0 deletions apps/api/src/middleware/requireScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Request, Response, NextFunction } from 'express'
import { AppError } from '../utils/AppError'

export function requireScope(...scopes: string[]) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user) {
next(new AppError('Authentication required', 401, 'AUTH_REQUIRED'))
return
}

// JWT users bypass scope checks entirely
if (!req.user.apiKeyName) {
next()
return
}

// API key with null/undefined scopes = full access (legacy keys)
if (!req.user.scopes || req.user.scopes.length === 0) {
next()
return
}

// Check the API key has at least one of the required scopes
const hasScope = scopes.some((s) => req.user!.scopes!.includes(s))
if (!hasScope) {
next(new AppError(
`API key requires one of: ${scopes.join(', ')}`,
403,
'FORBIDDEN',
))
return
}

next()
}
}
25 changes: 12 additions & 13 deletions apps/api/src/routes/collection.routes.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { Router } from 'express'
import * as collectionController from '../controllers/collection.controller'
import { requireAuth } from '../middleware/auth'
import { requireScope } from '../middleware/requireScope'

const router = Router()

router.use(requireAuth)

router.get('/', collectionController.listCollections)
router.post('/', collectionController.createCollection)
router.patch('/reorder', collectionController.reorderCollections)
router.get('/:id', collectionController.getCollection)
router.patch('/:id', collectionController.updateCollection)
router.delete('/:id', collectionController.deleteCollection)
router.post('/:id/share', collectionController.shareCollection)
router.delete('/:id/share', collectionController.revokeShare)
router.get('/:id/urls', collectionController.getCollectionUrls)
router.post('/:id/urls', collectionController.addUrlToCollection)
router.delete('/:id/urls', collectionController.removeUrlFromCollection)
router.get('/', requireAuth, requireScope('urls:read'), collectionController.listCollections)
router.post('/', requireAuth, requireScope('urls:write'), collectionController.createCollection)
router.patch('/reorder', requireAuth, requireScope('urls:write'), collectionController.reorderCollections)
router.get('/:id', requireAuth, requireScope('urls:read'), collectionController.getCollection)
router.patch('/:id', requireAuth, requireScope('urls:write'), collectionController.updateCollection)
router.delete('/:id', requireAuth, requireScope('urls:write'), collectionController.deleteCollection)
router.post('/:id/share', requireAuth, requireScope('urls:write'), collectionController.shareCollection)
router.delete('/:id/share', requireAuth, requireScope('urls:write'), collectionController.revokeShare)
router.get('/:id/urls', requireAuth, requireScope('urls:read'), collectionController.getCollectionUrls)
router.post('/:id/urls', requireAuth, requireScope('urls:write'), collectionController.addUrlToCollection)
router.delete('/:id/urls', requireAuth, requireScope('urls:write'), collectionController.removeUrlFromCollection)

export default router
15 changes: 7 additions & 8 deletions apps/api/src/routes/tag.routes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { Router } from 'express'
import * as tagController from '../controllers/tag.controller'
import { requireAuth } from '../middleware/auth'
import { requireScope } from '../middleware/requireScope'
import { bulkLimiter } from '../middleware/rateLimiter'

const router = Router()

router.use(requireAuth)

router.get('/', tagController.listTags)
router.post('/', tagController.createTag)
router.patch('/:id', tagController.updateTag)
router.delete('/:id', tagController.deleteTag)
router.post('/bulk', bulkLimiter, tagController.bulkTagUrls)
router.get('/:id/urls', tagController.getTagUrls)
router.get('/', requireAuth, requireScope('urls:read'), tagController.listTags)
router.post('/', requireAuth, requireScope('urls:write'), tagController.createTag)
router.patch('/:id', requireAuth, requireScope('urls:write'), tagController.updateTag)
router.delete('/:id', requireAuth, requireScope('urls:write'), tagController.deleteTag)
router.post('/bulk', requireAuth, requireScope('urls:write'), bulkLimiter, tagController.bulkTagUrls)
router.get('/:id/urls', requireAuth, requireScope('urls:read'), tagController.getTagUrls)

export default router
21 changes: 11 additions & 10 deletions apps/api/src/routes/url.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import * as linkController from '../controllers/link.controller'
import * as qrController from '../controllers/qr.controller'
import { requireAuth } from '../middleware/auth'
import { requireAAL } from '../middleware/requireAAL'
import { requireScope } from '../middleware/requireScope'
import { strictLimiter, bulkLimiter, passwordLimiter } from '../middleware/rateLimiter'

const router = Router()

router.post('/bulk', requireAuth, bulkLimiter, urlController.createUrlBulk)
router.post('/', requireAuth, strictLimiter, urlController.createUrl)
router.get('/', requireAuth, urlController.listUrls)
router.post('/bulk', requireAuth, requireScope('urls:write'), bulkLimiter, urlController.createUrlBulk)
router.post('/', requireAuth, requireScope('urls:write'), strictLimiter, urlController.createUrl)
router.get('/', requireAuth, requireScope('urls:read'), urlController.listUrls)
router.get('/:code', urlController.redirectUrl)
router.get('/:code/info', urlController.getUrlInfo)
router.get('/:code/visits', urlController.getVisits)
Expand All @@ -19,14 +20,14 @@ router.get('/:code/visits/export', urlController.exportVisits)
router.get('/:code/qr', requireAuth, qrController.generateQr)
router.post('/:code/qr/regenerate', requireAuth, qrController.regenerateQr)
router.post('/:code/verify-password', passwordLimiter, urlController.verifyPasswordRedirect)
router.delete('/:code', requireAuth, urlController.deleteUrl)
router.delete('/:code/purge', requireAuth, requireAAL(), urlController.purgeUrl)
router.delete('/:code', requireAuth, requireScope('urls:delete'), urlController.deleteUrl)
router.delete('/:code/purge', requireAuth, requireScope('urls:delete'), requireAAL(), urlController.purgeUrl)

router.patch('/:code/settings', requireAuth, linkController.updateLinkSettings)
router.post('/:code/password', requireAuth, linkController.setPassword)
router.delete('/:code/password', requireAuth, linkController.removePassword)
router.patch('/:code/settings', requireAuth, requireScope('urls:write'), linkController.updateLinkSettings)
router.post('/:code/password', requireAuth, requireScope('urls:write'), linkController.setPassword)
router.delete('/:code/password', requireAuth, requireScope('urls:write'), linkController.removePassword)
router.post('/:code/verify-password-token', passwordLimiter, linkController.verifyPassword)
router.post('/bulk-operations', requireAuth, bulkLimiter, linkController.executeBulkOperation)
router.post('/import/csv', requireAuth, bulkLimiter, linkController.importCsv)
router.post('/bulk-operations', requireAuth, requireScope('urls:write'), bulkLimiter, linkController.executeBulkOperation)
router.post('/import/csv', requireAuth, requireScope('urls:write'), bulkLimiter, linkController.importCsv)

export default router
1 change: 1 addition & 0 deletions apps/api/src/types/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface AuthenticatedUser {
role: UserRole
aal?: 'aal1' | 'aal2'
apiKeyName?: string
scopes?: string[]
}

declare global {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const envSchema = z.object({
BILLING_SUCCESS_URL: z.string().optional(),
BILLING_CANCEL_URL: z.string().optional(),
BILLING_RETURN_URL: z.string().optional(),
APP_URL: z.string().default('http://localhost:5173'),

// Feature flags
FEATURE_BULK_OPERATIONS: z.enum(['enabled', 'disabled']).default('enabled'),
Expand Down
50 changes: 50 additions & 0 deletions apps/dashboard/src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils"
import { forwardRef, type InputHTMLAttributes } from "react"

export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
onCheckedChange?: (checked: boolean) => void
}

const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, checked, onChange, onCheckedChange, disabled, ...props }, ref) => {
return (
<label
className={cn(
"inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-border transition-colors",
checked ? "bg-primary border-primary" : "bg-background",
disabled && "cursor-not-allowed opacity-50",
className
)}
>
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={(e) => {
onChange?.(e)
onCheckedChange?.(e.target.checked)
}}
disabled={disabled}
className="sr-only"
{...props}
/>
{checked && (
<svg
className="h-3 w-3 text-primary-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</label>
)
}
)
Checkbox.displayName = "Checkbox"

export { Checkbox }
2 changes: 1 addition & 1 deletion apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export function removeUrlPassword(token: string, code: string) {

export function verifyUrlPassword(code: string, password: string) {
return request<{ token: string }>(
`/api/urls/${code}/verify-password`,
`/api/urls/${code}/verify-password-token`,
{
method: "POST",
body: JSON.stringify({ password }),
Expand Down
Loading
Loading