Skip to content

Commit c6df745

Browse files
authored
feat: Plugin architecture
feat: Plugin architecture
2 parents f45bcdf + b4898ce commit c6df745

File tree

11 files changed

+619
-379
lines changed

11 files changed

+619
-379
lines changed

plugins/studio/handler.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
interface HandleStudioRequestOption {
2+
username: string
3+
password: string
4+
apiKey: string
5+
}
6+
7+
function createStudioHTML(apiKey: string): string {
8+
return `<!doctype>
9+
<html>
10+
<head>
11+
<style>
12+
html, body {
13+
padding: 0;
14+
margin: 0;
15+
width: 100vw;
16+
height: 100vh;
17+
}
18+
19+
iframe {
20+
width: 100vw;
21+
height: 100vh;
22+
overflow: hidden;
23+
border: 0;
24+
}
25+
</style>
26+
<title>Your Starbase - Outerbase Studio</title>
27+
<link rel="icon" type="image/x-icon" href="https://studio.outerbase.com/icons/outerbase.ico">
28+
</head>
29+
<body>
30+
<script>
31+
function handler(e) {
32+
if (e.data.type !== "query" && e.data.type !== "transaction") return;
33+
let requestBody = e.data.type === 'transaction' ?
34+
{ transaction: e.data.statements.map(t => ({sql: t})) } :
35+
{ sql: e.data.statement };
36+
37+
fetch("/query/raw", {
38+
method: "post",
39+
headers: {
40+
"Content-Type": "application/json",
41+
"Authorization": "Bearer ${apiKey}"
42+
},
43+
body: JSON.stringify(requestBody)
44+
}).then(r => {
45+
if (!r.ok) {
46+
document.getElementById('editor').contentWindow.postMessage({
47+
id: e.data.id,
48+
type: e.data.type,
49+
error: "Something went wrong",
50+
}, "*");
51+
throw new Error("Something went wrong");
52+
}
53+
return r.json()
54+
}).then(r => {
55+
const response = {
56+
id: e.data.id,
57+
type: e.data.type,
58+
data: Array.isArray(r.result) ? r.result.map(transformRawResult) : transformRawResult(r.result),
59+
};
60+
61+
document.getElementById('editor').contentWindow.postMessage(response, "*");
62+
}).catch(console.error)
63+
}
64+
65+
function transformRawResult(raw) {
66+
const columns = raw.columns ?? [];
67+
const values = raw.rows;
68+
const headerSet = new Set();
69+
70+
const headers = columns.map((colName) => {
71+
let renameColName = colName;
72+
73+
for (let i = 0; i < 20; i++) {
74+
if (!headerSet.has(renameColName)) break;
75+
renameColName = \`__\${colName}_\${i}\`;
76+
}
77+
78+
return {
79+
name: renameColName,
80+
displayName: colName,
81+
originalType: "text",
82+
type: undefined,
83+
};
84+
});
85+
86+
const rows = values
87+
? values.map((r) =>
88+
headers.reduce((a, b, idx) => {
89+
a[b.name] = r[idx];
90+
return a;
91+
}, {})
92+
)
93+
: [];
94+
95+
return {
96+
rows,
97+
stat: {
98+
queryDurationMs: 0,
99+
rowsAffected: 0,
100+
rowsRead: raw.meta.rows_read,
101+
rowsWritten: raw.meta.rows_written,
102+
},
103+
headers,
104+
};
105+
}
106+
107+
window.addEventListener("message", handler);
108+
</script>
109+
110+
<iframe
111+
id="editor"
112+
src="https://studio.outerbase.com/embed/starbase"
113+
/>
114+
</body>
115+
</html>`
116+
}
117+
118+
export async function handleStudioRequest(
119+
request: Request,
120+
options: HandleStudioRequestOption
121+
): Promise<Response> {
122+
// Check for basic authorization
123+
const auth = request.headers.get('Authorization')
124+
125+
if (!auth || !auth.startsWith('Basic ')) {
126+
return new Response('Unauthorized', {
127+
status: 401,
128+
headers: {
129+
'WWW-Authenticate': 'Basic realm="Access to the studio"',
130+
},
131+
})
132+
}
133+
134+
// base64 auth
135+
const base64Auth = auth.split('Basic ')[1]
136+
const decodedAuth = atob(base64Auth)
137+
const [username, password] = decodedAuth.split(':')
138+
139+
if (username !== options.username || password !== options.password) {
140+
return new Response('Unauthorized', {
141+
status: 401,
142+
headers: {
143+
'WWW-Authenticate': 'Basic realm="Access to the studio"',
144+
},
145+
})
146+
}
147+
148+
// Proceed with the request
149+
return new Response(createStudioHTML(options.apiKey), {
150+
headers: { 'Content-Type': 'text/html' },
151+
})
152+
}

