shiny-url-input-box 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/ShinyUrlDashboard.d.ts +9 -0
- package/dist/ShinyUrlDashboard.d.ts.map +1 -0
- package/dist/ShinyUrlDashboard.js +111 -0
- package/dist/ShinyUrlInput.d.ts.map +1 -1
- package/dist/ShinyUrlInput.js +6 -53
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shortener.d.ts +17 -0
- package/dist/shortener.d.ts.map +1 -0
- package/dist/shortener.js +66 -0
- package/package.json +17 -8
- package/src/ShinyUrlDashboard.tsx +261 -0
- package/src/ShinyUrlInput.tsx +6 -69
- package/src/index.ts +3 -0
- package/src/shortener.ts +93 -0
package/README.md
CHANGED
|
@@ -62,3 +62,51 @@ function App() {
|
|
|
62
62
|
- `buttonText` (string, optional) - Submit button text (default: "Shorten URL")
|
|
63
63
|
- `className` (string, optional) - Additional CSS class for the wrapper
|
|
64
64
|
|
|
65
|
+
|
|
66
|
+
## Programmatic Usage
|
|
67
|
+
|
|
68
|
+
You can also use the exported `shortenUrl` function to shorten URLs programmatically without using the UI component.
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
import { shortenUrl } from 'shiny-url-input-box';
|
|
72
|
+
|
|
73
|
+
// Inside an async function
|
|
74
|
+
try {
|
|
75
|
+
const result = await shortenUrl(
|
|
76
|
+
'https://example.com/long/url',
|
|
77
|
+
'YOUR_API_KEY',
|
|
78
|
+
{ apiBaseUrl: 'https://shinyurl-backend.onrender.com' } // Optional
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (result.success) {
|
|
82
|
+
console.log('Short URL:', result.data.shortUrl);
|
|
83
|
+
} else {
|
|
84
|
+
console.error('Error:', result.message);
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('Failed to shorten URL:', error);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `shortenUrl` Arguments
|
|
92
|
+
|
|
93
|
+
1. `url` (string): The URL to shorten.
|
|
94
|
+
2. `apiKey` (string): Your API key.
|
|
95
|
+
3. `options` (object, optional):
|
|
96
|
+
- `apiBaseUrl` (string): Base URL of the backend API. Defaults to window origin or shinyurl-backend.onrender.com.
|
|
97
|
+
|
|
98
|
+
### Return Value
|
|
99
|
+
|
|
100
|
+
Returns a Promise that resolves to:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
interface ShortenResponse {
|
|
104
|
+
success: boolean;
|
|
105
|
+
data?: {
|
|
106
|
+
originalUrl: string;
|
|
107
|
+
shortUrl: string;
|
|
108
|
+
shortCode: string;
|
|
109
|
+
};
|
|
110
|
+
message?: string;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface ShinyUrlDashboardProps {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
apiBaseUrl?: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const ShinyUrlDashboard: React.FC<ShinyUrlDashboardProps>;
|
|
9
|
+
//# sourceMappingURL=ShinyUrlDashboard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ShinyUrlDashboard.d.ts","sourceRoot":"","sources":["../src/ShinyUrlDashboard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAsBnD,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2BD,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA8M9D,CAAC"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Grid, Paper, Typography, Box, Card, CardContent, CircularProgress } from '@mui/material';
|
|
4
|
+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts';
|
|
5
|
+
import { getDefaultApiBaseUrl } from './shortener';
|
|
6
|
+
export const ShinyUrlDashboard = ({ apiKey, apiBaseUrl, className = '', title = 'Analytics Dashboard' }) => {
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const [stats, setStats] = useState(null);
|
|
10
|
+
const [clicks, setClicks] = useState(null);
|
|
11
|
+
const [topUrls, setTopUrls] = useState(null);
|
|
12
|
+
const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const fetchData = async () => {
|
|
15
|
+
if (!apiKey)
|
|
16
|
+
return;
|
|
17
|
+
setLoading(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
try {
|
|
20
|
+
const headers = {
|
|
21
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
22
|
+
'Content-Type': 'application/json'
|
|
23
|
+
};
|
|
24
|
+
// Try standard X-API-Key as well if Bearer fails?
|
|
25
|
+
// Admin panel uses Bearer for JWT.
|
|
26
|
+
// If user passes an API Key (sk_...), the backend might need 'X-API-Key'.
|
|
27
|
+
// For now, let's assume if it looks like a JWT (3 distinct parts), use Bearer, else X-API-Key?
|
|
28
|
+
// Or simpler: try to fetch with headers that match the backend's expectation for the key type.
|
|
29
|
+
// The prompt says "admin panel components... user can import this dashboard creds".
|
|
30
|
+
// If it's an "Admin Dashboard", it likely needs admin 'Bearer' token.
|
|
31
|
+
// If it's a "User Dashboard" (showing user's URLs), it might use 'X-API-Key'.
|
|
32
|
+
// The endpoints used in Admin Dashboard (/admin/...) are protected by JWT.
|
|
33
|
+
// Endpoints for users might be different or same.
|
|
34
|
+
// I will implement a fallback or accept a 'keyType' prop later if needed.
|
|
35
|
+
// For now, I'll follow the admin pattern (Bearer) as requested by "dashboard creds in other admin panels".
|
|
36
|
+
const fetchOptions = { headers };
|
|
37
|
+
const [statsRes, clicksRes, topUrlsRes] = await Promise.all([
|
|
38
|
+
fetch(`${baseUrl}/api/admin/dashboard/stats`, fetchOptions),
|
|
39
|
+
fetch(`${baseUrl}/api/admin/analytics/clicks?days=7`, fetchOptions),
|
|
40
|
+
fetch(`${baseUrl}/api/admin/analytics/top-urls?limit=10`, fetchOptions)
|
|
41
|
+
]);
|
|
42
|
+
if (!statsRes.ok || !clicksRes.ok || !topUrlsRes.ok) {
|
|
43
|
+
// Check if 401
|
|
44
|
+
if (statsRes.status === 401 || clicksRes.status === 401 || topUrlsRes.status === 401) {
|
|
45
|
+
throw new Error("Unauthorized: Invalid API Key or Token");
|
|
46
|
+
}
|
|
47
|
+
throw new Error("Failed to fetch dashboard data");
|
|
48
|
+
}
|
|
49
|
+
const statsData = await statsRes.json();
|
|
50
|
+
const clicksData = await clicksRes.json();
|
|
51
|
+
const topUrlsData = await topUrlsRes.json();
|
|
52
|
+
setStats(statsData);
|
|
53
|
+
setClicks(clicksData);
|
|
54
|
+
setTopUrls(topUrlsData);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof Error) {
|
|
58
|
+
setError(err.message);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
setError("An unknown error occurred");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
fetchData();
|
|
69
|
+
}, [apiKey, baseUrl]);
|
|
70
|
+
if (!apiKey) {
|
|
71
|
+
return (_jsx(Box, { p: 3, className: className, children: _jsx(Typography, { color: "error", children: "Please provide an API Key / Token to view the dashboard." }) }));
|
|
72
|
+
}
|
|
73
|
+
if (loading) {
|
|
74
|
+
return (_jsx(Box, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "400px", className: className, children: _jsx(CircularProgress, {}) }));
|
|
75
|
+
}
|
|
76
|
+
if (error) {
|
|
77
|
+
return (_jsx(Box, { p: 3, className: className, children: _jsxs(Typography, { color: "error", children: ["Error: ", error] }) }));
|
|
78
|
+
}
|
|
79
|
+
const statCards = [
|
|
80
|
+
{
|
|
81
|
+
title: 'Total URLs',
|
|
82
|
+
value: stats?.stats?.totalUrls || 0,
|
|
83
|
+
color: '#1976d2',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
title: 'Total Clicks',
|
|
87
|
+
value: stats?.stats?.totalClicks || 0,
|
|
88
|
+
color: '#2e7d32',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
title: 'Unique IPs',
|
|
92
|
+
value: stats?.stats?.uniqueIps || 0,
|
|
93
|
+
color: '#ed6c02',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
title: 'Clicks Today',
|
|
97
|
+
value: stats?.stats?.clicksToday || 0,
|
|
98
|
+
color: '#d32f2f',
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
const chartData = clicks?.data?.map((item) => ({
|
|
102
|
+
date: item._id,
|
|
103
|
+
clicks: item.count,
|
|
104
|
+
})) || [];
|
|
105
|
+
return (_jsxs(Box, { className: className, children: [_jsx(Typography, { variant: "h4", gutterBottom: true, children: title }), _jsx(Grid, { container: true, spacing: 3, sx: { mb: 3 }, children: statCards.map((card) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 3, children: _jsx(Card, { children: _jsxs(CardContent, { children: [_jsx(Typography, { color: "text.secondary", gutterBottom: true, children: card.title }), _jsx(Typography, { variant: "h4", sx: { color: card.color }, children: card.value.toLocaleString() })] }) }) }, card.title))) }), _jsxs(Grid, { container: true, spacing: 3, children: [_jsx(Grid, { item: true, xs: 12, md: 8, children: _jsxs(Paper, { sx: { p: 3 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: "Clicks Over Time (Last 7 Days)" }), _jsx(ResponsiveContainer, { width: "100%", height: 300, children: _jsxs(LineChart, { data: chartData, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "date" }), _jsx(YAxis, {}), _jsx(Tooltip, {}), _jsx(Legend, {}), _jsx(Line, { type: "monotone", dataKey: "clicks", stroke: "#1976d2", strokeWidth: 2 })] }) })] }) }), _jsx(Grid, { item: true, xs: 12, md: 4, children: _jsxs(Paper, { sx: { p: 3 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: "Top URLs" }), _jsx(Box, { sx: { maxHeight: 300, overflow: 'auto' }, children: topUrls?.data?.map((url, index) => (_jsxs(Box, { sx: {
|
|
106
|
+
p: 1,
|
|
107
|
+
mb: 1,
|
|
108
|
+
bgcolor: 'background.default',
|
|
109
|
+
borderRadius: 1,
|
|
110
|
+
}, children: [_jsxs(Typography, { variant: "body2", noWrap: true, children: [index + 1, ". ", url.originalUrl] }), _jsxs(Typography, { variant: "caption", color: "text.secondary", children: [url.clickCount, " clicks"] })] }, url._id))) })] }) })] })] }));
|
|
111
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ShinyUrlInput.d.ts","sourceRoot":"","sources":["../src/ShinyUrlInput.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"ShinyUrlInput.d.ts","sourceRoot":"","sources":["../src/ShinyUrlInput.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,oBAAoB,CAAC;AAG5B,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,QAAA,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAkN/C,CAAC;AAEF,eAAe,aAAa,CAAC"}
|
package/dist/ShinyUrlInput.js
CHANGED
|
@@ -2,29 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useMemo, useEffect } from 'react';
|
|
4
4
|
import './TinyUrlInput.css';
|
|
5
|
-
|
|
6
|
-
const isValidUrl = (string) => {
|
|
7
|
-
try {
|
|
8
|
-
const url = new URL(string);
|
|
9
|
-
// Only allow http and https protocols
|
|
10
|
-
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
11
|
-
}
|
|
12
|
-
catch (_) {
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
|
-
// Security: Sanitize input to prevent XSS
|
|
17
|
-
const sanitizeInput = (input) => {
|
|
18
|
-
return input.trim().replace(/[<>]/g, '');
|
|
19
|
-
};
|
|
20
|
-
// Get default API base URL (same origin or localhost:5000)
|
|
21
|
-
const getDefaultApiBaseUrl = () => {
|
|
22
|
-
if (typeof window !== 'undefined') {
|
|
23
|
-
// Use same origin if available
|
|
24
|
-
return window.location.origin;
|
|
25
|
-
}
|
|
26
|
-
return 'https://shinyurl-backend.onrender.com';
|
|
27
|
-
};
|
|
5
|
+
import { shortenUrl, getDefaultApiBaseUrl, sanitizeInput, isValidUrl } from './shortener';
|
|
28
6
|
const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter URL to shorten', buttonText = 'Shorten URL', className = '', installationPageUrl }) => {
|
|
29
7
|
const [url, setUrl] = useState('');
|
|
30
8
|
const [loading, setLoading] = useState(false);
|
|
@@ -93,34 +71,13 @@ const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter
|
|
|
93
71
|
setShortUrl('');
|
|
94
72
|
setCopied(false);
|
|
95
73
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'application/json',
|
|
101
|
-
'X-API-Key': apiKey,
|
|
102
|
-
},
|
|
103
|
-
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
104
|
-
});
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
// Try to get error message from response
|
|
107
|
-
let errorMessage = 'Failed to shorten URL';
|
|
108
|
-
try {
|
|
109
|
-
const errorData = await response.json();
|
|
110
|
-
errorMessage = errorData.message || errorMessage;
|
|
111
|
-
}
|
|
112
|
-
catch (e) {
|
|
113
|
-
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
114
|
-
}
|
|
115
|
-
throw new Error(errorMessage);
|
|
116
|
-
}
|
|
117
|
-
const data = await response.json();
|
|
118
|
-
if (!data.success || !data.data) {
|
|
119
|
-
throw new Error(data.message || 'Failed to shorten URL');
|
|
74
|
+
const result = await shortenUrl(sanitizedUrl, apiKey, { apiBaseUrl: baseUrl });
|
|
75
|
+
if (!result.success || !result.data) {
|
|
76
|
+
throw new Error(result.message || 'Failed to shorten URL');
|
|
120
77
|
}
|
|
121
|
-
setShortUrl(
|
|
78
|
+
setShortUrl(result.data.shortUrl);
|
|
122
79
|
if (onSuccess) {
|
|
123
|
-
onSuccess(
|
|
80
|
+
onSuccess(result);
|
|
124
81
|
}
|
|
125
82
|
}
|
|
126
83
|
catch (err) {
|
|
@@ -128,10 +85,6 @@ const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter
|
|
|
128
85
|
if (err instanceof Error) {
|
|
129
86
|
errorMessage = err.message;
|
|
130
87
|
}
|
|
131
|
-
// Handle network errors
|
|
132
|
-
if (err instanceof TypeError && err.message.includes('fetch')) {
|
|
133
|
-
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
134
|
-
}
|
|
135
88
|
setError(errorMessage);
|
|
136
89
|
if (onError) {
|
|
137
90
|
onError(errorMessage);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { default as ShinyUrlInput } from './ShinyUrlInput';
|
|
2
2
|
export type { ShinyUrlInputProps } from './ShinyUrlInput';
|
|
3
|
+
export * from './shortener';
|
|
4
|
+
export { ShinyUrlDashboard } from './ShinyUrlDashboard';
|
|
5
|
+
export type { ShinyUrlDashboardProps } from './ShinyUrlDashboard';
|
|
3
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC1D,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ShortenResponse {
|
|
2
|
+
success: boolean;
|
|
3
|
+
data?: {
|
|
4
|
+
originalUrl: string;
|
|
5
|
+
shortUrl: string;
|
|
6
|
+
shortCode: string;
|
|
7
|
+
};
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ShortenOptions {
|
|
11
|
+
apiBaseUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const isValidUrl: (string: string) => boolean;
|
|
14
|
+
export declare const sanitizeInput: (input: string) => string;
|
|
15
|
+
export declare const getDefaultApiBaseUrl: () => string;
|
|
16
|
+
export declare const shortenUrl: (originalUrl: string, apiKey: string, options?: ShortenOptions) => Promise<ShortenResponse>;
|
|
17
|
+
//# sourceMappingURL=shortener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shortener.d.ts","sourceRoot":"","sources":["../src/shortener.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE;QACL,WAAW,EAAE,MAAM,CAAC;QACpB,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAGD,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,KAAG,OAQ3C,CAAC;AAGF,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAE7C,CAAC;AAGF,eAAO,MAAM,oBAAoB,QAAO,MAMvC,CAAC;AAEF,eAAO,MAAM,UAAU,GACrB,aAAa,MAAM,EACnB,QAAQ,MAAM,EACd,UAAS,cAAmB,KAC3B,OAAO,CAAC,eAAe,CAiDzB,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Security: Validate and sanitize URL
|
|
2
|
+
export const isValidUrl = (string) => {
|
|
3
|
+
try {
|
|
4
|
+
const url = new URL(string);
|
|
5
|
+
// Only allow http and https protocols
|
|
6
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
7
|
+
}
|
|
8
|
+
catch (_) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
// Security: Sanitize input to prevent XSS
|
|
13
|
+
export const sanitizeInput = (input) => {
|
|
14
|
+
return input.trim().replace(/[<>]/g, '');
|
|
15
|
+
};
|
|
16
|
+
// Get default API base URL (same origin or localhost:5000)
|
|
17
|
+
export const getDefaultApiBaseUrl = () => {
|
|
18
|
+
if (typeof window !== 'undefined') {
|
|
19
|
+
// Use same origin if available
|
|
20
|
+
return window.location.origin;
|
|
21
|
+
}
|
|
22
|
+
return 'https://shinyurl-backend.onrender.com';
|
|
23
|
+
};
|
|
24
|
+
export const shortenUrl = async (originalUrl, apiKey, options = {}) => {
|
|
25
|
+
const sanitizedUrl = sanitizeInput(originalUrl);
|
|
26
|
+
if (!sanitizedUrl) {
|
|
27
|
+
return { success: false, message: 'Please enter a URL' };
|
|
28
|
+
}
|
|
29
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
30
|
+
return { success: false, message: 'Please enter a valid URL (must start with http:// or https://)' };
|
|
31
|
+
}
|
|
32
|
+
const baseUrl = options.apiBaseUrl || getDefaultApiBaseUrl();
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'X-API-Key': apiKey,
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
let errorMessage = 'Failed to shorten URL';
|
|
44
|
+
try {
|
|
45
|
+
const errorData = await response.json();
|
|
46
|
+
errorMessage = errorData.message || errorMessage;
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
50
|
+
}
|
|
51
|
+
return { success: false, message: errorMessage };
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
let errorMessage = 'An error occurred';
|
|
58
|
+
if (err instanceof Error) {
|
|
59
|
+
errorMessage = err.message;
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof TypeError && typeof err.message === 'string' && err.message.includes('fetch')) {
|
|
62
|
+
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
63
|
+
}
|
|
64
|
+
return { success: false, message: errorMessage };
|
|
65
|
+
}
|
|
66
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shiny-url-input-box",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A reusable React component for generating ShinyURLs",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -22,14 +22,23 @@
|
|
|
22
22
|
],
|
|
23
23
|
"author": "",
|
|
24
24
|
"license": "ISC",
|
|
25
|
-
"peerDependencies": {
|
|
26
|
-
"react": "^18.0.0",
|
|
27
|
-
"react-dom": "^18.0.0"
|
|
28
|
-
},
|
|
29
25
|
"devDependencies": {
|
|
30
26
|
"@types/react": "^18.2.45",
|
|
31
27
|
"@types/react-dom": "^18.2.18",
|
|
32
|
-
"typescript": "^5.3.3"
|
|
28
|
+
"typescript": "^5.3.3",
|
|
29
|
+
"@types/recharts": "^1.8.29",
|
|
30
|
+
"@mui/material": "^5.15.2",
|
|
31
|
+
"@emotion/react": "^11.11.3",
|
|
32
|
+
"@emotion/styled": "^11.11.0",
|
|
33
|
+
"@mui/icons-material": "^5.15.2",
|
|
34
|
+
"recharts": "^2.10.3"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": "^18.0.0",
|
|
38
|
+
"react-dom": "^18.0.0",
|
|
39
|
+
"@mui/material": "^5.0.0",
|
|
40
|
+
"@emotion/react": "^11.0.0",
|
|
41
|
+
"@emotion/styled": "^11.0.0",
|
|
42
|
+
"recharts": "^2.0.0"
|
|
33
43
|
}
|
|
34
|
-
}
|
|
35
|
-
|
|
44
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Grid,
|
|
4
|
+
Paper,
|
|
5
|
+
Typography,
|
|
6
|
+
Box,
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CircularProgress
|
|
10
|
+
} from '@mui/material';
|
|
11
|
+
import {
|
|
12
|
+
LineChart,
|
|
13
|
+
Line,
|
|
14
|
+
XAxis,
|
|
15
|
+
YAxis,
|
|
16
|
+
CartesianGrid,
|
|
17
|
+
Tooltip,
|
|
18
|
+
Legend,
|
|
19
|
+
ResponsiveContainer,
|
|
20
|
+
} from 'recharts';
|
|
21
|
+
import { getDefaultApiBaseUrl } from './shortener';
|
|
22
|
+
|
|
23
|
+
export interface ShinyUrlDashboardProps {
|
|
24
|
+
apiKey: string;
|
|
25
|
+
apiBaseUrl?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StatsData {
|
|
31
|
+
stats: {
|
|
32
|
+
totalUrls: number;
|
|
33
|
+
totalClicks: number;
|
|
34
|
+
uniqueIps: number;
|
|
35
|
+
clicksToday: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ClickAnalyticsData {
|
|
40
|
+
data: Array<{
|
|
41
|
+
_id: string; // date
|
|
42
|
+
count: number;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TopUrlData {
|
|
47
|
+
data: Array<{
|
|
48
|
+
_id: string;
|
|
49
|
+
originalUrl: string;
|
|
50
|
+
shortCode: string;
|
|
51
|
+
clickCount: number;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const ShinyUrlDashboard: React.FC<ShinyUrlDashboardProps> = ({
|
|
56
|
+
apiKey,
|
|
57
|
+
apiBaseUrl,
|
|
58
|
+
className = '',
|
|
59
|
+
title = 'Analytics Dashboard'
|
|
60
|
+
}) => {
|
|
61
|
+
const [loading, setLoading] = useState(true);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
const [stats, setStats] = useState<StatsData | null>(null);
|
|
64
|
+
const [clicks, setClicks] = useState<ClickAnalyticsData | null>(null);
|
|
65
|
+
const [topUrls, setTopUrls] = useState<TopUrlData | null>(null);
|
|
66
|
+
|
|
67
|
+
const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const fetchData = async () => {
|
|
71
|
+
if (!apiKey) return;
|
|
72
|
+
|
|
73
|
+
setLoading(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const headers = {
|
|
78
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
79
|
+
'Content-Type': 'application/json'
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Try standard X-API-Key as well if Bearer fails?
|
|
83
|
+
// Admin panel uses Bearer for JWT.
|
|
84
|
+
// If user passes an API Key (sk_...), the backend might need 'X-API-Key'.
|
|
85
|
+
// For now, let's assume if it looks like a JWT (3 distinct parts), use Bearer, else X-API-Key?
|
|
86
|
+
// Or simpler: try to fetch with headers that match the backend's expectation for the key type.
|
|
87
|
+
// The prompt says "admin panel components... user can import this dashboard creds".
|
|
88
|
+
// If it's an "Admin Dashboard", it likely needs admin 'Bearer' token.
|
|
89
|
+
// If it's a "User Dashboard" (showing user's URLs), it might use 'X-API-Key'.
|
|
90
|
+
// The endpoints used in Admin Dashboard (/admin/...) are protected by JWT.
|
|
91
|
+
// Endpoints for users might be different or same.
|
|
92
|
+
// I will implement a fallback or accept a 'keyType' prop later if needed.
|
|
93
|
+
// For now, I'll follow the admin pattern (Bearer) as requested by "dashboard creds in other admin panels".
|
|
94
|
+
|
|
95
|
+
const fetchOptions = { headers };
|
|
96
|
+
|
|
97
|
+
const [statsRes, clicksRes, topUrlsRes] = await Promise.all([
|
|
98
|
+
fetch(`${baseUrl}/api/admin/dashboard/stats`, fetchOptions),
|
|
99
|
+
fetch(`${baseUrl}/api/admin/analytics/clicks?days=7`, fetchOptions),
|
|
100
|
+
fetch(`${baseUrl}/api/admin/analytics/top-urls?limit=10`, fetchOptions)
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
if (!statsRes.ok || !clicksRes.ok || !topUrlsRes.ok) {
|
|
104
|
+
// Check if 401
|
|
105
|
+
if (statsRes.status === 401 || clicksRes.status === 401 || topUrlsRes.status === 401) {
|
|
106
|
+
throw new Error("Unauthorized: Invalid API Key or Token");
|
|
107
|
+
}
|
|
108
|
+
throw new Error("Failed to fetch dashboard data");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const statsData = await statsRes.json();
|
|
112
|
+
const clicksData = await clicksRes.json();
|
|
113
|
+
const topUrlsData = await topUrlsRes.json();
|
|
114
|
+
|
|
115
|
+
setStats(statsData);
|
|
116
|
+
setClicks(clicksData);
|
|
117
|
+
setTopUrls(topUrlsData);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof Error) {
|
|
120
|
+
setError(err.message);
|
|
121
|
+
} else {
|
|
122
|
+
setError("An unknown error occurred");
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
setLoading(false);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
fetchData();
|
|
130
|
+
}, [apiKey, baseUrl]);
|
|
131
|
+
|
|
132
|
+
if (!apiKey) {
|
|
133
|
+
return (
|
|
134
|
+
<Box p={3} className={className}>
|
|
135
|
+
<Typography color="error">Please provide an API Key / Token to view the dashboard.</Typography>
|
|
136
|
+
</Box>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (loading) {
|
|
141
|
+
return (
|
|
142
|
+
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px" className={className}>
|
|
143
|
+
<CircularProgress />
|
|
144
|
+
</Box>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (error) {
|
|
149
|
+
return (
|
|
150
|
+
<Box p={3} className={className}>
|
|
151
|
+
<Typography color="error">Error: {error}</Typography>
|
|
152
|
+
</Box>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const statCards = [
|
|
157
|
+
{
|
|
158
|
+
title: 'Total URLs',
|
|
159
|
+
value: stats?.stats?.totalUrls || 0,
|
|
160
|
+
color: '#1976d2',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
title: 'Total Clicks',
|
|
164
|
+
value: stats?.stats?.totalClicks || 0,
|
|
165
|
+
color: '#2e7d32',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
title: 'Unique IPs',
|
|
169
|
+
value: stats?.stats?.uniqueIps || 0,
|
|
170
|
+
color: '#ed6c02',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
title: 'Clicks Today',
|
|
174
|
+
value: stats?.stats?.clicksToday || 0,
|
|
175
|
+
color: '#d32f2f',
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const chartData = clicks?.data?.map((item: any) => ({
|
|
180
|
+
date: item._id,
|
|
181
|
+
clicks: item.count,
|
|
182
|
+
})) || [];
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Box className={className}>
|
|
186
|
+
<Typography variant="h4" gutterBottom>
|
|
187
|
+
{title}
|
|
188
|
+
</Typography>
|
|
189
|
+
|
|
190
|
+
<Grid container spacing={3} sx={{ mb: 3 }}>
|
|
191
|
+
{statCards.map((card) => (
|
|
192
|
+
<Grid item xs={12} sm={6} md={3} key={card.title}>
|
|
193
|
+
<Card>
|
|
194
|
+
<CardContent>
|
|
195
|
+
<Typography color="text.secondary" gutterBottom>
|
|
196
|
+
{card.title}
|
|
197
|
+
</Typography>
|
|
198
|
+
<Typography variant="h4" sx={{ color: card.color }}>
|
|
199
|
+
{card.value.toLocaleString()}
|
|
200
|
+
</Typography>
|
|
201
|
+
</CardContent>
|
|
202
|
+
</Card>
|
|
203
|
+
</Grid>
|
|
204
|
+
))}
|
|
205
|
+
</Grid>
|
|
206
|
+
|
|
207
|
+
<Grid container spacing={3}>
|
|
208
|
+
<Grid item xs={12} md={8}>
|
|
209
|
+
<Paper sx={{ p: 3 }}>
|
|
210
|
+
<Typography variant="h6" gutterBottom>
|
|
211
|
+
Clicks Over Time (Last 7 Days)
|
|
212
|
+
</Typography>
|
|
213
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
214
|
+
<LineChart data={chartData}>
|
|
215
|
+
<CartesianGrid strokeDasharray="3 3" />
|
|
216
|
+
<XAxis dataKey="date" />
|
|
217
|
+
<YAxis />
|
|
218
|
+
<Tooltip />
|
|
219
|
+
<Legend />
|
|
220
|
+
<Line
|
|
221
|
+
type="monotone"
|
|
222
|
+
dataKey="clicks"
|
|
223
|
+
stroke="#1976d2"
|
|
224
|
+
strokeWidth={2}
|
|
225
|
+
/>
|
|
226
|
+
</LineChart>
|
|
227
|
+
</ResponsiveContainer>
|
|
228
|
+
</Paper>
|
|
229
|
+
</Grid>
|
|
230
|
+
|
|
231
|
+
<Grid item xs={12} md={4}>
|
|
232
|
+
<Paper sx={{ p: 3 }}>
|
|
233
|
+
<Typography variant="h6" gutterBottom>
|
|
234
|
+
Top URLs
|
|
235
|
+
</Typography>
|
|
236
|
+
<Box sx={{ maxHeight: 300, overflow: 'auto' }}>
|
|
237
|
+
{topUrls?.data?.map((url: any, index: number) => (
|
|
238
|
+
<Box
|
|
239
|
+
key={url._id}
|
|
240
|
+
sx={{
|
|
241
|
+
p: 1,
|
|
242
|
+
mb: 1,
|
|
243
|
+
bgcolor: 'background.default',
|
|
244
|
+
borderRadius: 1,
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<Typography variant="body2" noWrap>
|
|
248
|
+
{index + 1}. {url.originalUrl}
|
|
249
|
+
</Typography>
|
|
250
|
+
<Typography variant="caption" color="text.secondary">
|
|
251
|
+
{url.clickCount} clicks
|
|
252
|
+
</Typography>
|
|
253
|
+
</Box>
|
|
254
|
+
))}
|
|
255
|
+
</Box>
|
|
256
|
+
</Paper>
|
|
257
|
+
</Grid>
|
|
258
|
+
</Grid>
|
|
259
|
+
</Box>
|
|
260
|
+
);
|
|
261
|
+
};
|
package/src/ShinyUrlInput.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useMemo, useEffect } from 'react';
|
|
4
4
|
import './TinyUrlInput.css';
|
|
5
|
+
import { shortenUrl, ShortenResponse, getDefaultApiBaseUrl, sanitizeInput, isValidUrl } from './shortener';
|
|
5
6
|
|
|
6
7
|
export interface ShinyUrlInputProps {
|
|
7
8
|
apiKey: string;
|
|
@@ -14,41 +15,6 @@ export interface ShinyUrlInputProps {
|
|
|
14
15
|
installationPageUrl?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
interface ShortenResponse {
|
|
18
|
-
success: boolean;
|
|
19
|
-
data?: {
|
|
20
|
-
originalUrl: string;
|
|
21
|
-
shortUrl: string;
|
|
22
|
-
shortCode: string;
|
|
23
|
-
};
|
|
24
|
-
message?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Security: Validate and sanitize URL
|
|
28
|
-
const isValidUrl = (string: string): boolean => {
|
|
29
|
-
try {
|
|
30
|
-
const url = new URL(string);
|
|
31
|
-
// Only allow http and https protocols
|
|
32
|
-
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
33
|
-
} catch (_) {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// Security: Sanitize input to prevent XSS
|
|
39
|
-
const sanitizeInput = (input: string): string => {
|
|
40
|
-
return input.trim().replace(/[<>]/g, '');
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// Get default API base URL (same origin or localhost:5000)
|
|
44
|
-
const getDefaultApiBaseUrl = (): string => {
|
|
45
|
-
if (typeof window !== 'undefined') {
|
|
46
|
-
// Use same origin if available
|
|
47
|
-
return window.location.origin;
|
|
48
|
-
}
|
|
49
|
-
return 'https://shinyurl-backend.onrender.com';
|
|
50
|
-
};
|
|
51
|
-
|
|
52
18
|
const ShinyUrlInput: React.FC<ShinyUrlInputProps> = ({
|
|
53
19
|
apiKey,
|
|
54
20
|
apiBaseUrl,
|
|
@@ -135,51 +101,22 @@ const ShinyUrlInput: React.FC<ShinyUrlInputProps> = ({
|
|
|
135
101
|
setCopied(false);
|
|
136
102
|
|
|
137
103
|
try {
|
|
138
|
-
|
|
139
|
-
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
140
|
-
method: 'POST',
|
|
141
|
-
headers: {
|
|
142
|
-
'Content-Type': 'application/json',
|
|
143
|
-
'X-API-Key': apiKey,
|
|
144
|
-
},
|
|
145
|
-
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
146
|
-
});
|
|
104
|
+
const result = await shortenUrl(sanitizedUrl, apiKey, { apiBaseUrl: baseUrl });
|
|
147
105
|
|
|
148
|
-
if (!
|
|
149
|
-
|
|
150
|
-
let errorMessage = 'Failed to shorten URL';
|
|
151
|
-
try {
|
|
152
|
-
const errorData: ShortenResponse = await response.json();
|
|
153
|
-
errorMessage = errorData.message || errorMessage;
|
|
154
|
-
} catch (e) {
|
|
155
|
-
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
156
|
-
}
|
|
157
|
-
throw new Error(errorMessage);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const data: ShortenResponse = await response.json();
|
|
161
|
-
|
|
162
|
-
if (!data.success || !data.data) {
|
|
163
|
-
throw new Error(data.message || 'Failed to shorten URL');
|
|
106
|
+
if (!result.success || !result.data) {
|
|
107
|
+
throw new Error(result.message || 'Failed to shorten URL');
|
|
164
108
|
}
|
|
165
109
|
|
|
166
|
-
setShortUrl(
|
|
110
|
+
setShortUrl(result.data.shortUrl);
|
|
167
111
|
|
|
168
112
|
if (onSuccess) {
|
|
169
|
-
onSuccess(
|
|
113
|
+
onSuccess(result);
|
|
170
114
|
}
|
|
171
115
|
} catch (err) {
|
|
172
116
|
let errorMessage = 'An error occurred';
|
|
173
|
-
|
|
174
117
|
if (err instanceof Error) {
|
|
175
118
|
errorMessage = err.message;
|
|
176
119
|
}
|
|
177
|
-
|
|
178
|
-
// Handle network errors
|
|
179
|
-
if (err instanceof TypeError && err.message.includes('fetch')) {
|
|
180
|
-
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
120
|
setError(errorMessage);
|
|
184
121
|
|
|
185
122
|
if (onError) {
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { default as ShinyUrlInput } from './ShinyUrlInput';
|
|
2
2
|
export type { ShinyUrlInputProps } from './ShinyUrlInput';
|
|
3
|
+
export * from './shortener';
|
|
4
|
+
export { ShinyUrlDashboard } from './ShinyUrlDashboard';
|
|
5
|
+
export type { ShinyUrlDashboardProps } from './ShinyUrlDashboard';
|
|
3
6
|
|
package/src/shortener.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface ShortenResponse {
|
|
2
|
+
success: boolean;
|
|
3
|
+
data?: {
|
|
4
|
+
originalUrl: string;
|
|
5
|
+
shortUrl: string;
|
|
6
|
+
shortCode: string;
|
|
7
|
+
};
|
|
8
|
+
message?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ShortenOptions {
|
|
12
|
+
apiBaseUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Security: Validate and sanitize URL
|
|
16
|
+
export const isValidUrl = (string: string): boolean => {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(string);
|
|
19
|
+
// Only allow http and https protocols
|
|
20
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Security: Sanitize input to prevent XSS
|
|
27
|
+
export const sanitizeInput = (input: string): string => {
|
|
28
|
+
return input.trim().replace(/[<>]/g, '');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Get default API base URL (same origin or localhost:5000)
|
|
32
|
+
export const getDefaultApiBaseUrl = (): string => {
|
|
33
|
+
if (typeof window !== 'undefined') {
|
|
34
|
+
// Use same origin if available
|
|
35
|
+
return window.location.origin;
|
|
36
|
+
}
|
|
37
|
+
return 'https://shinyurl-backend.onrender.com';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const shortenUrl = async (
|
|
41
|
+
originalUrl: string,
|
|
42
|
+
apiKey: string,
|
|
43
|
+
options: ShortenOptions = {}
|
|
44
|
+
): Promise<ShortenResponse> => {
|
|
45
|
+
const sanitizedUrl = sanitizeInput(originalUrl);
|
|
46
|
+
|
|
47
|
+
if (!sanitizedUrl) {
|
|
48
|
+
return { success: false, message: 'Please enter a URL' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
52
|
+
return { success: false, message: 'Please enter a valid URL (must start with http:// or https://)' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const baseUrl = options.apiBaseUrl || getDefaultApiBaseUrl();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'X-API-Key': apiKey,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
let errorMessage = 'Failed to shorten URL';
|
|
69
|
+
try {
|
|
70
|
+
const errorData = await response.json();
|
|
71
|
+
errorMessage = errorData.message || errorMessage;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
74
|
+
}
|
|
75
|
+
return { success: false, message: errorMessage };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data: ShortenResponse = await response.json();
|
|
79
|
+
return data;
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
let errorMessage = 'An error occurred';
|
|
82
|
+
|
|
83
|
+
if (err instanceof Error) {
|
|
84
|
+
errorMessage = err.message;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (err instanceof TypeError && typeof err.message === 'string' && err.message.includes('fetch')) {
|
|
88
|
+
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { success: false, message: errorMessage };
|
|
92
|
+
}
|
|
93
|
+
};
|