WIP: Secure mode #31

Closed
norm wants to merge 21 commits from (deleted):feat/secure-fetch into main
17 changed files with 82 additions and 403 deletions
Showing only changes of commit 5ffa80b27d - Show all commits

View file

@ -1,5 +0,0 @@
import { Note } from '@/models/entities/note.js';
export default function(note: Note): boolean {
return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0));
}

View file

@ -0,0 +1,5 @@
import { Note } from '@/models/entities/note.js';
export function isPureRenote(note: Note): boolean {
return note.renoteId != null && note.text == null && (renote.fileIds == null || renote.fileIds.length === 0) && !note.hasPoll;
}

View file

@ -15,6 +15,7 @@ import { makePaginationQuery } from '../api/common/make-pagination-query.js';
import { setResponseType } from '../activitypub.js';
import checkFetch from '@/remote/activitypub/check-fetch.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { isPureRenote } from '@/misc/renote.js';
export default async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
@ -113,10 +114,10 @@ export default async (ctx: Router.RouterContext) => {
* @param note Note
*/
export async function packActivity(note: Note): Promise<any> {
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
if (isPureRenote(note)) {
const renote = await Notes.findOneByOrFail({ id: note.renoteId });
return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note);
} else {
return renderCreate(await renderNote(note, false), note);
}
return renderCreate(await renderNote(note, false), note);
}

View file