plugins/studio/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { StarbaseApp } from '../../src/handler'
2+
import { StarbasePlugin } from '../../src/plugin'
3+
import { handleStudioRequest } from './handler'
4+
5+
export class StudioPlugin extends StarbasePlugin {
6+
private username: string
7+
private password: string
8+
private apiKey: string
9+
10+
constructor(options: {
11+
username?: string
12+
password?: string
13+
apiKey: string
14+
prefix?: string
15+
}) {
16+
super(
17+
'starbasedb:studio',
18+
{
19+
requiresAuth: false,
20+
},
21+
options.prefix ?? '/studio'
22+
)
23+
this.username = options.username || ''
24+
this.password = options.password || ''
25+
this.apiKey = options.apiKey
26+
}
27+
28+
override async register(app: StarbaseApp) {
29+
if (!this.pathPrefix) return
30+
31+
app.get(this.pathPrefix, async (c) => {
32+
return handleStudioRequest(c.req.raw, {
33+
username: this.username,
34+
password: this.password,
35+
apiKey: this.apiKey,
36+
})
37+
})
38+
}
39+
}

plugins/websocket/index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { StarbaseApp, StarbaseContext } from '../../src/handler'
2+
import { StarbasePlugin } from '../../src/plugin'
3+
4+
export class WebSocketPlugin extends StarbasePlugin {
5+
private prefix = '/socket'
6+
7+
constructor(opts?: { prefix?: string }) {
8+
super('starbasedb:websocket', {
9+
requiresAuth: true,
10+
})
11+
this.prefix = opts?.prefix ?? this.prefix
12+
}
13+
14+
override async register(app: StarbaseApp) {
15+
app.all(this.prefix, (c) => {
16+
return this.upgrade(c)
17+
})
18+
}
19+
20+
private upgrade(ctx: StarbaseContext): Response {
21+
if (ctx.req.header('upgrade') !== 'websocket') {
22+
return new Response('Expected upgrade request', { status: 400 })
23+
}
24+
25+
const config = ctx.get('config')
26+
const dataSource = ctx.get('dataSource')
27+
const { executeQuery } = ctx.get('operations')
28+
29+
const webSocketPair = new WebSocketPair()
30+
const [client, server] = Object.values(webSocketPair)
31+
32+
server.accept()
33+
server.addEventListener('message', (event) => {
34+
const { sql, params, action } = JSON.parse(event.data as string)
35+
36+
if (action === 'query') {
37+
const executeQueryWrapper = async () => {
38+
const response = await executeQuery({
39+
sql,
40+
params,
41+
isRaw: false,
42+
dataSource,
43+
config,
44+
})
45+
server.send(JSON.stringify(response))
46+
}
47+
executeQueryWrapper()
48+
}
49+
})
50+
51+
return new Response(null, { status: 101, webSocket: client })
52+
}
53+
}

src/allowlist/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ function normalizeSQL(sql: string) {
1616

1717
async function loadAllowlist(dataSource: DataSource): Promise<string[]> {
1818
try {
19-
const statement =
20-
'SELECT sql_statement, source FROM tmp_allowlist_queries'
19+
const statement = `SELECT sql_statement, source FROM tmp_allowlist_queries WHERE source="${dataSource.source}"`
2120
const result = (await dataSource.rpc.executeQuery({
2221
sql: statement,
2322
})) as QueryResult[]

src/cors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const corsHeaders = {
22
'Access-Control-Allow-Origin': '*',
3-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
3+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
44
'Access-Control-Allow-Headers':
55
'Authorization, Content-Type, X-Starbase-Source, X-Data-Source',
66
'Access-Control-Max-Age': '86400',

0 commit comments

Comments
 (0)