From 9d3cb00b9a86d3a5ebc75feeca99dad0f840b1e9 Mon Sep 17 00:00:00 2001 From: Yui Date: Mon, 3 Aug 2026 18:00:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E7=BD=91=E5=9D=80=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/items/item.entity.ts | 4 +- backend/src/items/items.controller.ts | 44 +++++- backend/src/items/items.module.ts | 3 +- backend/src/items/items.service.ts | 144 ++++++++++++++++++-- backend/src/items/user-item-state.entity.ts | 6 + backend/src/items/user-url-rule.entity.ts | 18 +++ frontend/src/api/items.ts | 27 +++- frontend/src/components/UrlRulesModal.vue | 120 ++++++++++++++++ frontend/src/layout/components/header.vue | 2 +- frontend/src/views/AppUploadView.vue | 20 +-- frontend/src/views/HomeView.vue | 2 +- frontend/src/views/ItemDetailView.vue | 26 ++++ frontend/src/views/MyPurchasesView.vue | 28 +++- frontend/src/views/MyWorksView.vue | 6 +- loader/package.json | 1 + loader/src/index.ts | 24 +--- loader/src/url-rules.test.ts | 62 +++++++++ loader/src/url-rules.ts | 30 ++++ 18 files changed, 513 insertions(+), 54 deletions(-) create mode 100644 backend/src/items/user-url-rule.entity.ts create mode 100644 frontend/src/components/UrlRulesModal.vue create mode 100644 loader/src/url-rules.test.ts create mode 100644 loader/src/url-rules.ts diff --git a/backend/src/items/item.entity.ts b/backend/src/items/item.entity.ts index 6abb699..ffaaf1e 100644 --- a/backend/src/items/item.entity.ts +++ b/backend/src/items/item.entity.ts @@ -19,8 +19,8 @@ export enum ItemStatus { export const ItemTypeLabels = { [ItemType.EXTENSION]: '扩展', [ItemType.THEME]: '主题', - [ItemType.APP_EXTENSION]: 'APP扩展', - [ItemType.APP_THEME]: 'APP主题', + [ItemType.APP_EXTENSION]: '客户端扩展', + [ItemType.APP_THEME]: '客户端主题', }; @Entity() diff --git a/backend/src/items/items.controller.ts b/backend/src/items/items.controller.ts index 8a65d9b..6374be9 100644 --- a/backend/src/items/items.controller.ts +++ b/backend/src/items/items.controller.ts @@ -61,6 +61,21 @@ export class ItemsController { return this.itemsService.getUserPurchases(req.user.userId, type); } + @Get('url-rules') + @UseGuards(JwtAuthGuard) + async getUserUrlRules(@Request() req) { + return this.itemsService.getUserUrlRules(req.user.userId); + } + + @Post('url-rules') + @UseGuards(JwtAuthGuard) + async setUserUrlRules( + @Body() body: { allowUrls?: string[]; blockUrls?: string[] }, + @Request() req, + ) { + return this.itemsService.setUserUrlRules(req.user.userId, body.allowUrls, body.blockUrls); + } + @Get('my-published') @UseGuards(JwtAuthGuard) async getMyPublished(@Request() req, @Query('type') type?: ItemType) { @@ -155,17 +170,19 @@ export class ItemsController { return; } - const origin = req.query.origin; - - const items = await this.itemsService.getUserPurchases(user.id); + const [items, globalUrlRules] = await Promise.all([ + this.itemsService.getUserPurchases(user.id), + this.itemsService.getUserUrlRules(user.id), + ]); const extensionIds = items.filter(p => p.type === 'extension' && p.isEnabled).map(i => i.id); const themeIds = items.filter(p => p.type === 'theme' && p.isEnabled).map(i => i.id); const extensionData = items.filter(p => p.isEnabled) - .filter(p => !origin || p.matchUrls.some((pattern: string) => new RegExp(pattern.replace(/\*/g, '.*')).test(origin))) .map(i => ({ id: i.id, name: i.name, matchUrls: i.matchUrls, + allowUrls: i.allowUrls, + blockUrls: i.blockUrls, identifier: i.identifier, dependencies: i.dependencies?.map(d => d.id) || [] })); @@ -174,7 +191,8 @@ export class ItemsController { .replace(/`{{#Ids}}`/, extensionIds.join(', ')) .replace(/`{{#Themes}}`/, themeIds.join(', ')) .replace(/['"`]{{#UserId}}['"`]/, `'${user.id}'`) - .replace(/\[`{{#ExtensionData}}`\]/, JSON.stringify(extensionData)) + .replace(/\[`{{#ExtensionData}}`\]/, () => JSON.stringify(extensionData)) + .replace(/\[`{{#GlobalUrlRules}}`\]/, () => JSON.stringify(globalUrlRules)) ); } @@ -338,6 +356,22 @@ export class ItemsController { return this.itemsService.findOne(id, req.user.userId); } + @Get(':id/url-rules') + @UseGuards(JwtAuthGuard) + async getItemUrlRules(@Param('id', ParseIntPipe) id: number, @Request() req) { + return this.itemsService.getItemUrlRules(id, req.user.userId); + } + + @Post(':id/url-rules') + @UseGuards(JwtAuthGuard) + async setItemUrlRules( + @Param('id', ParseIntPipe) id: number, + @Body() body: { allowUrls?: string[]; blockUrls?: string[] }, + @Request() req, + ) { + return this.itemsService.setItemUrlRules(id, req.user.userId, body.allowUrls, body.blockUrls); + } + @Get(':id/versions') @UseGuards(JwtAuthGuard) async getVersions(@Param('id', ParseIntPipe) id: number, @Request() req) { diff --git a/backend/src/items/items.module.ts b/backend/src/items/items.module.ts index e73cf24..6467fe5 100644 --- a/backend/src/items/items.module.ts +++ b/backend/src/items/items.module.ts @@ -4,13 +4,14 @@ import { Item } from './item.entity'; import { UserItemState } from './user-item-state.entity'; import { Comment } from './comment.entity'; import { GlobalStorage } from './global-storage.entity'; +import { UserUrlRule } from './user-url-rule.entity'; import { ItemsService } from './items.service'; import { ItemsController } from './items.controller'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Item, UserItemState, Comment, GlobalStorage]), + TypeOrmModule.forFeature([Item, UserItemState, UserUrlRule, Comment, GlobalStorage]), forwardRef(() => UsersModule), ], providers: [ItemsService], diff --git a/backend/src/items/items.service.ts b/backend/src/items/items.service.ts index 2fe7767..834f41e 100644 --- a/backend/src/items/items.service.ts +++ b/backend/src/items/items.service.ts @@ -9,6 +9,7 @@ import { Item, ItemStatus, ItemType, ItemTypeLabels } from './item.entity'; import { Comment } from './comment.entity'; import { UserItemState } from './user-item-state.entity'; import { GlobalStorage } from './global-storage.entity'; +import { UserUrlRule } from './user-url-rule.entity'; import { UsersService } from '../users/users.service'; import Fishpi, { FingerTo } from 'fishpi'; import { ConfigService } from 'src/config/config.service'; @@ -20,6 +21,8 @@ export class ItemsService { private itemsRepository: Repository, @InjectRepository(UserItemState) private itemStateRepository: Repository, + @InjectRepository(UserUrlRule) + private userUrlRuleRepository: Repository, @InjectRepository(Comment) private commentRepository: Repository, @InjectRepository(GlobalStorage) @@ -34,6 +37,86 @@ export class ItemsService { private readonly gzipAsync = promisify(gzip); private readonly gunzipAsync = promisify(gunzip); + private normalizeUrlRules(urls: unknown): string[] { + if (!Array.isArray(urls)) return []; + return [...new Set(urls + .filter((url): url is string => typeof url === 'string') + .map(url => url.trim()) + .filter(Boolean))]; + } + + private formatUrlRules(rule?: { allowUrls?: string[]; blockUrls?: string[] } | null) { + return { + allowUrls: this.normalizeUrlRules(rule?.allowUrls), + blockUrls: this.normalizeUrlRules(rule?.blockUrls), + }; + } + + private async findOwnedItem(itemId: number, userId: string): Promise { + const item = await this.itemsRepository.createQueryBuilder('item') + .leftJoin('item.purchasedBy', 'purchasedBy') + .leftJoinAndSelect('item.author', 'author') + .where('item.id = :itemId', { itemId }) + .andWhere('(purchasedBy.id = :userId OR author.id = :userId)', { userId }) + .getOne(); + if (!item) throw new UnauthorizedException('您尚未拥有此项目'); + if (item.type !== ItemType.EXTENSION && item.type !== ItemType.THEME) { + throw new BadRequestException('仅网页扩展和主题支持网址设置'); + } + return item; + } + + async getUserUrlRules(userId: string) { + const rule = await this.userUrlRuleRepository.findOne({ + where: { user: { id: userId } }, + }); + return this.formatUrlRules(rule); + } + + async setUserUrlRules(userId: string, allowUrls: unknown, blockUrls: unknown) { + const user = await this.usersService.findById(userId); + if (!user) throw new NotFoundException('找不到此用户'); + const rules = this.formatUrlRules({ allowUrls: allowUrls as string[], blockUrls: blockUrls as string[] }); + let rule = await this.userUrlRuleRepository.findOne({ + where: { user: { id: userId } }, + }); + if (!rule) rule = this.userUrlRuleRepository.create({ user }); + Object.assign(rule, rules); + await this.userUrlRuleRepository.save(rule); + return rules; + } + + async getItemUrlRules(itemId: number, userId: string) { + await this.findOwnedItem(itemId, userId); + const state = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: itemId } }, + }); + return this.formatUrlRules(state); + } + + async setItemUrlRules(itemId: number, userId: string, allowUrls: unknown, blockUrls: unknown) { + const [user, item] = await Promise.all([ + this.usersService.findById(userId), + this.findOwnedItem(itemId, userId), + ]); + if (!user) throw new NotFoundException('找不到此用户'); + const rules = this.formatUrlRules({ allowUrls: allowUrls as string[], blockUrls: blockUrls as string[] }); + let state = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: itemId } }, + }); + if (!state) { + state = this.itemStateRepository.create({ + user, + item, + isEnabled: true, + isAutoUpdate: true, + }); + } + Object.assign(state, rules); + await this.itemStateRepository.save(state); + return rules; + } + private async writeApprovedItemCache(item: Item): Promise { try { await fsp.mkdir(this.CACHE_DIR, { recursive: true }); @@ -83,7 +166,7 @@ export class ItemsService { } const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/; if (!regex.test(data.code)) { - throw new BadRequestException('APP扩展内容前端必须包含 // ==FishPiPlugin== 与 // ==/FishPiPlugin== 元数据'); + throw new BadRequestException('客户端扩展必须包含 FishPiPlugin 头部'); } } else if (data.type === ItemType.APP_THEME) { if (!data.code) { @@ -92,10 +175,10 @@ export class ItemsService { try { const parsed = JSON.parse(data.code); if (typeof parsed !== 'object' || parsed === null) { - throw new BadRequestException('APP主题内容必须是一个有效的JSON对象'); + throw new BadRequestException('客户端主题内容必须是 JSON 对象'); } } catch (e: any) { - throw new BadRequestException('APP主题内容必须是一个合法的JSON格式: ' + e.message); + throw new BadRequestException('客户端主题 JSON 格式错误: ' + e.message); } } } @@ -459,6 +542,7 @@ export class ItemsService { } for (const { user, wasEnabled, state } of ownersToUpdate) { + const inheritedRules = this.formatUrlRules(state); // Remove old version state if exists if (state) { await this.itemStateRepository.remove(state); @@ -469,14 +553,18 @@ export class ItemsService { where: { user: { id: user.id }, item: { id: item.id } } }); - if (!newState && !wasEnabled) { + if (!newState && (!wasEnabled || inheritedRules.allowUrls.length || inheritedRules.blockUrls.length)) { newState = this.itemStateRepository.create({ user: user, item: item, isEnabled: wasEnabled, - isAutoUpdate: true + isAutoUpdate: true, + ...inheritedRules, }); await this.itemStateRepository.save(newState); + } else if (newState) { + Object.assign(newState, inheritedRules); + await this.itemStateRepository.save(newState); } // Transfer ownership @@ -588,9 +676,13 @@ export class ItemsService { }); let ownsOtherVersion = false; + let previousState: UserItemState | null = null; for (const v of allVersions) { if (v.id !== item.id && v.purchasedBy?.some(u => u.id === userId)) { ownsOtherVersion = true; + previousState = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: v.id } }, + }); // Remove ownership from other versions (User can only own one version at a time) v.purchasedBy = v.purchasedBy.filter(u => u.id !== userId); await this.itemsRepository.save(v); @@ -621,6 +713,25 @@ export class ItemsService { item.purchasedBy.push(user); const savedItem = await this.itemsRepository.save(item); + if (ownsOtherVersion) { + const inheritedRules = this.formatUrlRules(previousState); + let nextState = await this.itemStateRepository.findOne({ + where: { user: { id: userId }, item: { id: item.id } }, + }); + if (!nextState && previousState) { + nextState = this.itemStateRepository.create({ + user, + item, + isEnabled: previousState.isEnabled, + isAutoUpdate: previousState.isAutoUpdate, + }); + } + if (nextState) { + Object.assign(nextState, inheritedRules); + await this.itemStateRepository.save(nextState); + } + } + // Handle dependencies: grant free ones automatically if (item.dependencies && item.dependencies.length > 0) { for (const dep of item.dependencies) { @@ -684,7 +795,8 @@ export class ItemsService { return { ...item, isEnabled: state ? state.isEnabled : true, - isAutoUpdate: state ? state.isAutoUpdate : true + isAutoUpdate: state ? state.isAutoUpdate : true, + ...this.formatUrlRules(state), }; }); @@ -736,6 +848,9 @@ export class ItemsService { }); let storageToMigrate = null; + let allowUrlsToMigrate: string[] = []; + let blockUrlsToMigrate: string[] = []; + let urlRulesToMigrate = false; for (const v of allVersionsOfProject) { if (v.id !== itemId) { @@ -762,6 +877,9 @@ export class ItemsService { if (otherState.storage && Object.keys(otherState.storage).length > 0) { storageToMigrate = otherState.storage; } + allowUrlsToMigrate = this.normalizeUrlRules(otherState.allowUrls); + blockUrlsToMigrate = this.normalizeUrlRules(otherState.blockUrls); + urlRulesToMigrate = true; await this.itemStateRepository.remove(otherState); } } @@ -777,13 +895,19 @@ export class ItemsService { item, isEnabled, isAutoUpdate: true, - storage: storageToMigrate || {} + storage: storageToMigrate || {}, + allowUrls: allowUrlsToMigrate, + blockUrls: blockUrlsToMigrate, }); } else { state.isEnabled = isEnabled; if (storageToMigrate) { state.storage = { ...storageToMigrate, ...(state.storage || {}) }; } + if (urlRulesToMigrate) { + state.allowUrls = allowUrlsToMigrate; + state.blockUrls = blockUrlsToMigrate; + } } const allVersionsSorted = await this.itemsRepository.find({ @@ -982,7 +1106,7 @@ export class ItemsService { } const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/; if (!regex.test(item.code)) { - throw new BadRequestException('APP扩展内容前端必须包含 // ==FishPiPlugin== 与 // ==/FishPiPlugin== 元数据'); + throw new BadRequestException('客户端扩展必须包含 FishPiPlugin 头部'); } } else if (item.type === ItemType.APP_THEME) { if (!item.code) { @@ -991,10 +1115,10 @@ export class ItemsService { try { const parsed = JSON.parse(item.code); if (typeof parsed !== 'object' || parsed === null) { - throw new BadRequestException('APP主题内容必须是一个有效的JSON对象'); + throw new BadRequestException('客户端主题内容必须是 JSON 对象'); } } catch (e: any) { - throw new BadRequestException('APP主题内容必须是一个合法的JSON格式: ' + e.message); + throw new BadRequestException('客户端主题 JSON 格式错误: ' + e.message); } } } diff --git a/backend/src/items/user-item-state.entity.ts b/backend/src/items/user-item-state.entity.ts index 08b21b2..b2e5a4b 100644 --- a/backend/src/items/user-item-state.entity.ts +++ b/backend/src/items/user-item-state.entity.ts @@ -22,4 +22,10 @@ export class UserItemState { @Column({ type: 'simple-json', nullable: true, comment: '配置数据' }) storage: Record; + + @Column({ type: 'simple-json', nullable: true, comment: '白名单地址' }) + allowUrls: string[]; + + @Column({ type: 'simple-json', nullable: true, comment: '黑名单地址' }) + blockUrls: string[]; } diff --git a/backend/src/items/user-url-rule.entity.ts b/backend/src/items/user-url-rule.entity.ts new file mode 100644 index 0000000..c3fc73b --- /dev/null +++ b/backend/src/items/user-url-rule.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '../users/user.entity'; + +@Entity() +export class UserUrlRule { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn() + user: User; + + @Column({ type: 'simple-json', nullable: true, comment: '白名单地址' }) + allowUrls: string[]; + + @Column({ type: 'simple-json', nullable: true, comment: '黑名单地址' }) + blockUrls: string[]; +} diff --git a/frontend/src/api/items.ts b/frontend/src/api/items.ts index 5ec01ea..d2c14a7 100644 --- a/frontend/src/api/items.ts +++ b/frontend/src/api/items.ts @@ -4,8 +4,8 @@ import request from '../utils/request' export const ItemTypeLabels: any = { 'extension': '扩展', 'theme': '主题', - 'app-extension': 'APP扩展', - 'app-theme': 'APP主题', + 'app-extension': '客户端扩展', + 'app-theme': '客户端主题', }; export interface Item { @@ -30,11 +30,18 @@ export interface Item { isEnabled?: boolean isAutoUpdate?: boolean matchUrls?: string[] + allowUrls?: string[] + blockUrls?: string[] upgradeFrom?: Item | number upgradeFromId?: number dependencies?: Item[] } +export interface UrlRules { + allowUrls: string[] + blockUrls: string[] +} + export interface Comment { id: number content: string @@ -113,6 +120,22 @@ export function getPurchasedItems() { return request.get('/items/my-purchases') } +export function getUserUrlRules() { + return request.get('/items/url-rules') +} + +export function setUserUrlRules(rules: UrlRules) { + return request.post('/items/url-rules', rules) +} + +export function getItemUrlRules(id: number) { + return request.get(`/items/${id}/url-rules`) +} + +export function setItemUrlRules(id: number, rules: UrlRules) { + return request.post(`/items/${id}/url-rules`, rules) +} + /** * Get item by ID */ diff --git a/frontend/src/components/UrlRulesModal.vue b/frontend/src/components/UrlRulesModal.vue new file mode 100644 index 0000000..f41ece3 --- /dev/null +++ b/frontend/src/components/UrlRulesModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/frontend/src/layout/components/header.vue b/frontend/src/layout/components/header.vue index 5d1ad50..b8c196d 100644 --- a/frontend/src/layout/components/header.vue +++ b/frontend/src/layout/components/header.vue @@ -78,7 +78,7 @@ const logout = () => {
  • - APP扩展与主题 + 客户端扩展与主题
  • diff --git a/frontend/src/views/AppUploadView.vue b/frontend/src/views/AppUploadView.vue index 37a36c4..188ace4 100644 --- a/frontend/src/views/AppUploadView.vue +++ b/frontend/src/views/AppUploadView.vue @@ -394,18 +394,18 @@ const handleSubmit = async (isDraft: boolean = false) => { if (type.value === 'app-extension') { const regex = /\/\/\s*==FishPiPlugin==[\s\S]*?\/\/\s*==\/FishPiPlugin==/ if (!regex.test(code.value)) { - error.value = 'APP扩展前端内容必须包含 // ==FishPiPlugin== 到 // ==/FishPiPlugin== 头部声明元数据' + error.value = '客户端扩展必须包含 FishPiPlugin 头部' return } } else if (type.value === 'app-theme') { try { const parsed = JSON.parse(code.value) if (typeof parsed !== 'object' || parsed === null) { - error.value = 'APP主题内容必须是一个有效的JSON对象' + error.value = '客户端主题内容必须是 JSON 对象' return } } catch (err: any) { - error.value = 'APP主题内容必须是一个合法的JSON格式: ' + err.message + error.value = '客户端主题 JSON 格式错误: ' + err.message return } } @@ -482,11 +482,11 @@ const handleSubmit = async (isDraft: boolean = false) => {
    1
    -

    APP 扩展必须以 FishPiPlugin 头部格式开始

    +

    客户端扩展需包含 FishPiPlugin 头部

    2
    -

    APP 主题必须是合法的 JSON 格式配置

    +

    客户端主题需使用 JSON 格式

    3
    @@ -657,7 +657,7 @@ const handleSubmit = async (isDraft: boolean = false) => {
    - +
    @@ -681,8 +681,8 @@ const handleSubmit = async (isDraft: boolean = false) => {
    @@ -841,7 +841,7 @@ const handleSubmit = async (isDraft: boolean = false) => {
    @@ -935,4 +935,4 @@ const handleSubmit = async (isDraft: boolean = false) => {
    - \ No newline at end of file + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 60621d8..b989e8c 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -189,7 +189,7 @@ onMounted(() => { @click="activeTab = 'app-theme'" class="btn btn-sm join-item px-4" :class="activeTab === 'app-theme' ? 'btn-primary shadow-sm' : 'btn-soft opacity-60'" - >APP主题 + >客户端主题
    diff --git a/frontend/src/views/ItemDetailView.vue b/frontend/src/views/ItemDetailView.vue index 5f54248..e94be8a 100644 --- a/frontend/src/views/ItemDetailView.vue +++ b/frontend/src/views/ItemDetailView.vue @@ -8,6 +8,7 @@ import 'highlight.js/styles/github-dark.css' import message from '@/components/msg' import MessageBox from '@/components/msgbox' import { useDependencyCheck } from '@/utils/hooks' +import UrlRulesModal from '@/components/UrlRulesModal.vue' const { checkDependencies } = useDependencyCheck() const route = useRoute() @@ -19,6 +20,7 @@ const versions = ref([]) const myPurchases = ref([]) const loading = ref(true) const purchasing = ref(false) +const showUrlRules = ref(false) const comments = ref([]) const commentContent = ref('') @@ -414,6 +416,14 @@ onMounted(() => { class="btn btn-primary btn-block rounded-xl h-12"> 使用此版本 +
    自动更新 {
    + + diff --git a/frontend/src/views/MyPurchasesView.vue b/frontend/src/views/MyPurchasesView.vue index d3b081a..708e016 100644 --- a/frontend/src/views/MyPurchasesView.vue +++ b/frontend/src/views/MyPurchasesView.vue @@ -5,12 +5,20 @@ import { getPurchasedItems, toggleItemState, getItems, purchaseItem, setAutoUpda import message from '@/components/msg' import MessageBox from '@/components/msgbox' import { useDependencyCheck } from '@/utils/hooks' +import UrlRulesModal from '@/components/UrlRulesModal.vue' const { checkDependencies } = useDependencyCheck() const router = useRouter() const items = ref([]) const latestItems = ref([]) const loading = ref(true) +const showUrlRules = ref(false) +const urlRulesItem = ref(null) + +const openUrlRules = (item?: any) => { + urlRulesItem.value = item || null + showUrlRules.value = true +} const loadPurchasedItems = async () => { loading.value = true @@ -126,7 +134,7 @@ onMounted(() => {
    -
    +
    @@ -135,9 +143,13 @@ onMounted(() => { 已购项目 {{ items.length }} -

    查看和管理您已获取的扩展与主题

    +

    管理已获取的扩展与主题

    +
    @@ -172,6 +184,11 @@ onMounted(() => {
    +
    + +
    { 前往集市
    + +
    diff --git a/frontend/src/views/MyWorksView.vue b/frontend/src/views/MyWorksView.vue index dae3265..feb6795 100644 --- a/frontend/src/views/MyWorksView.vue +++ b/frontend/src/views/MyWorksView.vue @@ -251,8 +251,8 @@ onMounted(() => { - - + + @@ -398,4 +398,4 @@ onMounted(() => { - \ No newline at end of file + diff --git a/loader/package.json b/loader/package.json index dd166ac..e0b096e 100644 --- a/loader/package.json +++ b/loader/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "tsdown && shx mv ./dist/loader.mjs ../backend/loader.min.js", "build": "tsdown", + "test": "node --test src/url-rules.test.ts", "postbuild": "shx mv ./dist/loader.mjs ../release/loader.min.js" }, "author": "Hancel.Lin", diff --git a/loader/src/index.ts b/loader/src/index.ts index b6d6ebe..71bd272 100644 --- a/loader/src/index.ts +++ b/loader/src/index.ts @@ -1,6 +1,7 @@ import Fishpi from 'fishpi/browser'; import * as GM from './gm'; import * as msgbox from './msgbox'; +import { isUrlAllowed } from './url-rules'; const defaultAllowGlobals = [ 'crypto', 'console', @@ -58,12 +59,6 @@ const defaultAllowGlobals = [ 'Element', ] -function matchUrl(pattern: string, currentHref: string, currentPath: string) { - const p = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - const regex = new RegExp('^' + p + '$'); - return regex.test(pattern.startsWith('/') ? currentPath : currentHref); -} - function readOnly(obj: any, fields: string[]) { // 浅拷贝原始对象 const newObj = { ...obj }; @@ -148,6 +143,7 @@ async function activate() { const themeItems = [`{{#Themes}}`]; const userId = '{{#UserId}}'; const extensionData = [`{{#ExtensionData}}`] as any[]; + const globalUrlRules = [`{{#GlobalUrlRules}}`] as any; const activationMap = new Map>(); async function activateExtension(item: any) { @@ -155,12 +151,9 @@ async function activate() { const promise = (async () => { const extension: any = extensionData.find((e: any) => e.id === item)!; + if (!extension) return; const identifier = extension.identifier || extension.id; - if (extension?.matchUrls && extension.matchUrls.length > 0) { - if (!extension.matchUrls.some((pattern: string) => matchUrl(pattern, location.href, location.pathname))) { - return; - } - } + if (!isUrlAllowed(location.href, location.pathname, extension.matchUrls, globalUrlRules, extension)) return; if (extension?.dependencies && extension.dependencies.length > 0) { const deps = extension.dependencies.filter((id: any) => jsItems.includes(id)); @@ -298,11 +291,8 @@ async function activate() { themeItems.forEach(async item => { const extension: any = extensionData.find((e: any) => e.id === item)!; - if (extension?.matchUrls && extension.matchUrls.length > 0) { - if (!extension.matchUrls.some((pattern: string) => matchUrl(pattern, location.href, location.pathname))) { - return; - } - } + if (!extension) return; + if (!isUrlAllowed(location.href, location.pathname, extension.matchUrls, globalUrlRules, extension)) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.id = `theme-${item}`; @@ -311,4 +301,4 @@ async function activate() { }); } -activate(); \ No newline at end of file +activate(); diff --git a/loader/src/url-rules.test.ts b/loader/src/url-rules.test.ts new file mode 100644 index 0000000..1cc423c --- /dev/null +++ b/loader/src/url-rules.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isUrlAllowed, matchUrl } from './url-rules.ts'; + +const href = 'https://fishpi.cn/article/123?tab=comments'; +const path = '/article/123'; + +test('匹配完整地址和通配符', () => { + assert.equal(matchUrl('https://fishpi.cn/*', href, path), true); + assert.equal(matchUrl('https://example.com/*', href, path), false); +}); + +test('斜杠开头的规则只匹配路径', () => { + assert.equal(matchUrl('/article/*', href, path), true); + assert.equal(matchUrl('/admin/*', href, path), false); +}); + +test('空白名单不限制地址', () => { + assert.equal(isUrlAllowed(href, path, [], {}, {}), true); +}); + +test('整体黑名单优先于白名单', () => { + assert.equal(isUrlAllowed( + href, + path, + ['https://fishpi.cn/*'], + { allowUrls: ['https://fishpi.cn/*'], blockUrls: ['/article/*'] }, + {}, + ), false); +}); + +test('单项黑名单优先于白名单', () => { + assert.equal(isUrlAllowed( + href, + path, + ['https://fishpi.cn/*'], + {}, + { allowUrls: ['/article/*'], blockUrls: ['https://fishpi.cn/article/*'] }, + ), false); +}); + +test('作者、整体和单项白名单必须全部命中', () => { + assert.equal(isUrlAllowed( + href, + path, + ['https://fishpi.cn/*'], + { allowUrls: ['/article/*'] }, + { allowUrls: ['https://fishpi.cn/article/*'] }, + ), true); + + assert.equal(isUrlAllowed( + href, + path, + ['https://fishpi.cn/*'], + { allowUrls: ['/article/*'] }, + { allowUrls: ['/settings/*'] }, + ), false); +}); + +test('作者适用网址未命中时禁止运行', () => { + assert.equal(isUrlAllowed(href, path, ['/settings/*'], {}, {}), false); +}); diff --git a/loader/src/url-rules.ts b/loader/src/url-rules.ts new file mode 100644 index 0000000..a7382a9 --- /dev/null +++ b/loader/src/url-rules.ts @@ -0,0 +1,30 @@ +export interface UrlRules { + allowUrls?: string[]; + blockUrls?: string[]; +} + +export function matchUrl(pattern: string, currentHref: string, currentPath: string): boolean { + const escapedPattern = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + const regex = new RegExp(`^${escapedPattern}$`); + return regex.test(pattern.startsWith('/') ? currentPath : currentHref); +} + +function matchesAny(patterns: string[] | undefined, currentHref: string, currentPath: string): boolean { + return (patterns || []).some(pattern => matchUrl(pattern, currentHref, currentPath)); +} + +export function isUrlAllowed( + currentHref: string, + currentPath: string, + authorUrls: string[] | undefined, + globalRules: UrlRules, + itemRules: UrlRules, +): boolean { + if (matchesAny(globalRules.blockUrls, currentHref, currentPath)) return false; + if (matchesAny(itemRules.blockUrls, currentHref, currentPath)) return false; + + const allowGroups = [authorUrls, globalRules.allowUrls, itemRules.allowUrls] + .filter((patterns): patterns is string[] => Boolean(patterns?.length)); + + return allowGroups.every(patterns => matchesAny(patterns, currentHref, currentPath)); +} From e41c25c2e0c673f0a021034caaca309dc720a8cc Mon Sep 17 00:00:00 2001 From: Yui Date: Mon, 3 Aug 2026 18:14:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=AE=89=E8=A3=85=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/items/items.service.ts | 44 +++++++++++++++++++++----- frontend/src/views/HomeView.vue | 42 ++++++++++++++++++++++-- frontend/src/views/ItemDetailView.vue | 22 ++++++++++--- frontend/src/views/MyPurchasesView.vue | 19 ++++++++--- frontend/src/views/UserView.vue | 43 +++++++++++++++++++++++-- 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/backend/src/items/items.service.ts b/backend/src/items/items.service.ts index 834f41e..a41e914 100644 --- a/backend/src/items/items.service.ts +++ b/backend/src/items/items.service.ts @@ -405,7 +405,7 @@ export class ItemsService { throw new NotFoundException('没找到'); } - const isPurchased = userId ? await this.itemsRepository.createQueryBuilder('item') + const isPurchased = userId && item.author.id !== userId ? await this.itemsRepository.createQueryBuilder('item') .innerJoin('item.purchasedBy', 'purchasedBy') .where('item.id = :id AND purchasedBy.id = :userId', { id, userId }) .getCount() > 0 : false; @@ -413,6 +413,7 @@ export class ItemsService { const purchaseCountResult = await this.itemsRepository.createQueryBuilder('item') .leftJoin('item.purchasedBy', 'purchasedBy') .where('item.id = :id', { id }) + .andWhere('(purchasedBy.id IS NULL OR purchasedBy.id != item.authorId)') .select('COUNT(purchasedBy.id)', 'count') .getRawOne(); @@ -553,7 +554,12 @@ export class ItemsService { where: { user: { id: user.id }, item: { id: item.id } } }); - if (!newState && (!wasEnabled || inheritedRules.allowUrls.length || inheritedRules.blockUrls.length)) { + if (!newState && ( + !wasEnabled || + inheritedRules.allowUrls.length || + inheritedRules.blockUrls.length || + user.id === item.author.id + )) { newState = this.itemStateRepository.create({ user: user, item: item, @@ -569,11 +575,17 @@ export class ItemsService { // Transfer ownership try { - await this.itemsRepository.createQueryBuilder() - .relation(Item, 'purchasedBy') - .of(item.id) - .add(user.id); - + if (user.id !== item.author.id) { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(item.id) + .add(user.id); + } else { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(item.id) + .remove(user.id); + } await this.itemsRepository.createQueryBuilder() .relation(Item, 'purchasedBy') .of(item.upgradeFrom.id) @@ -659,6 +671,11 @@ export class ItemsService { throw new BadRequestException('未通过审核'); } + if (item.author.id === userId) { + await this.toggleItemState(itemId, userId, true); + return item; + } + // Check if already purchased const alreadyPurchased = item.purchasedBy?.some(u => u.id === userId); if (alreadyPurchased) { @@ -781,7 +798,7 @@ export class ItemsService { .leftJoinAndSelect('item.dependencies', 'dependencies') .leftJoin('item.purchasedBy', 'purchasedBy') .leftJoin(UserItemState, 'state', 'state.itemId = item.id AND state.userId = :userId', { userId }) - .where('purchasedBy.id = :userId OR (author.id = :userId AND state.id IS NOT NULL)', { userId }) + .where('(purchasedBy.id = :userId AND author.id != :userId) OR (author.id = :userId AND state.id IS NOT NULL)', { userId }) .andWhere(type ? 'item.type = :type' : '1=1', { type }) .getMany(); @@ -837,6 +854,8 @@ export class ItemsService { throw new UnauthorizedException('您尚未拥有此项目'); } + const isAuthor = item.author.id === userId; + // 如果当前要开启,则关闭该项目(同作者、同名、同类型)的其他版本 if (isEnabled) { const allVersionsOfProject = await this.itemsRepository.find({ @@ -846,6 +865,15 @@ export class ItemsService { type: item.type, } }); + + if (isAuthor) { + for (const version of allVersionsOfProject) { + await this.itemsRepository.createQueryBuilder() + .relation(Item, 'purchasedBy') + .of(version.id) + .remove(userId); + } + } let storageToMigrate = null; let allowUrlsToMigrate: string[] = []; diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index b989e8c..c207119 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' -import { getItems, purchaseItem, getPurchasedItems, ItemTypeLabels } from '@/api/items' +import { getItems, purchaseItem, getPurchasedItems, toggleItemState, ItemTypeLabels } from '@/api/items' import Message from '@/components/msg' import MessageBox from '@/components/msgbox' import { useDependencyCheck } from '@/utils/hooks' @@ -103,6 +103,32 @@ const handlePurchase = async (item: any) => { } } +const handleAuthorInstall = async (item: any) => { + const ownedVersion = getOwnedVersionOfSameProject(item) + if (ownedVersion) { + const isUpdate = (item.version || 1) > (ownedVersion.version || 1) + const confirmed = await MessageBox.confirm( + `从 v${ownedVersion.version || 1} ${isUpdate ? '升级' : '切换'}到 v${item.version || 1}?`, + isUpdate ? '版本升级' : '切换版本' + ) + if (!confirmed) return + } + + if (!await checkDependencies(item, { + title: '运行依赖', + messagePrefix: '此作品需要以下依赖', + messageSuffix: '继续安装?' + })) return + + try { + await toggleItemState(item.id, true) + Message.success(ownedVersion ? '切换成功' : '安装成功') + await loadItems() + } catch (error: any) { + console.error('Install failed:', error) + } +} + const filteredItems = computed(() => { return items.value }) @@ -112,6 +138,10 @@ const isPurchased = (item: any) => { return myPurchases.value.some((p: any) => p.id === item.id) } +const isAuthor = (item: any) => { + return authStore.isAuthenticated && item.author?.id === authStore.user?.id +} + const getOwnedVersionOfSameProject = (item: any) => { if (!authStore.isAuthenticated) return null return myPurchases.value.find(p => @@ -248,7 +278,15 @@ onMounted(() => {