+16
.vscode/launch.json
+16
.vscode/launch.json
···
1
+
{
2
+
"version": "0.2.0",
3
+
"configurations": [
4
+
{
5
+
"name": "Wrangler",
6
+
"type": "node",
7
+
"request": "attach",
8
+
"port": 9229,
9
+
"resolveSourceMapLocations": null,
10
+
"attachExistingChildren": false,
11
+
"autoAttachChildProcesses": false,
12
+
"localRoot": "${workspaceRoot}/src",
13
+
"sourceMaps": true,
14
+
}
15
+
]
16
+
}
+4
-4
package-lock.json
+4
-4
package-lock.json
···
1
1
{
2
-
"name": "jolly-sun-74a6",
3
-
"version": "0.0.0",
2
+
"name": "blupimgsblue",
3
+
"version": "0.1.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
-
"name": "jolly-sun-74a6",
9
-
"version": "0.0.0",
8
+
"name": "blupimgsblue",
9
+
"version": "0.1.0",
10
10
"devDependencies": {
11
11
"@cloudflare/vitest-pool-workers": "^0.8.19",
12
12
"typescript": "^5.5.2",
+2
-2
package.json
+2
-2
package.json
+38
-30
public/index.html
+38
-30
public/index.html
···
1
-
<!doctype html>
1
+
<!DOCTYPE html>
2
2
<html lang="en">
3
-
<head>
4
-
<meta charset="UTF-8" />
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Hello, World!</title>
7
-
</head>
8
-
<body>
9
-
<h1 id="heading"></h1>
10
-
<p>This page comes from a static asset stored at `public/index.html` as configured in `wrangler.jsonc`.</p>
11
-
<button id="button" type="button">Fetch a random UUID</button>
12
-
<output id="random" for="button"></output>
13
-
<script>
14
-
fetch('/message')
15
-
.then((resp) => resp.text())
16
-
.then((text) => {
17
-
const h1 = document.getElementById('heading');
18
-
h1.textContent = text;
19
-
});
20
3
21
-
const button = document.getElementById("button");
22
-
button.addEventListener("click", () => {
23
-
fetch('/random')
24
-
.then((resp) => resp.text())
25
-
.then((text) => {
26
-
const random = document.getElementById('random');
27
-
random.textContent = text;
28
-
});
29
-
});
30
-
</script>
31
-
</body>
32
-
</html>
4
+
<head>
5
+
<meta charset="UTF-8">
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
<title>blup - Beautiful Image Uploads</title>
8
+
<link rel="stylesheet" href="/styles.css">
9
+
</head>
10
+
11
+
<body>
12
+
<div class="container">
13
+
<div class="icon">
14
+
<div class="logo-icon">B</div>
15
+
</div>
16
+
17
+
<h1>blup</h1>
18
+
19
+
<p>Upload images to the blue sky ☁️</p>
20
+
<p>A fast CLI tool for uploading images to your AT Protocol PDS and getting instant CDN URLs through
21
+
images.blue.</p>
22
+
23
+
<div class="instructions">
24
+
<h3>Installation</h3>
25
+
<p>Install the latest version from <a href="https://tangled.sh/@evan.jarrett.net/blup"
26
+
class="link">tangled.sh</a>:</p>
27
+
<code>go get https://tangled.sh/@evan.jarrett.net/blup@latest</code>
28
+
29
+
<h3>Or browse the source</h3>
30
+
<p>View the repository at <a href="https://tangled.sh/@evan.jarrett.net/blup"
31
+
class="link">tangled.sh/@evan.jarrett.net/blup</a></p>
32
+
</div>
33
+
34
+
<div class="footer">
35
+
Built with 💙 for the AT Protocol ecosystem
36
+
</div>
37
+
</div>
38
+
</body>
39
+
40
+
</html>
+19
public/oauth/client-metadata.json
+19
public/oauth/client-metadata.json
···
1
+
{
2
+
"client_id": "https://blup.imgs.blue/oauth/client-metadata.json",
3
+
"client_name": "Blup",
4
+
"client_uri": "https://blup.imgs.blue",
5
+
"grant_types": [
6
+
"authorization_code",
7
+
"refresh_token"
8
+
],
9
+
"scope": "atproto transition:generic",
10
+
"response_types": [
11
+
"code"
12
+
],
13
+
"redirect_uris": [
14
+
"https://blup.imgs.blue/oauth/callback"
15
+
],
16
+
"dpop_bound_access_tokens": true,
17
+
"token_endpoint_auth_method": "none",
18
+
"application_type": "native"
19
+
}
+47
public/oauth/success/index.html
+47
public/oauth/success/index.html
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
4
+
<head>
5
+
<meta charset="UTF-8">
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
<title>Authorization Successful</title>
8
+
<link rel="stylesheet" href="/styles.css">
9
+
</head>
10
+
11
+
<body>
12
+
<div class="container">
13
+
<div class="icon success-icon">
14
+
<div class="checkmark"></div>
15
+
</div>
16
+
17
+
<h1>Authorization Successful!</h1>
18
+
19
+
<p>Your application has been successfully connected.</p>
20
+
21
+
<div class="instructions">
22
+
<p><strong>You can now close this window</strong> and return to your terminal.</p>
23
+
<p style="margin-top: 0.5rem; font-size: 0.95rem;">
24
+
Waiting for your CLI to confirm receipt
25
+
<span class="spinner"></span>
26
+
</p>
27
+
</div>
28
+
29
+
<div class="footer">
30
+
Secured with AT Protocol OAuth
31
+
</div>
32
+
</div>
33
+
34
+
<script>
35
+
// Optional: Try to close the window after a delay
36
+
// This won't work unless the window was opened by script
37
+
setTimeout(() => {
38
+
window.close();
39
+
// If window.close() doesn't work, update the UI
40
+
document.querySelector('.instructions').innerHTML =
41
+
'<p><strong>✓ Authentication complete!</strong></p>' +
42
+
'<p style="margin-top: 0.5rem;">You can safely close this window.</p>';
43
+
}, 3000);
44
+
</script>
45
+
</body>
46
+
47
+
</html>`
+165
public/styles.css
+165
public/styles.css
···
1
+
* {
2
+
margin: 0;
3
+
padding: 0;
4
+
box-sizing: border-box;
5
+
}
6
+
7
+
body {
8
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
9
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
10
+
min-height: 100vh;
11
+
display: flex;
12
+
align-items: center;
13
+
justify-content: center;
14
+
color: #333;
15
+
}
16
+
17
+
.container {
18
+
background: white;
19
+
padding: 3rem;
20
+
border-radius: 20px;
21
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
22
+
text-align: center;
23
+
max-width: 700px;
24
+
width: 90%;
25
+
animation: slideIn 0.4s ease-out;
26
+
}
27
+
28
+
@keyframes slideIn {
29
+
from {
30
+
opacity: 0;
31
+
transform: translateY(20px);
32
+
}
33
+
34
+
to {
35
+
opacity: 1;
36
+
transform: translateY(0);
37
+
}
38
+
}
39
+
40
+
.icon {
41
+
width: 80px;
42
+
height: 80px;
43
+
margin: 0 auto 1.5rem;
44
+
position: relative;
45
+
}
46
+
47
+
.success-icon .checkmark {
48
+
width: 100%;
49
+
height: 100%;
50
+
border-radius: 50%;
51
+
background: #4ade80;
52
+
position: relative;
53
+
animation: scaleIn 0.4s ease-out 0.2s both;
54
+
}
55
+
56
+
.logo-icon {
57
+
width: 100%;
58
+
height: 100%;
59
+
border-radius: 50%;
60
+
background: #6366f1;
61
+
position: relative;
62
+
animation: scaleIn 0.4s ease-out 0.2s both;
63
+
display: flex;
64
+
align-items: center;
65
+
justify-content: center;
66
+
font-size: 2rem;
67
+
font-weight: bold;
68
+
color: white;
69
+
}
70
+
71
+
@keyframes scaleIn {
72
+
from {
73
+
transform: scale(0);
74
+
}
75
+
76
+
to {
77
+
transform: scale(1);
78
+
}
79
+
}
80
+
81
+
.checkmark::after {
82
+
content: '';
83
+
position: absolute;
84
+
width: 30%;
85
+
height: 50%;
86
+
border: solid white;
87
+
border-width: 0 5px 5px 0;
88
+
left: 35%;
89
+
top: 20%;
90
+
transform: rotate(45deg);
91
+
}
92
+
93
+
h1 {
94
+
color: #1f2937;
95
+
font-size: 1.75rem;
96
+
margin-bottom: 0.75rem;
97
+
font-weight: 600;
98
+
}
99
+
100
+
p {
101
+
color: #6b7280;
102
+
font-size: 1.1rem;
103
+
line-height: 1.6;
104
+
margin-bottom: 1.5rem;
105
+
}
106
+
107
+
.instructions {
108
+
background: #f3f4f6;
109
+
padding: 1rem;
110
+
border-radius: 10px;
111
+
margin-top: 1.5rem;
112
+
text-align: left;
113
+
}
114
+
115
+
.instructions h3 {
116
+
color: #1f2937;
117
+
font-size: 1.1rem;
118
+
margin-bottom: 0.5rem;
119
+
}
120
+
121
+
.instructions code {
122
+
background: #e5e7eb;
123
+
padding: 0.2rem 0.4rem;
124
+
border-radius: 4px;
125
+
font-family: 'Courier New', monospace;
126
+
font-size: 0.9rem;
127
+
display: block;
128
+
margin: 0.5rem 0;
129
+
padding: 0.75rem;
130
+
white-space: pre-wrap;
131
+
}
132
+
133
+
.footer {
134
+
margin-top: 2rem;
135
+
font-size: 0.9rem;
136
+
color: #9ca3af;
137
+
}
138
+
139
+
.spinner {
140
+
display: inline-block;
141
+
width: 16px;
142
+
height: 16px;
143
+
border: 2px solid #e5e7eb;
144
+
border-radius: 50%;
145
+
border-top-color: #6366f1;
146
+
animation: spin 1s ease-in-out infinite;
147
+
margin-left: 0.5rem;
148
+
vertical-align: middle;
149
+
}
150
+
151
+
@keyframes spin {
152
+
to {
153
+
transform: rotate(360deg);
154
+
}
155
+
}
156
+
157
+
.link {
158
+
color: #6366f1;
159
+
text-decoration: none;
160
+
font-weight: 500;
161
+
}
162
+
163
+
.link:hover {
164
+
text-decoration: underline;
165
+
}
+124
-19
src/index.ts
+124
-19
src/index.ts
···
1
-
/**
2
-
* Welcome to Cloudflare Workers! This is your first worker.
3
-
*
4
-
* - Run `npm run dev` in your terminal to start a development server
5
-
* - Open a browser tab at http://localhost:8787/ to see your worker in action
6
-
* - Run `npm run deploy` to publish your worker
7
-
*
8
-
* Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
9
-
* `Env` object can be regenerated with `npm run cf-typegen`.
10
-
*
11
-
* Learn more at https://developers.cloudflare.com/workers/
12
-
*/
1
+
2
+
interface Env {
3
+
OAUTH_SESSIONS: KVNamespace;
4
+
ASSETS: Fetcher;
5
+
}
13
6
14
7
export default {
15
-
async fetch(request, env, ctx): Promise<Response> {
8
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
16
9
const url = new URL(request.url);
17
10
switch (url.pathname) {
18
-
case '/message':
19
-
return new Response('Hello, World!');
20
-
case '/random':
21
-
return new Response(crypto.randomUUID());
22
-
default:
23
-
return new Response('Not Found', { status: 404 });
11
+
case '/oauth/callback':
12
+
return handleOAuthCallback(request, env, ctx);
13
+
case '/oauth/events':
14
+
return handleSSE(request, env, ctx)
24
15
}
16
+
return env.ASSETS.fetch(request);
25
17
},
26
18
} satisfies ExportedHandler<Env>;
19
+
20
+
async function handleSSE(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
21
+
const url = new URL(request.url);
22
+
const sessionId = url.searchParams.get('session');
23
+
24
+
if (!sessionId) {
25
+
return new Response('Missing session ID', { status: 400 });
26
+
}
27
+
28
+
// Create a TransformStream for SSE
29
+
const { readable, writable } = new TransformStream();
30
+
const writer = writable.getWriter();
31
+
const encoder = new TextEncoder();
32
+
33
+
// SSE headers
34
+
const headers = {
35
+
'Content-Type': 'text/event-stream',
36
+
'Cache-Control': 'no-cache',
37
+
'Connection': 'keep-alive',
38
+
'Access-Control-Allow-Origin': '*', // Adjust for your security needs
39
+
};
40
+
41
+
// Start the SSE response
42
+
const response = new Response(readable, { headers });
43
+
44
+
// Handle the async SSE logic
45
+
(async () => {
46
+
try {
47
+
// Send initial connection message
48
+
await writer.write(encoder.encode(': ping\n\n'));
49
+
50
+
// Poll for auth data (max 5 minutes)
51
+
const maxAttempts = 60; // 5 minutes with 5-second intervals
52
+
let attempts = 0;
53
+
54
+
while (attempts < maxAttempts) {
55
+
// Check if auth data exists in KV
56
+
const authData = await env.OAUTH_SESSIONS.get(sessionId);
57
+
58
+
if (authData) {
59
+
// Parse and send the auth data
60
+
const data = JSON.parse(authData);
61
+
62
+
// Send auth complete event
63
+
await writer.write(encoder.encode(
64
+
`event: auth-complete\ndata: ${authData}\n\n`
65
+
));
66
+
67
+
// Delete the session immediately after sending
68
+
await env.OAUTH_SESSIONS.delete(sessionId);
69
+
70
+
// Send close event
71
+
await writer.write(encoder.encode('event: close\ndata: {}\n\n'));
72
+
break;
73
+
}
74
+
75
+
// Wait 5 seconds before next check
76
+
await new Promise(resolve => setTimeout(resolve, 5000));
77
+
78
+
// Send keepalive
79
+
await writer.write(encoder.encode(': keepalive\n\n'));
80
+
81
+
attempts++;
82
+
}
83
+
84
+
// Timeout if no auth received
85
+
if (attempts >= maxAttempts) {
86
+
await writer.write(encoder.encode(
87
+
'event: timeout\ndata: {"error": "Authentication timeout"}\n\n'
88
+
));
89
+
}
90
+
91
+
} catch (error: unknown) {
92
+
// Send error event
93
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
94
+
await writer.write(encoder.encode(
95
+
`event: error\ndata: ${JSON.stringify({ error: errorMessage })}\n\n`
96
+
));
97
+
} finally {
98
+
await writer.close();
99
+
}
100
+
})();
101
+
102
+
return response;
103
+
}
104
+
105
+
async function handleOAuthCallback(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
106
+
const url = new URL(request.url);
107
+
const code = url.searchParams.get('code');
108
+
const iss = url.searchParams.get('iss');
109
+
const state = url.searchParams.get('state');
110
+
111
+
if (!code || !state || !iss) {
112
+
return new Response('Missing required parameters', { status: 400 });
113
+
}
114
+
115
+
// Store auth data in KV
116
+
await env.OAUTH_SESSIONS.put(
117
+
state,
118
+
JSON.stringify({
119
+
code,
120
+
iss,
121
+
state,
122
+
timestamp: Date.now()
123
+
}),
124
+
{
125
+
expirationTtl: 300 // 5 minutes
126
+
}
127
+
);
128
+
129
+
// Redirect to success page
130
+
return Response.redirect(new URL('/oauth/success', request.url).toString(), 302);
131
+
}
+16
-3
wrangler.jsonc
+16
-3
wrangler.jsonc
···
4
4
*/
5
5
{
6
6
"$schema": "node_modules/wrangler/config-schema.json",
7
-
"name": "jolly-sun-74a6",
7
+
"name": "blupimgsblue",
8
8
"main": "src/index.ts",
9
9
"compatibility_date": "2025-06-28",
10
10
"compatibility_flags": [
11
11
"global_fetch_strictly_public"
12
12
],
13
13
"assets": {
14
-
"directory": "./public"
14
+
"directory": "./public",
15
+
"binding": "ASSETS",
15
16
},
16
17
"observability": {
17
18
"enabled": true
18
-
}
19
+
},
20
+
"kv_namespaces": [
21
+
{
22
+
"binding": "OAUTH_SESSIONS",
23
+
"id": "dcd7506b76584cf1b2eef1d0effbd45b"
24
+
}
25
+
],
26
+
"routes": [
27
+
{
28
+
"pattern": "blup.imgs.blue",
29
+
"custom_domain": true
30
+
}
31
+
]
19
32
/**
20
33
* Smart Placement
21
34
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement