Merge branch 'develop' of https://activitypub.software/TransFem-org/Sharkey into feat/instance-admin-ui

This commit is contained in:
PrivateGER 2024-10-06 23:13:10 +02:00
commit fadae347ff
37 changed files with 541 additions and 65 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -216,19 +216,25 @@ export async function mainBoot() {
claimAchievement('collectAchievements30');
}
window.setInterval(() => {
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
}
}, 1000 * 10);
if (!claimedAchievements.includes('justPlainLucky')) {
window.setInterval(() => {
if (Math.floor(Math.random() * 20000) === 0) {
claimAchievement('justPlainLucky');
}
}, 1000 * 10);
}
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
if (!claimedAchievements.includes('client30min')) {
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
}
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
if (!claimedAchievements.includes('client60min')) {
window.setTimeout(() => {
claimAchievement('client60min');
}, 1000 * 60 * 60);
}
// 邪魔
//const lastUsed = miLocalStorage.getItem('lastUsed');

View file

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
:title="url"
@click.prevent="self ? true : promptConfirm()"
@click.prevent="self ? true : warningExternalWebsite(url)"
@click.stop
>
<slot></slot>
@ -23,7 +23,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { MkABehavior } from '@/components/global/MkA.vue';
import { i18n } from '@/i18n.js';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const props = withDefaults(defineProps<{
url: string;
@ -49,16 +49,6 @@ if (isEnabledUrlPreview.value) {
});
});
}
async function promptConfirm() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.confirmRemoteUrl({ x: props.url }),
plain: true,
});
if (canceled) return;
window.open(props.url, '_blank', 'nofollow noopener popup=false');
}
</script>
<style lang="scss" module>

View file

