预览 iframe 没有 allow-same-origin,访问 localStorage 会抛异常。现在在学生代码之前 注入一个替身(支持常用方法、属性读写、Object.keys 等): - 自己的编辑器(教程、挑战、自由创作):写入通过 postMessage 交给主页面,按“用户 + 任务” 存到本站 localStorage 的 preview-storage:* 下,下次渲染注入回去,刷新后数据还在 - 看别人的作品:只存在内存里 - 单个任务最多 10 万字符,总共 50 万字符,超出时先删最久没用的,不挤占登录 token 的配额 - 下载的 index.html 不带替身 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc91b9b5db
commit
40f3063b91
8 files changed
+230
-6
No files matched your search
@@ -43,6 +43,7 @@
|
||||
</n-flex>
|
||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||
<iframe
|
||||
ref="frame"
|
||||
class="iframe"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="previewContent"
|
||||
@@ -51,7 +52,7 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Submission } from "../../api"
|
||||
import { submission } from "../../store/submission"
|
||||
@@ -62,6 +63,12 @@ import {
|
||||
buildPreviewDocument,
|
||||
PREVIEW_SANDBOX,
|
||||
} from "../../utils/previewDocument"
|
||||
import {
|
||||
listenPreviewStorage,
|
||||
loadPreviewStorage,
|
||||
} from "../../utils/previewStorage"
|
||||
import { taskId } from "../../store/task"
|
||||
import { user } from "../../store/user"
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
@@ -71,6 +78,8 @@ interface Props {
|
||||
submissionId?: string
|
||||
showCodeButton?: boolean
|
||||
clearable?: boolean
|
||||
// 自己的编辑器:代码里的 localStorage 按“用户 + 任务”存下来,刷新后还在
|
||||
persistStorage?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -118,12 +127,18 @@ const router = useRouter()
|
||||
const showDL = computed(() => props.html || props.css || props.js)
|
||||
const previewContent = ref("")
|
||||
|
||||
function getContent() {
|
||||
const frame = useTemplateRef<HTMLIFrameElement>("frame")
|
||||
const storageScope = () =>
|
||||
props.persistStorage && taskId.value ? `${user.username}:${taskId.value}` : ""
|
||||
|
||||
function getContent(forDownload = false) {
|
||||
const scope = storageScope()
|
||||
return buildPreviewDocument({
|
||||
html: props.html,
|
||||
css: props.css,
|
||||
js: props.js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
storage: forDownload ? false : scope ? loadPreviewStorage(scope) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,7 +147,7 @@ function preview() {
|
||||
}
|
||||
|
||||
function download() {
|
||||
const content = getContent()
|
||||
const content = getContent(true)
|
||||
const blob = new Blob([content], { type: "text/html" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@@ -159,7 +174,10 @@ function open() {
|
||||
`<!DOCTYPE html><title>预览</title><style>html,body,iframe{margin:0;width:100%;height:100%;border:0;display:block}</style><iframe sandbox="${PREVIEW_SANDBOX}"></iframe>`,
|
||||
)
|
||||
doc.close()
|
||||
doc.querySelector("iframe")!.srcdoc = getContent()
|
||||
const tabFrame = doc.querySelector("iframe")!
|
||||
// 全屏页里的写入也存到同一个地方
|
||||
listenPreviewStorage(newTab, () => tabFrame, storageScope)
|
||||
tabFrame.srcdoc = getContent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +210,7 @@ watchDebounced(
|
||||
},
|
||||
)
|
||||
onMounted(preview)
|
||||
onUnmounted(listenPreviewStorage(window, () => frame.value, storageScope))
|
||||
</script>
|
||||
<style scoped>
|
||||
.title {
|
||||
|
||||
@@ -241,6 +241,7 @@ import { computed, ref, watch } from "vue"
|
||||
import { NPopconfirm, NButton, useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { storageShim } from "../../utils/previewStorage"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import type { PromptRound } from "../../utils/type"
|
||||
import { user, roleSuper } from "../../store/user"
|
||||
@@ -298,7 +299,7 @@ const selectedPageHtml = computed(() => {
|
||||
if (!round?.html) return null
|
||||
const style = round.css ? `<style>${round.css}</style>` : ""
|
||||
const script = round.js ? `<script>${round.js}<\/script>` : ""
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
|
||||
})
|
||||
|
||||
function isPromptLong(text: string): boolean {
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
persist-storage
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
persist-storage
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storageShim } from "../utils/previewStorage"
|
||||
import { onMounted, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
@@ -91,7 +92,7 @@ const awards = ref<AwardSection[]>([])
|
||||
function buildSrcdoc(item: ShowcaseItem): string {
|
||||
const css = item.css ? `<style>${item.css}</style>` : ""
|
||||
const js = item.js ? `<script>${item.js}<\/script>` : ""
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}<meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
|
||||
}
|
||||
|
||||
function openDetail(item: ShowcaseItem) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
persist-storage
|
||||
/>
|
||||
</template>
|
||||
</n-split>
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
export const PREVIEW_SANDBOX =
|
||||
"allow-scripts allow-modals allow-forms allow-popups allow-pointer-lock"
|
||||
|
||||
import { storageShim, type PreviewStorage } from "./previewStorage"
|
||||
|
||||
interface PreviewDocumentOptions {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assetBaseUrl?: string
|
||||
// 不传:localStorage 替身只存在内存里;传入:带上已存的数据并写回主页面;
|
||||
// false:不注入替身(下载成文件时用,浏览器里直接打开有真正的 localStorage)
|
||||
storage?: PreviewStorage | false
|
||||
}
|
||||
|
||||
export function buildPreviewDocument({
|
||||
@@ -15,11 +20,13 @@ export function buildPreviewDocument({
|
||||
css,
|
||||
js,
|
||||
assetBaseUrl,
|
||||
storage,
|
||||
}: PreviewDocumentOptions) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
${storage === false ? "" : storageShim(storage)}
|
||||
<title>预览</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// 预览 iframe 没有 allow-same-origin,学生代码里访问 localStorage 会抛异常。
|
||||
// 这里在学生代码之前注入一个替身:数据先放在 iframe 的内存里;
|
||||
// 自己编辑器里的预览再通过 postMessage 交给主页面,存到本站 localStorage 的独立 key 下,
|
||||
// 下次渲染时注入回去,所以刷新、改代码后数据还在。看别人的作品时只存在内存里。
|
||||
|
||||
const KEY_PREFIX = "preview-storage:"
|
||||
// 本站的登录 token 也在 localStorage 里(配额约 5MB),预览数据不能把它挤满
|
||||
const SCOPE_MAX_CHARS = 100_000
|
||||
const TOTAL_MAX_CHARS = 500_000
|
||||
const MESSAGE_TYPE = "preview-storage"
|
||||
|
||||
export interface PreviewStorage {
|
||||
scope: string
|
||||
data: Record<string, string>
|
||||
}
|
||||
|
||||
// 在 iframe 里运行,必须自成一体(不能引用外面的变量)
|
||||
const SHIM = `(function (config, maxChars, type) {
|
||||
function create(initial, persist) {
|
||||
var data = new Map(Object.entries(initial || {}))
|
||||
function post(msg) {
|
||||
if (!persist) return
|
||||
msg.type = type
|
||||
msg.scope = config.scope
|
||||
parent.postMessage(msg, "*")
|
||||
}
|
||||
function size() {
|
||||
var n = 0
|
||||
data.forEach(function (v, k) { n += k.length + v.length })
|
||||
return n
|
||||
}
|
||||
var api = {
|
||||
getItem: function (k) {
|
||||
k = String(k)
|
||||
return data.has(k) ? data.get(k) : null
|
||||
},
|
||||
setItem: function (k, v) {
|
||||
k = String(k)
|
||||
v = String(v)
|
||||
var old = data.has(k) ? k.length + data.get(k).length : 0
|
||||
if (size() - old + k.length + v.length > maxChars) {
|
||||
throw new DOMException("预览里的 localStorage 最多存 " + maxChars + " 个字符", "QuotaExceededError")
|
||||
}
|
||||
data.set(k, v)
|
||||
post({ op: "set", key: k, value: v })
|
||||
},
|
||||
removeItem: function (k) {
|
||||
k = String(k)
|
||||
if (data.delete(k)) post({ op: "remove", key: k })
|
||||
},
|
||||
clear: function () {
|
||||
data.clear()
|
||||
post({ op: "clear" })
|
||||
},
|
||||
key: function (i) {
|
||||
var keys = Array.from(data.keys())
|
||||
return i >= 0 && i < keys.length ? keys[i] : null
|
||||
},
|
||||
}
|
||||
var isApi = function (p) { return p === "length" || Object.prototype.hasOwnProperty.call(api, p) }
|
||||
// 支持 localStorage.foo = 1、localStorage["foo"]、Object.keys(localStorage) 这些写法
|
||||
return new Proxy({}, {
|
||||
get: function (_, p) {
|
||||
if (p === "length") return data.size
|
||||
if (isApi(p)) return api[p]
|
||||
if (typeof p === "string" && data.has(p)) return data.get(p)
|
||||
return undefined
|
||||
},
|
||||
set: function (_, p, v) {
|
||||
if (typeof p === "string" && !isApi(p)) api.setItem(p, v)
|
||||
return true
|
||||
},
|
||||
deleteProperty: function (_, p) {
|
||||
if (typeof p === "string") api.removeItem(p)
|
||||
return true
|
||||
},
|
||||
has: function (_, p) {
|
||||
return isApi(p) || (typeof p === "string" && data.has(p))
|
||||
},
|
||||
ownKeys: function () {
|
||||
return Array.from(data.keys())
|
||||
},
|
||||
getOwnPropertyDescriptor: function (_, p) {
|
||||
if (typeof p !== "string" || !data.has(p)) return undefined
|
||||
return { value: data.get(p), writable: true, enumerable: true, configurable: true }
|
||||
},
|
||||
})
|
||||
}
|
||||
function define(name, value) {
|
||||
try {
|
||||
Object.defineProperty(window, name, { value: value, configurable: true })
|
||||
} catch (e) {}
|
||||
}
|
||||
define("localStorage", create(config && config.data, !!config))
|
||||
define("sessionStorage", create(null, false))
|
||||
})`
|
||||
|
||||
// storage 不传时只存在内存里
|
||||
export function storageShim(storage?: PreviewStorage) {
|
||||
// 防止数据里的 </script> 提前结束脚本
|
||||
const config = JSON.stringify(storage ?? null).replace(/</g, "\\u003c")
|
||||
return `<script>${SHIM}(${config}, ${SCOPE_MAX_CHARS}, "${MESSAGE_TYPE}")<\/script>`
|
||||
}
|
||||
|
||||
interface Saved {
|
||||
// 最后写入时间,总量超了先删最久没用的
|
||||
t: number
|
||||
d: Record<string, string>
|
||||
}
|
||||
|
||||
function read(key: string): Saved | null {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) ?? "null")
|
||||
return saved && typeof saved.d === "object" ? saved : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPreviewStorage(scope: string): PreviewStorage {
|
||||
return { scope, data: read(KEY_PREFIX + scope)?.d ?? {} }
|
||||
}
|
||||
|
||||
function previewKeys() {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith(KEY_PREFIX)) keys.push(k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function evictOthers(keep: string, all: boolean) {
|
||||
const others = previewKeys()
|
||||
.filter((k) => k !== keep)
|
||||
.map((k) => ({ k, t: read(k)?.t ?? 0, n: localStorage.getItem(k)!.length }))
|
||||
.sort((a, b) => a.t - b.t)
|
||||
let total = others.reduce((n, o) => n + o.n, 0) + (localStorage.getItem(keep)?.length ?? 0)
|
||||
for (const o of others) {
|
||||
if (!all && total <= TOTAL_MAX_CHARS) break
|
||||
localStorage.removeItem(o.k)
|
||||
total -= o.n
|
||||
}
|
||||
}
|
||||
|
||||
function save(scope: string, data: Record<string, string>) {
|
||||
const key = KEY_PREFIX + scope
|
||||
const value = JSON.stringify({ t: Date.now(), d: data } satisfies Saved)
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
evictOthers(key, false)
|
||||
} catch {
|
||||
// 配额满了:清掉别的预览数据再试一次,还不行就不存了
|
||||
try {
|
||||
evictOthers(key, true)
|
||||
localStorage.setItem(key, value)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function apply(scope: string, msg: any) {
|
||||
const data = { ...loadPreviewStorage(scope).data }
|
||||
if (msg.op === "set") {
|
||||
if (typeof msg.key !== "string" || typeof msg.value !== "string") return
|
||||
data[msg.key] = msg.value
|
||||
} else if (msg.op === "remove") {
|
||||
if (typeof msg.key !== "string") return
|
||||
delete data[msg.key]
|
||||
} else if (msg.op === "clear") {
|
||||
for (const k of Object.keys(data)) delete data[k]
|
||||
} else {
|
||||
return
|
||||
}
|
||||
const size = Object.entries(data).reduce((n, [k, v]) => n + k.length + v.length, 0)
|
||||
if (size > SCOPE_MAX_CHARS) return
|
||||
save(scope, data)
|
||||
}
|
||||
|
||||
// 在 win 上接收 iframe 发来的写入。只认这个 iframe、这个 scope 的消息;返回取消监听的函数
|
||||
export function listenPreviewStorage(
|
||||
win: Window,
|
||||
frame: () => HTMLIFrameElement | null | undefined,
|
||||
scope: () => string,
|
||||
) {
|
||||
const handler = (e: MessageEvent) => {
|
||||
const s = scope()
|
||||
if (!s || !e.data || e.data.type !== MESSAGE_TYPE || e.data.scope !== s) return
|
||||
if (!frame() || e.source !== frame()!.contentWindow) return
|
||||
apply(s, e.data)
|
||||
}
|
||||
win.addEventListener("message", handler)
|
||||
return () => win.removeEventListener("message", handler)
|
||||
}
|
||||
Reference in new issue
Block a user