@ -10,6 +10,7 @@ import { noteVisibilities } from '../../../../types.js';
import { ApiError } from '../../error.js';
import define from '../../define.js';
import { getNote } from '../../common/getters.js';
import { isPureRenote } from '@/misc/renote.js';
export const meta = {
tags: ['notes'],
@ -201,7 +202,7 @@ export default define(meta, paramDef, async (ps, user) => {
throw e;
});
if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) {
if (isPureRenote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
}
@ -230,7 +231,7 @@ export default define(meta, paramDef, async (ps, user) => {
throw e;
});
if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) {
if (isPureRenote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
}

View file

@ -14,6 +14,7 @@ import { deliverToFollowers, deliverToUser } from '@/remote/activitypub/deliver-
import { countSameRenotes } from '@/misc/count-same-renotes.js';
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
import { deliverToRelays } from '../relay.js';
import { isPureRenote } from '@/misc/renote.js';
/**
* 稿
@ -43,7 +44,7 @@ export default async function(user: { id: User['id']; uri: User['uri']; host: Us
let renote: Note | null = null;
// if deletd note is renote
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
if (isPureRenote(note)) {
renote = await Notes.findOneBy({
id: note.renoteId,
});

View file

@ -1,15 +0,0 @@
<template>
<span class="mk-file-type-icon">
<template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
type: string;
}>();
const kind = computed(() => props.type.split('/')[0]);
</script>

View file

@ -12,7 +12,7 @@
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:autocomplete="autocomplete ? 'on' : 'off'"
:spellcheck="spellcheck"
@focus="focused = true"
@blur="focused = false"
@ -62,34 +62,40 @@ const props = withDefaults(defineProps<{
manualSave: false,
});
const { modelValue, autofocus } = toRefs(props);
const { modelValue } = toRefs(props);
// modelValue is read only, so a separate ref is needed.
const v = $ref(modelValue.value);
const focused = $ref(false);
const changed = $ref(false);
const invalid = $ref(false);
const filled = computed(() => modelValue.value !== '' && modelValue.value != null);
const inputEl = $ref(null);
let focused = $ref(false);
let changed = $ref(false);
let invalid = $ref(false);
let inputEl: HTMLTextAreaElement | null = $ref(null);
const focus = () => inputEl.focus();
const onInput = evt => {
const filled = computed(() => modelValue.value !== '' && modelValue.value != null);
const focus = (): void => {
inputEl?.focus();
};
const onInput = (evt: HTMLInputEvent): void => {
changed = true;
emit('change', evt);
};
const onKeydown = (evt: KeyboardEvent) => {
const onKeydown = (evt: KeyboardEvent): void => {
emit('keydown', evt);
if (evt.code === 'Enter') {
emit('enter');
}
};
const updated = () => {
const updated = (): void => {
changed = false;
emit('update:modelValue', v);
};
const debouncedUpdated = debounce(1000, updated);
watch(modelValue, newValue => {
watch(modelValue, () => {
if (!props.manualSave) {
if (props.debounce) {
debouncedUpdated();
@ -98,15 +104,12 @@ watch(modelValue, newValue => {
}
}
invalid = inputEl.validity.badInput;
invalid = inputEl?.validity.badInput ?? false;
});
onMounted(() => {
nextTick(() => {
if (props.autofocus) {
inputEl.focus();
}
if (props.autofocus) focus();
});
});
</script>

View file

@ -1,77 +0,0 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga">
<header>{{ image.name }}</header>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
<footer>
<span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span>
<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
</footer>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkModal from '@/components/ui/modal.vue';
const props = withDefaults(defineProps<{
image: misskey.entities.DriveFile;
}>(), {
});
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const modal = $ref<InstanceType<typeof MkModal>>();
</script>
<style lang="scss" scoped>
.xubzgfga {
display: flex;
flex-direction: column;
height: 100%;
> header,
> footer {
align-self: center;
display: inline-block;
padding: 6px 9px;
font-size: 90%;
background: rgba(0, 0, 0, 0.5);
border-radius: 6px;
color: #fff;
}
> header {
margin-bottom: 8px;
opacity: 0.9;
}
> img {
display: block;
flex: 1;
min-height: 0;
object-fit: contain;
width: 100%;
cursor: zoom-out;
image-orientation: from-image;
}
> footer {
margin-top: 8px;
opacity: 0.8;
> span + span {
margin-left: 0.5em;
padding-left: 0.5em;
border-left: solid 1px rgba(255, 255, 255, 0.5);
}
}
}
</style>

View file

@ -1,17 +0,0 @@
<template>
<div class="evrzpitu"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
export default defineComponent({});
</script>
<style lang="scss" scoped>
.evrzpitu
margin 16px 0
border-bottom solid var(--lineWidth) var(--faceDivider)
</style>

View file

@ -31,7 +31,7 @@ type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer';
const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
anchor?: { x: string; y: string; };
src?: HTMLElement | null;
src?: HTMLElement;
preferType?: ModalTypes | 'auto';
zPriority?: 'low' | 'middle' | 'high';
noOverlap?: boolean;

View file

@ -1,35 +0,0 @@
import { Directive } from 'vue';
import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
export default {
mounted(src, binding, vn) {
if (binding.value === false) return;
let isBottom = true;
const container = getScrollContainer(src)!;
container.addEventListener('scroll', () => {
const pos = getScrollPosition(container);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
isBottom = (pos + viewHeight > height - 32);
}, { passive: true });
container.scrollTop = container.scrollHeight;
const ro = new ResizeObserver((entries, observer) => {
if (isBottom) {
const height = container.scrollHeight;
container.scrollTop = height;
}
});
ro.observe(src);
// TODO: 新たにプロパティを作るのをやめMapを使う
src._ro_ = ro;
},
unmounted(src, binding, vn) {
if (src._ro_) src._ro_.unobserve(src);
}
} as Directive;

View file

@ -1,98 +1,46 @@
<template>
<div class="driuhtrh">
<div class="query">
<MkInput v-model="q" class="" :placeholder="$ts.search">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search">
<template #prefix><i class="fas fa-search"></i></template>
</MkInput>
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div>
<MkFolder v-if="searchEmojis" class="emojis">
<template #header>{{ $ts.searchResult }}</template>
<template #header>{{ i18n.ts.searchResult }}</template>
<div class="zuvgdzyt">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
</div>
</MkFolder>
<MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis">
<template #header>{{ category || $ts.other }}</template>
<MkFolder v-for="category in emojiCategories" :key="category" class="emojis">
<template #header>{{ category || i18n.ts.other }}</template>
<div class="zuvgdzyt">
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
<XEmoji v-for="emoji in instance.emojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
</div>
</MkFolder>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import MkButton from '@/components/ui/button.vue';
<script lang="ts" setup>
import { watch } from 'vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkTab from '@/components/tab.vue';
import * as os from '@/os';
import { emojiCategories, emojiTags } from '@/instance';
import { i18n } from '@/i18n';
import { emojiCategories, instance } from '@/instance';
import XEmoji from './emojis.emoji.vue';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkFolder,
MkTab,
XEmoji,
},
let q: string = $ref('');
let searchEmojis: null | Record<string, any>[] = $ref(null);
data() {
return {
q: '',
customEmojiCategories: emojiCategories,
customEmojis: this.$instance.emojis,
tags: emojiTags,
selectedTags: new Set(),
searchEmojis: null,
};
},
watch: {
q() { this.search(); },
selectedTags: {
handler() {
this.search();
},
deep: true
},
},
methods: {
search() {
if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
this.searchEmojis = null;
return;
}
if (this.selectedTags.size === 0) {
this.searchEmojis = this.customEmojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q));
} else {
this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.aliases.includes(t)));
}
},
toggleTag(tag) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
}
function search() {
if (q === '') {
searchEmojis = null;
} else {
searchEmojis = instance.emojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q));
}
});
}
watch(q, search);
</script>
<style lang="scss" scoped>

View file

@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<div :class="$style.root">
<XCategory v-if="tab === 'category'"/>
<XCategory/>
</div>
</MkStickyContainer>
</template>
@ -14,8 +14,6 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const tab = ref('category');
function menu(ev) {
os.popupMenu([{
icon: 'fas fa-download',

View file

@ -4,7 +4,7 @@
<div class="title"><slot name="header"></slot></div>
<div class="buttons">
<slot name="func"></slot>
<button v-if="removable" class="_button" @click="remove()">
<button v-if="removable" class="_button" @click="emit('remove')">
<i class="fas fa-trash-alt"></i>
</button>
<button v-if="draggable" class="drag-handle _button">
@ -16,56 +16,41 @@
</button>
</div>
</header>
<p v-show="showBody" v-if="error != null" class="error">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
<p v-show="showBody" v-if="warn != null" class="warn">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
<p v-show="showBody" v-if="error != null" class="error">{{ i18n.t('_pages.script.typeError', { slot: error.arg + 1, expect: i18n.t(`script.types.${error.expect}`), actual: i18n.t(`script.types.${error.actual}`) }) }}</p>
<p v-show="showBody" v-if="warn != null" class="warn">{{ i18n.t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
<div v-show="showBody" class="body">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
expanded: {
type: Boolean,
default: true
},
removable: {
type: Boolean,
default: true
},
draggable: {
type: Boolean,
default: false
},
error: {
required: false,
default: null
},
warn: {
required: false,
default: null
}
},
emits: ['toggle', 'remove'],
data() {
return {
showBody: this.expanded,
};
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
this.$emit('toggle', show);
},
remove() {
this.$emit('remove');
}
}
const emit = defineEmits<{
(ev: 'toggle', v: boolean): void;
(ev: 'remove'): void;
}>();
const props = withDefaults(defineProps<{
expanded?: boolean;
removable?: boolean;
draggable?: boolean;
error?: { arg: integer; expect: string; actual: string; };
warn?: { slot: integer; };
}>(), {
expanded: true,
removable: true,
draggable: false,
});
let showBody = $ref(props.expanded);
function toggleContent(show: boolean) {
showBody = show;
emit('toggle', show);
}
</script>
<style lang="scss" scoped>

View file

@ -1,30 +0,0 @@
import * as Acct from 'misskey-js/built/acct';
import { host as localHost } from '@/config';
export async function genSearchQuery(v: any, q: string) {
let host: string;
let userId: string;
if (q.split(' ').some(x => x.startsWith('@'))) {
for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) {
if (at.includes('.')) {
if (at === localHost || at === '.') {
host = null;
} else {
host = at;
}
} else {
const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null);
if (user) {
userId = user.id;
} else {
// todo: show error
}
}
}
}
return {
query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
host: host,
userId: userId
};
}

View file

@ -1,3 +0,0 @@
export default function(user: { name?: string | null, username: string }): string {
return user.name || user.username;
}

View file

@ -1,81 +0,0 @@
import { v4 as uuid } from 'uuid';
import { themeProps, Theme } from './theme';
export type Default = null;
export type Color = string;
export type FuncName = 'alpha' | 'darken' | 'lighten';
export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
export type RefProp = { type: 'refProp'; key: string; };
export type RefConst = { type: 'refConst'; key: string; };
export type Css = { type: 'css'; value: string; };
export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
export type ThemeViewModel = [ string, ThemeValue ][];
export const fromThemeString = (str?: string) : ThemeValue => {
if (!str) return null;
if (str.startsWith(':')) {
const parts = str.slice(1).split('<');
const name = parts[0] as FuncName;
const arg = parseFloat(parts[1]);
const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
return { type: 'func', name, arg, value };
} else if (str.startsWith('@')) {
return {
type: 'refProp',
key: str.slice(1),
};
} else if (str.startsWith('$')) {
return {
type: 'refConst',
key: str.slice(1),
};
} else if (str.startsWith('"')) {
return {
type: 'css',
value: str.substr(1).trim(),
};
} else {
return str;
}
};
export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
if (typeof value === 'string') return value;
switch (value.type) {
case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
case 'refProp': return `@${value.key}`;
case 'refConst': return `$${value.key}`;
case 'css': return `" ${value.value}`;
}
};
export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
const props = { } as { [key: string]: string };
for (const [ key, value ] of vm) {
if (value === null) continue;
props[key] = toThemeString(value);
}
return {
id: uuid(),
name, desc, author, props, base
};
};
export const convertToViewModel = (theme: Theme): ThemeViewModel => {
const vm: ThemeViewModel = [];
// プロパティの登録
vm.push(...themeProps.map(key => [ key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
// 定数の登録
const consts = Object
.keys(theme.props)
.filter(k => k.startsWith('$'))
.map(k => [ k, fromThemeString(theme.props[k]) ] as [ string, ThemeValue ]);
vm.push(...consts);
return vm;
};