@ -630,11 +630,22 @@ async function onPaste(ev: ClipboardEvent) {
if (paste.length > 1000) {
ev.preventDefault();
os.confirm({
type: 'info',
os.actions({
type: 'question',
text: i18n.ts.attachAsFileQuestion,
}).then(({ canceled }) => {
if (canceled) {
actions: [
{
value: 'yes',
text: i18n.ts.yes,
primary: true,
},
{
value: 'no',
text: i18n.ts.no,
},
],
}).then(({ result }) => {
if (result !== 'yes') {
insertTextAtCursor(textareaEl.value, paste);
return;
}

View file

@ -0,0 +1,131 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root" class="_gaps">
<div class="_gaps_s">
<div :class="$style.header">
<div :class="$style.icon">
<i class="ti ti-alert-triangle"></i>
</div>
<div :class="$style.title">{{ i18n.ts._externalNavigationWarning.title }}</div>
</div>
<div><Mfm :text="i18n.tsx._externalNavigationWarning.description({ host: instanceName })"/></div>
<div class="_monospace" :class="$style.urlAddress">{{ url }}</div>
<div>
<MkSwitch v-model="trustThisDomain">{{ i18n.ts._externalNavigationWarning.trustThisDomain }}</MkSwitch>
</div>
</div>
<div :class="$style.buttons">
<MkButton data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton data-cy-modal-dialog-ok inline primary rounded @click="ok"><i class="ti ti-external-link"></i> {{ i18n.ts.open }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import { instanceName } from '@/config.js';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
type Result = string | number | true | null;
const props = defineProps<{
url: string;
}>();
const emit = defineEmits<{
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const trustThisDomain = ref(false);
const domain = computed(() => new URL(props.url).hostname);
// overload function 使 lint
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
modal.value?.close();
}
async function ok() {
const result = true;
if (!defaultStore.state.trustedDomains.includes(domain.value) && trustThisDomain.value) {
await defaultStore.set('trustedDomains', defaultStore.state.trustedDomains.concat(domain.value));
}
done(false, result);
}
function cancel() {
done(true);
}
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
width: 100%;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
background: var(--panel);
border-radius: 16px;
}
.header {
display: flex;
align-items: center;
gap: 0.75em;
}
.icon {
font-size: 18px;
color: var(--warn);
}
.title {
font-weight: bold;
font-size: 1.1em;
}
.urlAddress {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--divider);
overflow-x: auto;
white-space: nowrap;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: right;
}
</style>

View file

@ -142,6 +142,7 @@ function showMenu(ev: MouseEvent) {
height: 32px;
border-radius: var(--radius-sm);
font-size: 18px;
z-index: 50;
}
.mainFg {

View file

@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
@contextmenu.stop="() => {}"
@click.prevent="self ? true : warningExternalWebsite(props.url)"
@click.stop
>
<template v-if="!self">
@ -34,6 +35,7 @@ import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { MkABehavior } from '@/components/global/MkA.vue';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const props = withDefaults(defineProps<{
url: string;

View file

@ -160,9 +160,9 @@ export const ROLE_POLICIES = [
export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM';
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://launcher.moe/error.png';
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://launcher.moe/missingpage.webp';
export const DEFAULT_INFO_IMAGE_URL = 'https://launcher.moe/nothinghere.png';
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/status/error.png';
export const DEFAULT_NOT_FOUND_IMAGE_URL = '/status/missingpage.webp';
export const DEFAULT_INFO_IMAGE_URL = '/status/nothinghere.png';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {

View file

@ -20,7 +20,7 @@
worker-src 'self';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org;
frame-src *;"

View file

@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkTextarea v-model="trustedLinkUrlPatterns">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template>
<template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template>
</MkTextarea>
<MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
@ -105,6 +111,7 @@ const bubbleTimeline = ref<string>('');
const tosUrl = ref<string | null>(null);
const privacyPolicyUrl = ref<string | null>(null);
const inquiryUrl = ref<string | null>(null);
const trustedLinkUrlPatterns = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
@ -120,6 +127,7 @@ async function init() {
bubbleTimeline.value = meta.bubbleInstances.join('\n');
bubbleTimelineEnabled.value = meta.policies.btlAvailable;
inquiryUrl.value = meta.inquiryUrl;
trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n');
}
function save() {
@ -135,6 +143,7 @@ function save() {
hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'),
bubbleInstances: bubbleTimeline.value.split('\n'),
trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'),
}).then(() => {
fetchInstance(true);
});

View file

@ -23,6 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
'markSensitiveDriveFile',
'resetPassword',
'suspendRemoteInstance',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'rejectRemoteInstanceReports',
'acceptRemoteInstanceReports',
].includes(log.type),
[$style.logRed]: [
'suspend',
@ -61,6 +65,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'unmarkSensitiveDriveFile'">: @{{ log.info.fileUserUsername }}{{ log.info.fileUserHost ? '@' + log.info.fileUserHost : '' }}</span>
<span v-else-if="log.type === 'suspendRemoteInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'unsuspendRemoteInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'setRemoteInstanceNSFW'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'unsetRemoteInstanceNSFW'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'rejectRemoteInstanceReports'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'acceptRemoteInstanceReports'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'createGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>
<span v-else-if="log.type === 'updateGlobalAnnouncement'">: {{ log.info.before.title }}</span>
<span v-else-if="log.type === 'deleteGlobalAnnouncement'">: {{ log.info.announcement.title }}</span>

View file

@ -49,10 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="suspensionState === 'none'" inline :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton>
<MkButton v-if="suspensionState !== 'none'" inline :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
</div>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo>
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch>
<MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch>
<MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
<MkTextarea v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
@ -160,6 +164,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
import { dateString } from '@/filters/date.js';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
const props = defineProps<{
host: string;
@ -174,10 +179,26 @@ const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'au
const isBlocked = ref(false);
const isSilenced = ref(false);
const isNSFW = ref(false);
const rejectReports = ref(false);
const isMediaSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
const moderationNote = ref('');
const baseDomains = computed(() => {
const domains: string[] = [];
const parts = props.host.toLowerCase().split('.');
for (let s = 1; s < parts.length; s++) {
const domain = parts.slice(s).join('.');
domains.push(domain);
}
return domains;
});
const isBaseBlocked = computed(() => meta.value && baseDomains.value.some(d => meta.value?.blockedHosts.includes(d)));
const isBaseSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.silencedHosts.includes(d)));
const isBaseMediaSilenced = computed(() => meta.value && baseDomains.value.some(d => meta.value?.mediaSilencedHosts.includes(d)));
const usersPagination = {
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
limit: 10,
@ -204,6 +225,7 @@ async function fetch(): Promise<void> {
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
isNSFW.value = instance.value?.isNSFW ?? false;
rejectReports.value = instance.value?.rejectReports ?? false;
isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
moderationNote.value = instance.value?.moderationNote ?? '';
@ -264,6 +286,14 @@ async function toggleNSFW(): Promise<void> {
});
}
async function toggleRejectReports(): Promise<void> {
if (!instance.value) throw new Error('No instance?');
await misskeyApi('admin/federation/update-instance', {
host: instance.value.host,
rejectReports: rejectReports.value,
});
}
function refreshMetadata(): void {
if (!instance.value) throw new Error('No instance?');
misskeyApi('admin/federation/refresh-remote-instance-metadata', {

View file

@ -30,7 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div v-if="$i && $i.id != user.id" class="info-badges">
<span v-if="user.isFollowed">{{ i18n.ts.followsYou }}</span>
<span v-if="user.isMuted">{{ i18n.ts.muted }}</span>
<span v-if="user.isRenoteMuted">{{ i18n.ts.renoteMuted }}</span>
<span v-if="user.isBlocking">{{ i18n.ts.blocked }}</span>
</div>
<div class="actions">
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
@ -445,15 +450,25 @@ onUnmounted(() => {
background: linear-gradient(transparent, rgba(#000, 0.7));
}
> .followed {
> .info-badges {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
font-size: 0.7em;
border-radius: var(--radius-sm);
display: flex;
flex-direction: row;
> * {
padding: 4px 8px;
color: #fff;
background: rgba(0, 0, 0, 0.7);
font-size: 0.7em;
border-radius: var(--radius-sm);
}
> :not(:first-child) {
margin-left: 8px;
}
}
> .actions {

View file

@ -9,6 +9,7 @@ import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { Plugin, noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions, pageViewInterruptors } from '@/store.js';
import { warningExternalWebsite } from '@/scripts/warning-external-website.js';
const parser = new Parser();
const pluginContexts = new Map<string, Interpreter>();
@ -92,16 +93,8 @@ function createPluginEnv(opts: { plugin: Plugin; storageKey: string }): Record<s
registerPageViewInterruptor({ pluginId: opts.plugin.id, handler });
}),
'Plugin:open_url': values.FN_NATIVE(([url]) => {
(async () => {
utils.assertString(url);
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.confirmRemoteUrl({ x: url.value }),
plain: true,
});
if (canceled) return;
window.open(url.value, '_blank', 'noopener');
})();
utils.assertString(url);
warningExternalWebsite(url.value);
}),
'Plugin:config': values.OBJ(config),
};

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue';
const extractDomain = /^(https?:\/\/|\/\/)?([^@/\s]+@)?(www\.)?([^:/\s]+)/i;
const isRegExp = /^\/(.+)\/(.*)$/;
export async function warningExternalWebsite(url: string) {
const domain = extractDomain.exec(url)?.[4];
if (!domain) return false;
const isTrustedByInstance = instance.trustedLinkUrlPatterns.some(expression => {
const r = isRegExp.exec(expression);
if (r) {
return new RegExp(r[1], r[2]).test(url);
} else if (expression.includes(' ')) {
return expression.split(' ').every(keyword => url.includes(keyword));
} else {
return domain.endsWith(expression);
}
});
const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(domain);
if (!isTrustedByInstance && !isTrustedByUser) {
const confirm = await new Promise<{ canceled: boolean }>(resolve => {
const { dispose } = os.popup(MkUrlWarningDialog, {
url,
}, {
done: result => {
resolve(result ?? { canceled: true });
},
closed: () => dispose(),
});
});
if (confirm.canceled) return false;
return window.open(url, '_blank', 'nofollow noopener popup=false');
}
return window.open(url, '_blank', 'nofollow noopener popup=false');
}

View file

@ -165,6 +165,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: 'public' as 'public' | 'home' | 'followers',
},
trustedDomains: {
where: 'account',
default: [] as string[],
},
menu: {
where: 'deviceAccount',