forked from defunkt/toes
Remove version param, add path traversal protection
This commit is contained in:
parent
6e5d665846
commit
c081785d37
|
|
@ -3,11 +3,17 @@ import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
import { readdir, stat } from 'fs/promises'
|
import { readdir, stat } from 'fs/promises'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join, extname, basename } from 'path'
|
import { join, resolve, extname, basename } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
const safePath = (base: string, ...segments: string[]) => {
|
||||||
|
const full = resolve(base, ...segments)
|
||||||
|
if (!full.startsWith(base + '/') && full !== base) return null
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
const Container = define('Container', {
|
const Container = define('Container', {
|
||||||
|
|
@ -257,21 +263,15 @@ const fileMemoryScript = `
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var app = params.get('app');
|
var app = params.get('app');
|
||||||
var file = params.get('file');
|
var file = params.get('file');
|
||||||
var version = params.get('version') || 'current';
|
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
var key = 'code-app:' + app + ':' + version + ':file';
|
var key = 'code-app:' + app + ':file';
|
||||||
if (params.has('file')) {
|
if (params.has('file')) {
|
||||||
// Explicit file param (even empty) - save it
|
|
||||||
if (file) localStorage.setItem(key, file);
|
if (file) localStorage.setItem(key, file);
|
||||||
else localStorage.removeItem(key);
|
else localStorage.removeItem(key);
|
||||||
} else {
|
} else {
|
||||||
// No file param - restore saved location
|
|
||||||
var saved = localStorage.getItem(key);
|
var saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
var url = '/?app=' + encodeURIComponent(app);
|
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
|
||||||
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
|
|
||||||
url += '&file=' + encodeURIComponent(saved);
|
|
||||||
window.location.replace(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -327,14 +327,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
|
||||||
app.get('/raw', async c => {
|
app.get('/raw', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file')
|
const filePath = c.req.query('file')
|
||||||
|
|
||||||
if (!appName || !filePath) {
|
if (!appName || !filePath) {
|
||||||
return c.text('Missing app or file parameter', 400)
|
return c.text('Missing app or file parameter', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||||
|
if (!fullPath) return c.text('Invalid path', 400)
|
||||||
const file = Bun.file(fullPath)
|
const file = Bun.file(fullPath)
|
||||||
|
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
|
|
@ -346,14 +346,14 @@ app.get('/raw', async c => {
|
||||||
|
|
||||||
app.post('/save', async c => {
|
app.post('/save', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file')
|
const filePath = c.req.query('file')
|
||||||
|
|
||||||
if (!appName || !filePath) {
|
if (!appName || !filePath) {
|
||||||
return c.text('Missing app or file parameter', 400)
|
return c.text('Missing app or file parameter', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(APPS_DIR, appName, version, filePath)
|
const fullPath = safePath(APPS_DIR, appName, filePath)
|
||||||
|
if (!fullPath) return c.text('Invalid path', 400)
|
||||||
const content = await c.req.text()
|
const content = await c.req.text()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -385,10 +385,9 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
appName: string
|
appName: string
|
||||||
filePath: string
|
filePath: string
|
||||||
versionParam: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
|
||||||
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
||||||
const crumbs: { name: string; path: string }[] = []
|
const crumbs: { name: string; path: string }[] = []
|
||||||
|
|
||||||
|
|
@ -401,7 +400,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
{crumbs.length > 0 ? (
|
{crumbs.length > 0 ? (
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
|
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
||||||
)}
|
)}
|
||||||
|
|
@ -411,7 +410,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
||||||
{i === crumbs.length - 1 ? (
|
{i === crumbs.length - 1 ? (
|
||||||
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
|
@ -479,7 +478,6 @@ function getPrismLanguage(filename: string): string {
|
||||||
|
|
||||||
app.get('/', async c => {
|
app.get('/', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const version = c.req.query('version') || 'current'
|
|
||||||
const filePath = c.req.query('file') || ''
|
const filePath = c.req.query('file') || ''
|
||||||
|
|
||||||
if (!appName) {
|
if (!appName) {
|
||||||
|
|
@ -490,19 +488,34 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName, version)
|
const appPath = safePath(APPS_DIR, appName)
|
||||||
|
if (!appPath) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Code Browser">
|
||||||
|
<ErrorBox>Invalid app name</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(appPath)
|
await stat(appPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Code Browser">
|
<Layout title="Code Browser">
|
||||||
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
<ErrorBox>App "{appName}" not found</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = safePath(appPath, filePath)
|
||||||
|
if (!fullPath) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Code Browser">
|
||||||
|
<ErrorBox>Invalid file path</ErrorBox>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(appPath, filePath)
|
|
||||||
let fileStats
|
let fileStats
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -515,18 +528,16 @@ app.get('/', async c => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
|
||||||
|
|
||||||
if (fileStats.isFile()) {
|
if (fileStats.isFile()) {
|
||||||
const filename = basename(fullPath)
|
const filename = basename(fullPath)
|
||||||
const fileType = getFileType(filename)
|
const fileType = getFileType(filename)
|
||||||
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
|
const rawUrl = `/raw?app=${appName}&file=${filePath}`
|
||||||
const downloadUrl = `${rawUrl}&download=1`
|
const downloadUrl = `${rawUrl}&download=1`
|
||||||
|
|
||||||
if (fileType === 'image') {
|
if (fileType === 'image') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -543,7 +554,7 @@ app.get('/', async c => {
|
||||||
if (fileType === 'audio') {
|
if (fileType === 'audio') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -560,7 +571,7 @@ app.get('/', async c => {
|
||||||
if (fileType === 'video') {
|
if (fileType === 'video') {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`}>
|
<Layout title={`${appName}/${filePath}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<MediaContainer>
|
<MediaContainer>
|
||||||
<MediaHeader>
|
<MediaHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
|
|
@ -615,7 +626,7 @@ saveBtn.onclick = async () => {
|
||||||
status.textContent = 'Saving...';
|
status.textContent = 'Saving...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
|
const res = await fetch('/save?app=${appName}&file=${filePath}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
body: jar.toString()
|
body: jar.toString()
|
||||||
|
|
@ -641,14 +652,14 @@ document.addEventListener('keydown', (e) => {
|
||||||
`
|
`
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`} editable>
|
<Layout title={`${appName}/${filePath}`} editable>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>
|
<CodeHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
|
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
|
||||||
<EditButton id="save-btn">Save</EditButton>
|
<EditButton id="save-btn">Save</EditButton>
|
||||||
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
|
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink>
|
||||||
</div>
|
</div>
|
||||||
</CodeHeader>
|
</CodeHeader>
|
||||||
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
|
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
|
||||||
|
|
@ -660,11 +671,11 @@ document.addEventListener('keydown', (e) => {
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`} highlight>
|
<Layout title={`${appName}/${filePath}`} highlight>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>
|
<CodeHeader>
|
||||||
<span>{filename}</span>
|
<span>{filename}</span>
|
||||||
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
|
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink>
|
||||||
</CodeHeader>
|
</CodeHeader>
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
|
|
@ -676,11 +687,11 @@ document.addEventListener('keydown', (e) => {
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<PathBreadcrumb appName={appName} filePath={filePath} />
|
||||||
<FileList>
|
<FileList>
|
||||||
{files.map(file => (
|
{files.map(file => (
|
||||||
<FileItem>
|
<FileItem>
|
||||||
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
||||||
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
||||||
<span>{file.name}</span>
|
<span>{file.name}</span>
|
||||||
</FileLink>
|
</FileLink>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user