Merge branch 'develop'

This commit is contained in:
syuilo 2020-03-28 19:52:41 +09:00
commit f014a79f8d
48 changed files with 836 additions and 195 deletions

View File

@ -192,7 +192,7 @@ newPassword: "Nouveau mot de passe"
newPasswordRetype: "Nouveau mot de passe (répéter)" newPasswordRetype: "Nouveau mot de passe (répéter)"
attachFile: "Joindre un fichier" attachFile: "Joindre un fichier"
more: "Plus !" more: "Plus !"
featured: "Surlignage" featured: "Tendances"
usernameOrUserId: "Nom d'utilisateur ou ID utilisateur" usernameOrUserId: "Nom d'utilisateur ou ID utilisateur"
noSuchUser: "Utilisateur non trouvé" noSuchUser: "Utilisateur non trouvé"
lookup: "Recherche" lookup: "Recherche"
@ -214,7 +214,7 @@ explore: "Découvrir"
games: "Jeux de Misskey" games: "Jeux de Misskey"
messageRead: "Lus" messageRead: "Lus"
noMoreHistory: "Plus d'histoire passée" noMoreHistory: "Plus d'histoire passée"
startMessaging: "Commencer à écrire un discutez" startMessaging: "Commencer à discuter"
nUsersRead: "{n} personnes ont lu" nUsersRead: "{n} personnes ont lu"
agreeTo: "D'accord {0}" agreeTo: "D'accord {0}"
tos: "Conditions d'utilisation" tos: "Conditions d'utilisation"
@ -524,7 +524,7 @@ _tutorial:
step7_3: "Alors, profitez de Misskey 🚀" step7_3: "Alors, profitez de Misskey 🚀"
_2fa: _2fa:
alreadyRegistered: "Cette étape à déjà été complétée" alreadyRegistered: "Cette étape à déjà été complétée"
registerDevice: "Sinscrire l'appareil" registerDevice: "Ajouter un appareil"
registerKey: "Sinscrire la clé" registerKey: "Sinscrire la clé"
step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil." step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil."
step2: "Ensuite, scannez le code QR affiché avec l'application." step2: "Ensuite, scannez le code QR affiché avec l'application."

View File

@ -468,6 +468,10 @@ unableToProcess: "操作を完了できません"
recentUsed: "最近使用" recentUsed: "最近使用"
install: "インストール" install: "インストール"
uninstall: "アンインストール" uninstall: "アンインストール"
installedApps: "インストールされたアプリ"
nothing: "ありません"
installedDate: "インストール日時"
lastUsedDate: "最終使用日時"
_theme: _theme:
explore: "テーマを探す" explore: "テーマを探す"
@ -568,7 +572,11 @@ _permissions:
_auth: _auth:
shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?" shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?"
shareAccessAsk: "アカウントへのアクセスを許可しますか?"
permissionAsk: "このアプリは次の権限を要求しています" permissionAsk: "このアプリは次の権限を要求しています"
pleaseGoBack: "アプリケーションに戻ってやっていってください"
callback: "アプリケーションに戻っています"
denied: "アクセスを拒否しました"
_antennaSources: _antennaSources:
all: "全てのノート" all: "全てのノート"

View File

@ -0,0 +1,36 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class miauth1585361548360 implements MigrationInterface {
name = 'miauth1585361548360'
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "access_token" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE DEFAULT null`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "session" character varying(128) DEFAULT null`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "name" character varying(128) DEFAULT null`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "description" character varying(512) DEFAULT null`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "iconUrl" character varying(512) DEFAULT null`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "permission" character varying(64) array NOT NULL DEFAULT '{}'::varchar[]`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD "fetched" boolean NOT NULL DEFAULT false`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "appId" DROP NOT NULL`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "appId" SET DEFAULT null`, undefined);
await queryRunner.query(`CREATE INDEX "IDX_bf3a053c07d9fb5d87317c56ee" ON "access_token" ("session") `, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "access_token" DROP CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_bf3a053c07d9fb5d87317c56ee"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "appId" DROP DEFAULT`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "appId" SET NOT NULL`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" ADD CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "fetched"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "permission"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "iconUrl"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "description"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "name"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "session"`, undefined);
await queryRunner.query(`ALTER TABLE "access_token" DROP COLUMN "lastUsedAt"`, undefined);
}
}

View File

@ -0,0 +1,48 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class customNotification1585385921215 implements MigrationInterface {
name = 'customNotification1585385921215'
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "notification" ADD "customBody" character varying(2048)`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD "customHeader" character varying(256)`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD "customIcon" character varying(1024)`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD "appAccessTokenId" character varying(32)`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710"`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "notifierId" DROP NOT NULL`, undefined);
await queryRunner.query(`COMMENT ON COLUMN "notification"."notifierId" IS 'The ID of sender user of the Notification.'`, undefined);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`, undefined);
await queryRunner.query(`CREATE TYPE "notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum" USING "type"::"text"::"notification_type_enum"`, undefined);
await queryRunner.query(`DROP TYPE "notification_type_enum_old"`, undefined);
await queryRunner.query(`COMMENT ON COLUMN "notification"."type" IS 'The type of the Notification.'`, undefined);
await queryRunner.query(`CREATE INDEX "IDX_3b4e96eec8d36a8bbb9d02aa71" ON "notification" ("notifierId") `, undefined);
await queryRunner.query(`CREATE INDEX "IDX_33f33cc8ef29d805a97ff4628b" ON "notification" ("type") `, undefined);
await queryRunner.query(`CREATE INDEX "IDX_080ab397c379af09b9d2169e5b" ON "notification" ("isRead") `, undefined);
await queryRunner.query(`CREATE INDEX "IDX_e22bf6bda77b6adc1fd9e75c8c" ON "notification" ("appAccessTokenId") `, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_e22bf6bda77b6adc1fd9e75c8c9" FOREIGN KEY ("appAccessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_e22bf6bda77b6adc1fd9e75c8c9"`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_e22bf6bda77b6adc1fd9e75c8c"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_080ab397c379af09b9d2169e5b"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_33f33cc8ef29d805a97ff4628b"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_3b4e96eec8d36a8bbb9d02aa71"`, undefined);
await queryRunner.query(`COMMENT ON COLUMN "notification"."type" IS ''`, undefined);
await queryRunner.query(`CREATE TYPE "notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited')`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum_old" USING "type"::"text"::"notification_type_enum_old"`, undefined);
await queryRunner.query(`DROP TYPE "notification_type_enum"`, undefined);
await queryRunner.query(`ALTER TYPE "notification_type_enum_old" RENAME TO "notification_type_enum"`, undefined);
await queryRunner.query(`COMMENT ON COLUMN "notification"."notifierId" IS ''`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "notifierId" SET NOT NULL`, undefined);
await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "appAccessTokenId"`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "customIcon"`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "customHeader"`, undefined);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "customBody"`, undefined);
}
}

View File

@ -1,7 +1,7 @@
{ {
"name": "misskey", "name": "misskey",
"author": "syuilo <syuilotan@yahoo.co.jp>", "author": "syuilo <syuilotan@yahoo.co.jp>",
"version": "12.26.0", "version": "12.27.0",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",
@ -169,6 +169,7 @@
"lolex": "5.1.2", "lolex": "5.1.2",
"lookup-dns-cache": "2.1.0", "lookup-dns-cache": "2.1.0",
"markdown-it": "10.0.0", "markdown-it": "10.0.0",
"markdown-it-anchor": "5.2.5",
"mocha": "7.0.1", "mocha": "7.0.1",
"moji": "0.5.1", "moji": "0.5.1",
"ms": "2.1.2", "ms": "2.1.2",

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="mk-notes" v-size="[{ max: 500 }]"> <div class="mk-notes" v-size="[{ max: 500 }]">
<div class="empty" v-if="empty"> <div class="_fullinfo" v-if="empty">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $t('noNotes') }}</div> <div>{{ $t('noNotes') }}</div>
</div> </div>
@ -90,18 +90,6 @@ export default Vue.extend({
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-notes { .mk-notes {
> .empty {
padding: 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
}
> .notes { > .notes {
> ::v-deep *:not(:last-child) { > ::v-deep *:not(:last-child) {
margin-bottom: var(--marginFull); margin-bottom: var(--marginFull);

View File

@ -1,22 +1,24 @@
<template> <template>
<div class="mk-notification" :class="notification.type" v-size="[{ max: 500 }, { max: 600 }]"> <div class="mk-notification" :class="notification.type" v-size="[{ max: 500 }, { max: 600 }]">
<div class="head"> <div class="head">
<mk-avatar class="avatar" :user="notification.user"/> <mk-avatar v-if="notification.user" class="icon" :user="notification.user"/>
<div class="icon" :class="notification.type"> <img v-else class="icon" :src="notification.icon" alt=""/>
<div class="sub-icon" :class="notification.type">
<fa :icon="faPlus" v-if="notification.type === 'follow'"/> <fa :icon="faPlus" v-if="notification.type === 'follow'"/>
<fa :icon="faClock" v-if="notification.type === 'receiveFollowRequest'"/> <fa :icon="faClock" v-else-if="notification.type === 'receiveFollowRequest'"/>
<fa :icon="faCheck" v-if="notification.type === 'followRequestAccepted'"/> <fa :icon="faCheck" v-else-if="notification.type === 'followRequestAccepted'"/>
<fa :icon="faIdCardAlt" v-if="notification.type === 'groupInvited'"/> <fa :icon="faIdCardAlt" v-else-if="notification.type === 'groupInvited'"/>
<fa :icon="faRetweet" v-if="notification.type === 'renote'"/> <fa :icon="faRetweet" v-else-if="notification.type === 'renote'"/>
<fa :icon="faReply" v-if="notification.type === 'reply'"/> <fa :icon="faReply" v-else-if="notification.type === 'reply'"/>
<fa :icon="faAt" v-if="notification.type === 'mention'"/> <fa :icon="faAt" v-else-if="notification.type === 'mention'"/>
<fa :icon="faQuoteLeft" v-if="notification.type === 'quote'"/> <fa :icon="faQuoteLeft" v-else-if="notification.type === 'quote'"/>
<x-reaction-icon v-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/> <x-reaction-icon v-else-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/>
</div> </div>
</div> </div>
<div class="tail"> <div class="tail">
<header> <header>
<router-link class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link> <router-link v-if="notification.user" class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link>
<span v-else>{{ notification.header }}</span>
<mk-time :time="notification.createdAt" v-if="withTime"/> <mk-time :time="notification.createdAt" v-if="withTime"/>
</header> </header>
<router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
@ -42,6 +44,9 @@
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span> <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span>
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span> <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span>
<span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $t('groupInvited') }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $t('reject') }}</button></div></span> <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $t('groupInvited') }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $t('reject') }}</button></div></span>
<span v-if="notification.type === 'app'" class="text">
<mfm :text="notification.body" :nowrap="!full"/>
</span>
</div> </div>
</div> </div>
</template> </template>
@ -142,14 +147,14 @@ export default Vue.extend({
height: 42px; height: 42px;
margin-right: 8px; margin-right: 8px;
> .avatar { > .icon {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 6px; border-radius: 6px;
} }
> .icon { > .sub-icon {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
bottom: -2px; bottom: -2px;
@ -163,6 +168,10 @@ export default Vue.extend({
font-size: 12px; font-size: 12px;
pointer-events: none; pointer-events: none;
&:empty {
display: none;
}
> * { > * {
color: #fff; color: #fff;
width: 100%; width: 100%;

101
src/client/pages/apps.vue Normal file
View File

@ -0,0 +1,101 @@
<template>
<div>
<portal to="icon"><fa :icon="faPlug"/></portal>
<portal to="title">{{ $t('installedApps') }}</portal>
<mk-pagination :pagination="pagination" class="bfomjevm" ref="list">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $t('nothing') }}</div>
</div>
</template>
<template #default="{items}">
<div class="token _panel" v-for="token in items" :key="token.id">
<img class="icon" :src="token.iconUrl" alt=""/>
<div class="body">
<div class="name">{{ token.name }}</div>
<div class="description">{{ token.description }}</div>
<div class="_keyValue">
<div>{{ $t('installedDate') }}:</div>
<div><mk-time :time="token.createdAt"/></div>
</div>
<div class="_keyValue">
<div>{{ $t('lastUsedDate') }}:</div>
<div><mk-time :time="token.lastUsedAt"/></div>
</div>
<div class="actions">
<button class="_button" @click="revoke(token)"><fa :icon="faTrashAlt"/></button>
</div>
</div>
</div>
</template>
</mk-pagination>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTrashAlt, faPlug } from '@fortawesome/free-solid-svg-icons';
import MkPagination from '../components/ui/pagination.vue';
export default Vue.extend({
metaInfo() {
return {
title: this.$t('installedApps') as string
};
},
components: {
MkPagination
},
data() {
return {
pagination: {
endpoint: 'i/apps',
limit: 100,
params: {
sort: '+lastUsedAt'
}
},
faTrashAlt, faPlug
};
},
methods: {
revoke(token) {
this.$root.api('i/revoke-token', { tokenId: token.id }).then(() => {
this.$refs.list.reload();
});
}
}
});
</script>
<style lang="scss" scoped>
.bfomjevm {
> .token {
display: flex;
padding: 16px;
> .icon {
display: block;
flex-shrink: 0;
margin: 0 12px 0 0;
width: 50px;
height: 50px;
border-radius: 8px;
}
> .body {
width: calc(100% - 62px);
position: relative;
> .name {
font-weight: bold;
}
}
}
}
</style>

View File

@ -12,20 +12,18 @@
@accepted="accepted" @accepted="accepted"
/> />
<div class="denied _panel" v-if="state == 'denied'"> <div class="denied _panel" v-if="state == 'denied'">
<h1>{{ $t('denied') }}</h1> <h1>{{ $t('_auth.denied') }}</h1>
<p>{{ $t('denied-paragraph') }}</p>
</div> </div>
<div class="accepted _panel" v-if="state == 'accepted'"> <div class="accepted _panel" v-if="state == 'accepted'">
<h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1> <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1>
<p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p> <p v-if="session.app.callbackUrl">{{ $t('_auth.callback') }}<mk-ellipsis/></p>
<p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p> <p v-if="!session.app.callbackUrl">{{ $t('_auth.pleaseGoBack') }}</p>
</div> </div>
<div class="error _panel" v-if="state == 'fetch-session-error'"> <div class="error _panel" v-if="state == 'fetch-session-error'">
<p>{{ $t('error') }}</p> <p>{{ $t('error') }}</p>
</div> </div>
</div> </div>
<div class="signin" v-else> <div class="signin" v-else>
<h1>{{ $t('sign-in') }}</h1>
<mk-signin @login="onLogin"/> <mk-signin @login="onLogin"/>
</div> </div>
</template> </template>

View File

@ -18,6 +18,7 @@
import Vue from 'vue'; import Vue from 'vue';
import { faFileAlt } from '@fortawesome/free-solid-svg-icons' import { faFileAlt } from '@fortawesome/free-solid-svg-icons'
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import MarkdownItAnchor from 'markdown-it-anchor';
import i18n from '../i18n'; import i18n from '../i18n';
import { url, lang } from '../config'; import { url, lang } from '../config';
import MkLink from '../components/link.vue'; import MkLink from '../components/link.vue';
@ -26,6 +27,10 @@ const markdown = MarkdownIt({
html: true html: true
}); });
markdown.use(MarkdownItAnchor, {
slugify: (s) => encodeURIComponent(String(s).trim().replace(/\s+/g, '-'))
});
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -72,6 +77,9 @@ export default Vue.extend({
}, },
parse(md: string) { parse(md: string) {
//
md = md.replace(/\{_URL_\}/g, url);
// markdown // markdown
const parsed = markdown.parse(md, {}); const parsed = markdown.parse(md, {});
if (parsed.length === 0) return; if (parsed.length === 0) return;
@ -115,6 +123,23 @@ export default Vue.extend({
margin-bottom: 0; margin-bottom: 0;
} }
::v-deep a {
color: var(--link);
}
::v-deep blockquote {
display: block;
margin: 8px;
padding: 6px 0 6px 12px;
color: var(--fg);
border-left: solid 3px var(--fg);
opacity: 0.7;
p {
margin: 0;
}
}
::v-deep h2 { ::v-deep h2 {
font-size: 1.25em; font-size: 1.25em;
padding: 0 0 0.5em 0; padding: 0 0 0.5em 0;

View File

@ -5,7 +5,7 @@
<mk-pagination :pagination="pagination" class="mk-follow-requests" ref="list"> <mk-pagination :pagination="pagination" class="mk-follow-requests" ref="list">
<template #empty> <template #empty>
<div class="tkdrhpxr"> <div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $t('noFollowRequests') }}</div> <div>{{ $t('noFollowRequests') }}</div>
</div> </div>
@ -75,18 +75,6 @@ export default Vue.extend({
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-follow-requests { .mk-follow-requests {
.tkdrhpxr {
padding: 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
}
> .user { > .user {
display: flex; display: flex;
padding: 16px; padding: 16px;

View File

@ -31,7 +31,7 @@
</div> </div>
</router-link> </router-link>
</div> </div>
<div class="no-history" v-if="!fetching && messages.length == 0"> <div class="_fullinfo" v-if="!fetching && messages.length == 0">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $t('noHistory') }}</div> <div>{{ $t('noHistory') }}</div>
</div> </div>
@ -287,18 +287,6 @@ export default Vue.extend({
} }
} }
> .no-history {
padding: 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
}
@media (max-width: 400px) { @media (max-width: 400px) {
> .history { > .history {
> .message { > .message {

103
src/client/pages/miauth.vue Normal file
View File

@ -0,0 +1,103 @@
<template>
<div v-if="$store.getters.isSignedIn">
<div class="waiting _card" v-if="state == 'waiting'">
<div class="_content">
<mk-loading/>
</div>
</div>
<div class="denied _card" v-if="state == 'denied'">
<div class="_content">
<p>{{ $t('_auth.denied') }}</p>
</div>
</div>
<div class="accepted _card" v-else-if="state == 'accepted'">
<div class="_content">
<p v-if="callback">{{ $t('_auth.callback') }}<mk-ellipsis/></p>
<p v-else>{{ $t('_auth.pleaseGoBack') }}</p>
</div>
</div>
<div class="_card" v-else>
<div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div>
<div class="_title" v-else>{{ $t('_auth.shareAccessAsk') }}</div>
<div class="_content">
<p>{{ $t('_auth.permissionAsk') }}</p>
<ul>
<template v-for="p in permission">
<li :key="p">{{ $t(`_permissions.${p}`) }}</li>
</template>
</ul>
</div>
<div class="_footer">
<mk-button @click="deny" inline>{{ $t('cancel') }}</mk-button>
<mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button>
</div>
</div>
</div>
<div class="signin" v-else>
<mk-signin @login="onLogin"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import MkSignin from '../components/signin.vue';
import MkButton from '../components/ui/button.vue';
export default Vue.extend({
i18n,
components: {
MkSignin,
MkButton,
},
data() {
return {
state: null
};
},
computed: {
session(): string {
return this.$route.params.session;
},
callback(): string {
return this.$route.query.callback;
},
name(): string {
return this.$route.query.name;
},
icon(): string {
return this.$route.query.icon;
},
permission(): string {
return this.$route.query.permission;
},
},
methods: {
async accept() {
this.state = 'waiting';
await this.$root.api('miauth/gen-token', {
session: this.session,
name: this.name,
iconUrl: this.icon,
permission: this.permission || [],
});
this.state = 'accepted';
if (this.callback) {
location.href = `${this.callback}?session=${this.session}`;
}
},
deny() {
this.state = 'denied';
},
onLogin(res) {
localStorage.setItem('i', res.i);
location.reload();
}
}
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -32,7 +32,9 @@
<x-integration/> <x-integration/>
<x-api/> <x-api/>
<mk-button @click="$root.signout()" primary style="margin: var(--margin) auto;">{{ $t('logout') }}</mk-button> <router-link class="_panel _buttonPrimary" to="/my/apps" style="margin: var(--margin) auto;">{{ $t('installedApps') }}</router-link>
<button class="_panel _buttonPrimary" @click="$root.signout()" style="margin: var(--margin) auto;">{{ $t('logout') }}</button>
</div> </div>
</template> </template>

View File

@ -57,7 +57,8 @@
<mk-textarea v-model="installThemeCode"> <mk-textarea v-model="installThemeCode">
<span>{{ $t('_theme.code') }}</span> <span>{{ $t('_theme.code') }}</span>
</mk-textarea> </mk-textarea>
<mk-button @click="() => install(this.installThemeCode)" :disabled="installThemeCode == null"><fa :icon="faCheck"/> {{ $t('install') }}</mk-button> <mk-button @click="() => install(this.installThemeCode)" :disabled="installThemeCode == null" primary inline><fa :icon="faCheck"/> {{ $t('install') }}</mk-button>
<mk-button @click="() => preview(this.installThemeCode)" :disabled="installThemeCode == null" inline><fa :icon="faEye"/> {{ $t('preview') }}</mk-button>
</details> </details>
</div> </div>
<div class="_content"> <div class="_content">
@ -79,7 +80,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
import * as JSON5 from 'json5'; import * as JSON5 from 'json5';
import MkInput from '../../components/ui/input.vue'; import MkInput from '../../components/ui/input.vue';
import MkButton from '../../components/ui/button.vue'; import MkButton from '../../components/ui/button.vue';
@ -108,7 +109,7 @@ export default Vue.extend({
installThemeCode: null, installThemeCode: null,
selectedThemeId: null, selectedThemeId: null,
wallpaper: localStorage.getItem('wallpaper'), wallpaper: localStorage.getItem('wallpaper'),
faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
} }
}, },
@ -196,8 +197,9 @@ export default Vue.extend({
}); });
}, },
install(code) { parseThemeCode(code) {
let theme; let theme;
try { try {
theme = JSON5.parse(code); theme = JSON5.parse(code);
} catch (e) { } catch (e) {
@ -205,22 +207,34 @@ export default Vue.extend({
type: 'error', type: 'error',
text: this.$t('_theme.invalid') text: this.$t('_theme.invalid')
}); });
return; return false;
} }
if (!validateTheme(theme)) { if (!validateTheme(theme)) {
this.$root.dialog({ this.$root.dialog({
type: 'error', type: 'error',
text: this.$t('_theme.invalid') text: this.$t('_theme.invalid')
}); });
return; return false;
} }
if (this.$store.state.device.themes.some(t => t.id === theme.id)) { if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
this.$root.dialog({ this.$root.dialog({
type: 'info', type: 'info',
text: this.$t('_theme.alreadyInstalled') text: this.$t('_theme.alreadyInstalled')
}); });
return; return false;
} }
return theme;
},
preview(code) {
const theme = this.parseThemeCode(code);
if (theme) applyTheme(theme, false);
},
install(code) {
const theme = this.parseThemeCode(code);
if (!theme) return;
const themes = this.$store.state.device.themes.concat(theme); const themes = this.$store.state.device.themes.concat(theme);
this.$store.commit('device/set', { this.$store.commit('device/set', {
key: 'themes', value: themes key: 'themes', value: themes

View File

@ -46,6 +46,7 @@ export const router = new VueRouter({
{ path: '/my/groups', component: page('my-groups/index') }, { path: '/my/groups', component: page('my-groups/index') },
{ path: '/my/groups/:group', component: page('my-groups/group') }, { path: '/my/groups/:group', component: page('my-groups/group') },
{ path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/antennas', component: page('my-antennas/index') },
{ path: '/my/apps', component: page('apps') },
{ path: '/preferences', component: page('preferences/index') }, { path: '/preferences', component: page('preferences/index') },
{ path: '/instance', component: page('instance/index') }, { path: '/instance', component: page('instance/index') },
{ path: '/instance/emojis', component: page('instance/emojis') }, { path: '/instance/emojis', component: page('instance/emojis') },
@ -58,6 +59,7 @@ export const router = new VueRouter({
{ path: '/notes/:note', name: 'note', component: page('note') }, { path: '/notes/:note', name: 'note', component: page('note') },
{ path: '/tags/:tag', component: page('tag') }, { path: '/tags/:tag', component: page('tag') },
{ path: '/auth/:token', component: page('auth') }, { path: '/auth/:token', component: page('auth') },
{ path: '/miauth/:session', component: page('miauth') },
{ path: '/authorize-follow', component: page('follow') }, { path: '/authorize-follow', component: page('follow') },
{ path: '/share', component: page('share') }, { path: '/share', component: page('share') },
{ path: '*', component: page('not-found') } { path: '*', component: page('not-found') }

View File

@ -412,6 +412,26 @@ main ._panel {
} }
} }
._fullinfo {
padding: 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
}
._keyValue {
display: flex;
> div {
flex: 1;
}
}
._link { ._link {
color: var(--link); color: var(--link);
} }

View File

@ -57,6 +57,7 @@ import { Antenna } from '../models/entities/antenna';
import { AntennaNote } from '../models/entities/antenna-note'; import { AntennaNote } from '../models/entities/antenna-note';
import { PromoNote } from '../models/entities/promo-note'; import { PromoNote } from '../models/entities/promo-note';
import { PromoRead } from '../models/entities/promo-read'; import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -149,7 +150,7 @@ export const entities = [
...charts as any ...charts as any
]; ];
export function initDb(justBorrow = false, sync = false, log = false, forceRecreate = false) { export function initDb(justBorrow = false, sync = false, forceRecreate = false) {
if (!forceRecreate) { if (!forceRecreate) {
try { try {
const conn = getConnection(); const conn = getConnection();
@ -157,6 +158,8 @@ export function initDb(justBorrow = false, sync = false, log = false, forceRecre
} catch (e) {} } catch (e) {}
} }
const log = program.verbose;
return createConnection({ return createConnection({
type: 'postgres', type: 'postgres',
host: config.db.host, host: config.db.host,

View File

@ -1,3 +1,62 @@
# Misskey API # Misskey API
[APIリファレンス](/api-doc) MisskeyAPIを使ってMisskeyクライアント、Misskey連携Webサービス、Bot等(以下「アプリケーション」と呼びます)を開発できます。
ストリーミングAPIもあるので、リアルタイム性のあるアプリケーションを作ることも可能です。
APIを使い始めるには、まずアクセストークンを取得する必要があります。
このドキュメントでは、アクセストークンを取得する手順を説明した後、基本的なAPIの使い方を説明します。
## アクセストークンの取得
基本的に、APIはリクエストにはアクセストークンが必要となります。
あなたの作ろうとしているアプリケーションが、あなた専用のものなのか、それとも不特定多数の人に使ってもらうものなのかによって、アクセストークンの取得手順は異なります。
* あなた専用の場合: [「自分のアカウントのアクセストークンを取得する」](#自分のアカウントのアクセストークンを取得する)に進む
* 皆に使ってもらう場合: [「アプリケーションとしてアクセストークンを取得する」](#アプリケーションとしてアクセストークンを取得する)に進む
### 自分のアカウントのアクセストークンを取得する
「設定 > API」で、自分のアクセストークンを取得できます。
> この方法で入手したアクセストークンは強力なので、第三者に教えないでください(アプリなどにも入力しないでください)。
[「APIの使い方」へ進む](#APIの使い方)
### アプリケーションとしてアクセストークンを取得する
アプリケーションを使ってもらうには、ユーザーのアクセストークンを以下の手順で取得する必要があります。
#### Step 1
UUIDを生成する。以後これをセッションIDと呼びます。
#### Step 2
`{_URL_}/miauth/{session}`をユーザーのブラウザで表示させる。`{session}`の部分は、セッションIDに置き換えてください。
> 例: `{_URL_}/miauth/c1f6d42b-468b-4fd2-8274-e58abdedef6f`
表示する際、URLにクエリパラメータとしていくつかのオプションを設定できます:
* `name` ... アプリケーション名
* > 例: `MissDeck`
* `icon` ... アプリケーションのアイコン画像URL
* > 例: `https://missdeck.example.com/icon.png`
* `callback` ... 認証が終わった後にリダイレクトするURL
* > 例: `https://missdeck.example.com/callback`
* リダイレクト時には、`session`というクエリパラメータでセッションIDが付きます
* `permission` ... アプリケーションが要求する権限
* > 例: `write:notes,write:following,read:drive`
* 要求する権限を`,`で区切って列挙します
* どのような権限があるかは[APIリファレンス](/api-doc)で確認できます
#### Step 3
ユーザーが連携を許可した後、`{_URL_}/miauth/{session}/check`にPOSTリクエストすると、レスポンスとしてアクセストークンを含むJSONが返ります。
レスポンスに含まれるプロパティ:
* `token` ... ユーザーのアクセストークン
* `user` ... ユーザーの情報
[「APIの使い方」へ進む](#APIの使い方)
## APIの使い方
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。RESTではありません。**
アクセストークンは、`i`というパラメータ名でリクエストに含めます。
* [APIリファレンス](/api-doc)
* [ストリーミングAPI](./stream)

View File

@ -13,12 +13,26 @@ export class AccessToken {
}) })
public createdAt: Date; public createdAt: Date;
@Column('timestamp with time zone', {
nullable: true,
default: null,
})
public lastUsedAt: Date | null;
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 128 length: 128
}) })
public token: string; public token: string;
@Index()
@Column('varchar', {
length: 128,
nullable: true,
default: null
})
public session: string | null;
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 128 length: 128
@ -35,12 +49,48 @@ export class AccessToken {
@JoinColumn() @JoinColumn()
public user: User | null; public user: User | null;
@Column(id()) @Column({
public appId: App['id']; ...id(),
nullable: true,
default: null
})
public appId: App['id'] | null;
@ManyToOne(type => App, { @ManyToOne(type => App, {
onDelete: 'CASCADE' onDelete: 'CASCADE'
}) })
@JoinColumn() @JoinColumn()
public app: App | null; public app: App | null;
@Column('varchar', {
length: 128,
nullable: true,
default: null
})
public name: string | null;
@Column('varchar', {
length: 512,
nullable: true,
default: null
})
public description: string | null;
@Column('varchar', {
length: 512,
nullable: true,
default: null
})
public iconUrl: string | null;
@Column('varchar', {
length: 64, array: true,
default: '{}'
})
public permission: string[];
@Column('boolean', {
default: false
})
public fetched: boolean;
} }

View File

@ -4,6 +4,7 @@ import { id } from '../id';
import { Note } from './note'; import { Note } from './note';
import { FollowRequest } from './follow-request'; import { FollowRequest } from './follow-request';
import { UserGroupInvitation } from './user-group-invitation'; import { UserGroupInvitation } from './user-group-invitation';
import { AccessToken } from './access-token';
@Entity() @Entity()
export class Notification { export class Notification {
@ -35,11 +36,13 @@ export class Notification {
/** /**
* (initiator) * (initiator)
*/ */
@Index()
@Column({ @Column({
...id(), ...id(),
nullable: true,
comment: 'The ID of sender user of the Notification.' comment: 'The ID of sender user of the Notification.'
}) })
public notifierId: User['id']; public notifierId: User['id'] | null;
@ManyToOne(type => User, { @ManyToOne(type => User, {
onDelete: 'CASCADE' onDelete: 'CASCADE'
@ -59,16 +62,19 @@ export class Notification {
* receiveFollowRequest - * receiveFollowRequest -
* followRequestAccepted - * followRequestAccepted -
* groupInvited - * groupInvited -
* app -
*/ */
@Index()
@Column('enum', { @Column('enum', {
enum: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited'], enum: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'],
comment: 'The type of the Notification.' comment: 'The type of the Notification.'
}) })
public type: 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollVote' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited'; public type: 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollVote' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'app';
/** /**
* *
*/ */
@Index()
@Column('boolean', { @Column('boolean', {
default: false, default: false,
comment: 'Whether the Notification is read.' comment: 'Whether the Notification is read.'
@ -114,10 +120,52 @@ export class Notification {
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true length: 128, nullable: true
}) })
public reaction: string; public reaction: string | null;
@Column('integer', { @Column('integer', {
nullable: true nullable: true
}) })
public choice: number; public choice: number | null;
/**
* body
*/
@Column('varchar', {
length: 2048, nullable: true
})
public customBody: string | null;
/**
* header
* ()
*/
@Column('varchar', {
length: 256, nullable: true
})
public customHeader: string | null;
/**
* icon(URL)
* ()
*/
@Column('varchar', {
length: 1024, nullable: true
})
public customIcon: string | null;
/**
* ()
*/
@Index()
@Column({
...id(),
nullable: true
})
public appAccessTokenId: AccessToken['id'] | null;
@ManyToOne(type => AccessToken, {
onDelete: 'CASCADE'
})
@JoinColumn()
public appAccessToken: AccessToken | null;
} }

View File

@ -1,5 +1,5 @@
import { EntityRepository, Repository } from 'typeorm'; import { EntityRepository, Repository } from 'typeorm';
import { Users, Notes, UserGroupInvitations } from '..'; import { Users, Notes, UserGroupInvitations, AccessTokens } from '..';
import { Notification } from '../entities/notification'; import { Notification } from '../entities/notification';
import { ensure } from '../../prelude/ensure'; import { ensure } from '../../prelude/ensure';
import { awaitAll } from '../../prelude/await-all'; import { awaitAll } from '../../prelude/await-all';
@ -13,13 +13,14 @@ export class NotificationRepository extends Repository<Notification> {
src: Notification['id'] | Notification, src: Notification['id'] | Notification,
): Promise<PackedNotification> { ): Promise<PackedNotification> {
const notification = typeof src === 'object' ? src : await this.findOne(src).then(ensure); const notification = typeof src === 'object' ? src : await this.findOne(src).then(ensure);
const token = notification.appAccessTokenId ? await AccessTokens.findOne(notification.appAccessTokenId).then(ensure) : null;
return await awaitAll({ return await awaitAll({
id: notification.id, id: notification.id,
createdAt: notification.createdAt.toISOString(), createdAt: notification.createdAt.toISOString(),
type: notification.type, type: notification.type,
userId: notification.notifierId, userId: notification.notifierId,
user: Users.pack(notification.notifier || notification.notifierId), user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null,
...(notification.type === 'mention' ? { ...(notification.type === 'mention' ? {
note: Notes.pack(notification.note || notification.noteId!, notification.notifieeId), note: Notes.pack(notification.note || notification.noteId!, notification.notifieeId),
} : {}), } : {}),
@ -43,6 +44,11 @@ export class NotificationRepository extends Repository<Notification> {
...(notification.type === 'groupInvited' ? { ...(notification.type === 'groupInvited' ? {
invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!),
} : {}), } : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader || token!.name,
icon: notification.customIcon || token!.iconUrl,
} : {}),
}); });
} }

View File

@ -1,9 +1,10 @@
import isNativeToken from './common/is-native-token'; import isNativeToken from './common/is-native-token';
import { User } from '../../models/entities/user'; import { User } from '../../models/entities/user';
import { App } from '../../models/entities/app';
import { Users, AccessTokens, Apps } from '../../models'; import { Users, AccessTokens, Apps } from '../../models';
import { ensure } from '../../prelude/ensure';
import { AccessToken } from '../../models/entities/access-token';
export default async (token: string): Promise<[User | null | undefined, App | null | undefined]> => { export default async (token: string): Promise<[User | null | undefined, AccessToken | null | undefined]> => {
if (token == null) { if (token == null) {
return [null, null]; return [null, null];
} }
@ -27,14 +28,25 @@ export default async (token: string): Promise<[User | null | undefined, App | nu
throw new Error('invalid signature'); throw new Error('invalid signature');
} }
const app = await Apps AccessTokens.update(accessToken.id, {
.findOne(accessToken.appId); lastUsedAt: new Date(),
});
const user = await Users const user = await Users
.findOne({ .findOne({
id: accessToken.userId // findOne(accessToken.userId) のように書かないのは後方互換性のため id: accessToken.userId // findOne(accessToken.userId) のように書かないのは後方互換性のため
}); });
return [user, app]; if (accessToken.appId) {
const app = await Apps
.findOne(accessToken.appId).then(ensure);
return [user, {
id: accessToken.id,
permission: app.permission
} as AccessToken];
} else {
return [user, accessToken];
}
} }
}; };

View File

@ -4,7 +4,7 @@ import { User } from '../../models/entities/user';
import endpoints from './endpoints'; import endpoints from './endpoints';
import { ApiError } from './error'; import { ApiError } from './error';
import { apiLogger } from './logger'; import { apiLogger } from './logger';
import { App } from '../../models/entities/app'; import { AccessToken } from '../../models/entities/access-token';
const accessDenied = { const accessDenied = {
message: 'Access denied.', message: 'Access denied.',
@ -12,8 +12,8 @@ const accessDenied = {
id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e' id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e'
}; };
export default async (endpoint: string, user: User | null | undefined, app: App | null | undefined, data: any, file?: any) => { export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, file?: any) => {
const isSecure = user != null && app == null; const isSecure = user != null && token == null;
const ep = endpoints.find(e => e.name === endpoint); const ep = endpoints.find(e => e.name === endpoint);
@ -51,7 +51,7 @@ export default async (endpoint: string, user: User | null | undefined, app: App
throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); throw new ApiError(accessDenied, { reason: 'You are not a moderator.' });
} }
if (app && ep.meta.kind && !app.permission.some(p => p === ep.meta.kind)) { if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) {
throw new ApiError({ throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.', message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED', code: 'PERMISSION_DENIED',
@ -73,7 +73,7 @@ export default async (endpoint: string, user: User | null | undefined, app: App
// API invoking // API invoking
const before = performance.now(); const before = performance.now();
return await ep.exec(data, user, app, file).catch((e: Error) => { return await ep.exec(data, user, token, file).catch((e: Error) => {
if (e instanceof ApiError) { if (e instanceof ApiError) {
throw e; throw e;
} else { } else {

View File

@ -2,8 +2,8 @@ import * as fs from 'fs';
import { ILocalUser } from '../../models/entities/user'; import { ILocalUser } from '../../models/entities/user';
import { IEndpointMeta } from './endpoints'; import { IEndpointMeta } from './endpoints';
import { ApiError } from './error'; import { ApiError } from './error';
import { App } from '../../models/entities/app';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
import { AccessToken } from '../../models/entities/access-token';
// TODO: defaultが設定されている場合はその型も考慮する // TODO: defaultが設定されている場合はその型も考慮する
type Params<T extends IEndpointMeta> = { type Params<T extends IEndpointMeta> = {
@ -15,12 +15,12 @@ type Params<T extends IEndpointMeta> = {
export type Response = Record<string, any> | void; export type Response = Record<string, any> | void;
type executor<T extends IEndpointMeta> = type executor<T extends IEndpointMeta> =
(params: Params<T>, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any, cleanup?: Function) => (params: Params<T>, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, token: AccessToken, file?: any, cleanup?: Function) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>) export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>)
: (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any) => Promise<any> { : (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, token: AccessToken, file?: any) => Promise<any> {
return (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any) => { return (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, token: AccessToken, file?: any) => {
function cleanup() { function cleanup() {
fs.unlink(file.path, () => {}); fs.unlink(file.path, () => {});
} }
@ -37,7 +37,7 @@ export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>)
return Promise.reject(pserr); return Promise.reject(pserr);
} }
return cb(ps, user, app, file, cleanup); return cb(ps, user, token, file, cleanup);
}; };
} }

View File

@ -28,8 +28,8 @@ export const meta = {
} }
}; };
export default define(meta, async (ps, user, app) => { export default define(meta, async (ps, user, token) => {
const isSecure = user != null && app == null; const isSecure = token == null;
// Lookup app // Lookup app
const ap = await Apps.findOne(ps.appId); const ap = await Apps.findOne(ps.appId);

View File

@ -78,7 +78,7 @@ export const meta = {
} }
}; };
export default define(meta, async (ps, user, app, file, cleanup) => { export default define(meta, async (ps, user, _, file, cleanup) => {
// Get 'name' parameter // Get 'name' parameter
let name = ps.name || file.originalname; let name = ps.name || file.originalname;
if (name !== undefined && name !== null) { if (name !== undefined && name !== null) {

View File

@ -19,8 +19,8 @@ export const meta = {
}, },
}; };
export default define(meta, async (ps, user, app) => { export default define(meta, async (ps, user, token) => {
const isSecure = user != null && app == null; const isSecure = token == null;
return await Users.pack(user, user, { return await Users.pack(user, user, {
detail: true, detail: true,

View File

@ -0,0 +1,41 @@
import $ from 'cafy';
import define from '../../define';
import { AccessTokens } from '../../../../models';
export const meta = {
requireCredential: true as const,
secure: true,
params: {
sort: {
validator: $.optional.str.or([
'+createdAt',
'-createdAt',
'+lastUsedAt',
'-lastUsedAt',
]),
},
}
};
export default define(meta, async (ps, user) => {
const query = AccessTokens.createQueryBuilder('token');
switch (ps.sort) {
case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break;
case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break;
case '+lastUsedAt': query.andWhere('token.lastUsedAt IS NOT NULL').orderBy('token.lastUsedAt', 'DESC'); break;
case '-lastUsedAt': query.andWhere('token.lastUsedAt IS NOT NULL').orderBy('token.lastUsedAt', 'ASC'); break;
default: query.orderBy('token.id', 'ASC'); break;
}
const tokens = await query.getMany();
return await Promise.all(tokens.map(token => ({
id: token.id,
name: token.name,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
})));
});

View File

@ -0,0 +1,24 @@
import $ from 'cafy';
import define from '../../define';
import { AccessTokens } from '../../../../models';
import { ID } from '../../../../misc/cafy-id';
export const meta = {
requireCredential: true as const,
secure: true,
params: {
tokenId: {
validator: $.type(ID)
}
}
};
export default define(meta, async (ps, user) => {
const token = await AccessTokens.findOne(ps.tokenId);
if (token) {
AccessTokens.delete(token.id);
}
});

View File

@ -178,8 +178,8 @@ export const meta = {
} }
}; };
export default define(meta, async (ps, user, app) => { export default define(meta, async (ps, user, token) => {
const isSecure = user != null && app == null; const isSecure = token == null;
const updates = {} as Partial<User>; const updates = {} as Partial<User>;
const profileUpdates = {} as Partial<UserProfile>; const profileUpdates = {} as Partial<UserProfile>;

View File

@ -0,0 +1,55 @@
import rndstr from 'rndstr';
import $ from 'cafy';
import define from '../../define';
import { AccessTokens } from '../../../../models';
import { genId } from '../../../../misc/gen-id';
export const meta = {
tags: ['auth'],
requireCredential: true as const,
secure: true,
params: {
session: {
validator: $.str
},
name: {
validator: $.nullable.optional.str
},
description: {
validator: $.nullable.optional.str,
},
iconUrl: {
validator: $.nullable.optional.str,
},
permission: {
validator: $.arr($.str).unique(),
},
},
};
export default define(meta, async (ps, user) => {
// Generate access token
const accessToken = rndstr('a-zA-Z0-9', 32);
// Insert access token doc
await AccessTokens.save({
id: genId(),
createdAt: new Date(),
lastUsedAt: new Date(),
session: ps.session,
userId: user.id,
token: accessToken,
hash: accessToken,
name: ps.name,
description: ps.description,
iconUrl: ps.iconUrl,
permission: ps.permission,
});
});

View File

@ -209,7 +209,7 @@ export const meta = {
} }
}; };
export default define(meta, async (ps, user, app) => { export default define(meta, async (ps, user) => {
let visibleUsers: User[] = []; let visibleUsers: User[] = [];
if (ps.visibleUserIds) { if (ps.visibleUserIds) {
visibleUsers = (await Promise.all(ps.visibleUserIds.map(id => Users.findOne(id)))) visibleUsers = (await Promise.all(ps.visibleUserIds.map(id => Users.findOne(id))))
@ -281,7 +281,6 @@ export default define(meta, async (ps, user, app) => {
reply, reply,
renote, renote,
cw: ps.cw, cw: ps.cw,
app,
viaMobile: ps.viaMobile, viaMobile: ps.viaMobile,
localOnly: ps.localOnly, localOnly: ps.localOnly,
visibility: ps.visibility, visibility: ps.visibility,

View File

@ -132,7 +132,8 @@ export default define(meta, async (ps, user) => {
}); });
// Notify // Notify
createNotification(note.userId, user.id, 'pollVote', { createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
choice: ps.choice choice: ps.choice
}); });
@ -143,7 +144,8 @@ export default define(meta, async (ps, user) => {
userId: Not(user.id), userId: Not(user.id),
}).then(watchers => { }).then(watchers => {
for (const watcher of watchers) { for (const watcher of watchers) {
createNotification(watcher.userId, user.id, 'pollVote', { createNotification(watcher.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
choice: ps.choice choice: ps.choice
}); });

View File

@ -0,0 +1,37 @@
import $ from 'cafy';
import define from '../../define';
import { createNotification } from '../../../../services/create-notification';
export const meta = {
tags: ['notifications'],
requireCredential: true as const,
kind: 'write:notifications',
params: {
body: {
validator: $.str
},
header: {
validator: $.optional.nullable.str
},
icon: {
validator: $.optional.nullable.str
},
},
errors: {
}
};
export default define(meta, async (ps, user, token) => {
createNotification(user.id, 'app', {
appAccessTokenId: token.id,
customBody: ps.body,
customHeader: ps.header,
customIcon: ps.icon,
});
});

View File

@ -104,7 +104,8 @@ export default define(meta, async (ps, me) => {
} as UserGroupInvitation); } as UserGroupInvitation);
// 通知を作成 // 通知を作成
createNotification(user.id, me.id, 'groupInvited', { createNotification(user.id, 'groupInvited', {
notifierId: me.id,
userGroupInvitationId: invitation.id userGroupInvitationId: invitation.id
}); });
}); });

View File

@ -15,7 +15,7 @@ import signin from './private/signin';
import discord from './service/discord'; import discord from './service/discord';
import github from './service/github'; import github from './service/github';
import twitter from './service/twitter'; import twitter from './service/twitter';
import { Instances } from '../../models'; import { Instances, AccessTokens, Users } from '../../models';
// Init app // Init app
const app = new Koa(); const app = new Koa();
@ -73,6 +73,28 @@ router.get('/v1/instance/peers', async ctx => {
ctx.body = instances.map(instance => instance.host); ctx.body = instances.map(instance => instance.host);
}); });
router.post('/miauth/:session/check', async ctx => {
const token = await AccessTokens.findOne({
session: ctx.params.session
});
if (token && !token.fetched) {
AccessTokens.update(token.id, {
fetched: true
});
ctx.body = {
ok: true,
token: token.token,
user: await Users.pack(token.userId, null, { detail: true })
};
} else {
ctx.body = {
ok: false,
};
}
});
// Return 404 for unknown API // Return 404 for unknown API
router.all('*', async ctx => { router.all('*', async ctx => {
ctx.status = 404; ctx.status = 404;

View File

@ -42,52 +42,7 @@ export function getDescription(lang = 'ja-JP'): string {
.join('\n'); .join('\n');
const descriptions = { const descriptions = {
'ja-JP': `**Misskey is a decentralized microblogging platform.** 'ja-JP': `
# Usage
**APIはすべてPOSTでリクエスト/JSON形式です**
APIはリクエストに認証情報(APIキー)\`i\`というパラメータでAPIキーを添付してください。
## APIキーを取得する
> APIAPIキーを取得できます
> ()
## APIキーを取得する
APIキーをアプリケーションが扱うのはセキュリティ上のリスクがあるので
APIを利用する際にはAPIキーを発行します
### 1.
Webサービス()Misskeyに登録します
[](/dev) >
使
> </p>
### 2.
使
[${config.apiUrl}/auth/session/generate](#operation/auth/session/generate) \`appSecret\`としてシークレットキーを含めたリクエストを送信します。
URLが取得できるのでURLをブラウザで表示し
URLを設定している場合
URLに\`token\`という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。
URLを設定していない場合(())
### 3.
[${config.apiUrl}/auth/session/userkey](#operation/auth/session/userkey)
*+sha256したもの*APIキーとしてAPIにリクエストできます
APIキーの生成方法を擬似コードで表すと次のようになります:
\`\`\` js
const i = sha256(userToken + secretKey);
\`\`\`
# Permissions # Permissions
|Permisson (kind)|Description|Endpoints| |Permisson (kind)|Description|Endpoints|
|:--|:--|:--| |:--|:--|:--|

View File

@ -7,9 +7,9 @@ import Channel from './channel';
import channels from './channels'; import channels from './channels';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { User } from '../../../models/entities/user'; import { User } from '../../../models/entities/user';
import { App } from '../../../models/entities/app';
import { Users, Followings, Mutings } from '../../../models'; import { Users, Followings, Mutings } from '../../../models';
import { ApiError } from '../error'; import { ApiError } from '../error';
import { AccessToken } from '../../../models/entities/access-token';
/** /**
* Main stream connection * Main stream connection
@ -18,7 +18,7 @@ export default class Connection {
public user?: User; public user?: User;
public following: User['id'][] = []; public following: User['id'][] = [];
public muting: User['id'][] = []; public muting: User['id'][] = [];
public app: App; public token: AccessToken;
private wsConnection: websocket.connection; private wsConnection: websocket.connection;
public subscriber: EventEmitter; public subscriber: EventEmitter;
private channels: Channel[] = []; private channels: Channel[] = [];
@ -30,12 +30,12 @@ export default class Connection {
wsConnection: websocket.connection, wsConnection: websocket.connection,
subscriber: EventEmitter, subscriber: EventEmitter,
user: User | null | undefined, user: User | null | undefined,
app: App | null | undefined token: AccessToken | null | undefined
) { ) {
this.wsConnection = wsConnection; this.wsConnection = wsConnection;
this.subscriber = subscriber; this.subscriber = subscriber;
if (user) this.user = user; if (user) this.user = user;
if (app) this.app = app; if (token) this.token = token;
this.wsConnection.on('message', this.onWsConnectionMessage); this.wsConnection.on('message', this.onWsConnectionMessage);
@ -83,7 +83,7 @@ export default class Connection {
const endpoint = payload.endpoint || payload.ep; // alias const endpoint = payload.endpoint || payload.ep; // alias
// 呼び出し // 呼び出し
call(endpoint, user, this.app, payload.data).then(res => { call(endpoint, user, this.token, payload.data).then(res => {
this.sendMessageToWs(`api:${payload.id}`, { res }); this.sendMessageToWs(`api:${payload.id}`, { res });
}).catch((e: ApiError) => { }).catch((e: ApiError) => {
this.sendMessageToWs(`api:${payload.id}`, { this.sendMessageToWs(`api:${payload.id}`, {

View File

@ -3,46 +3,26 @@ import pushSw from './push-notification';
import { Notifications, Mutings } from '../models'; import { Notifications, Mutings } from '../models';
import { genId } from '../misc/gen-id'; import { genId } from '../misc/gen-id';
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
import { Note } from '../models/entities/note';
import { Notification } from '../models/entities/notification'; import { Notification } from '../models/entities/notification';
import { FollowRequest } from '../models/entities/follow-request';
import { UserGroupInvitation } from '../models/entities/user-group-invitation';
export async function createNotification( export async function createNotification(
notifieeId: User['id'], notifieeId: User['id'],
notifierId: User['id'],
type: Notification['type'], type: Notification['type'],
content?: { data: Partial<Notification>
noteId?: Note['id'];
reaction?: string;
choice?: number;
followRequestId?: FollowRequest['id'];
userGroupInvitationId?: UserGroupInvitation['id'];
}
) { ) {
if (notifieeId === notifierId) { if (data.notifierId && (notifieeId === data.notifierId)) {
return null; return null;
} }
const data = { // Create notification
const notification = await Notifications.save({
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
notifieeId: notifieeId, notifieeId: notifieeId,
notifierId: notifierId,
type: type, type: type,
isRead: false, isRead: false,
} as Partial<Notification>; ...data
} as Partial<Notification>);
if (content) {
if (content.noteId) data.noteId = content.noteId;
if (content.reaction) data.reaction = content.reaction;
if (content.choice) data.choice = content.choice;
if (content.followRequestId) data.followRequestId = content.followRequestId;
if (content.userGroupInvitationId) data.userGroupInvitationId = content.userGroupInvitationId;
}
// Create notification
const notification = await Notifications.save(data);
const packed = await Notifications.pack(notification); const packed = await Notifications.pack(notification);
@ -58,7 +38,7 @@ export async function createNotification(
const mutings = await Mutings.find({ const mutings = await Mutings.find({
muterId: notifieeId muterId: notifieeId
}); });
if (mutings.map(m => m.muteeId).includes(notifierId)) { if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
return; return;
} }
//#endregion //#endregion

View File

@ -57,7 +57,9 @@ export async function insertFollowingDoc(followee: User, follower: User) {
}); });
// 通知を作成 // 通知を作成
createNotification(follower.id, followee.id, 'followRequestAccepted'); createNotification(follower.id, 'followRequestAccepted', {
notifierId: followee.id,
});
} }
if (alreadyFollowed) return; if (alreadyFollowed) return;
@ -95,7 +97,9 @@ export async function insertFollowingDoc(followee: User, follower: User) {
Users.pack(follower, followee).then(packed => publishMainStream(followee.id, 'followed', packed)), Users.pack(follower, followee).then(packed => publishMainStream(followee.id, 'followed', packed)),
// 通知を作成 // 通知を作成
createNotification(followee.id, follower.id, 'follow'); createNotification(followee.id, 'follow', {
notifierId: follower.id
});
} }
} }

View File

@ -50,7 +50,8 @@ export default async function(follower: User, followee: User, requestId?: string
}).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => publishMainStream(followee.id, 'meUpdated', packed));
// 通知を作成 // 通知を作成
createNotification(followee.id, follower.id, 'receiveFollowRequest', { createNotification(followee.id, 'receiveFollowRequest', {
notifierId: follower.id,
followRequestId: followRequest.id followRequestId: followRequest.id
}); });
} }

View File

@ -54,6 +54,7 @@ export default class Logger {
private log(level: Level, message: string, data?: Record<string, any> | null, important = false, subDomains: Domain[] = [], store = true): void { private log(level: Level, message: string, data?: Record<string, any> | null, important = false, subDomains: Domain[] = [], store = true): void {
if (program.quiet) return; if (program.quiet) return;
if (!this.store) store = false; if (!this.store) store = false;
if (level === 'debug') store = false;
if (this.parentLogger) { if (this.parentLogger) {
this.parentLogger.log(level, message, data, important, [this.domain].concat(subDomains), store); this.parentLogger.log(level, message, data, important, [this.domain].concat(subDomains), store);

View File

@ -78,7 +78,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) { if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
createNotification(x.target, this.notifier.id, x.reason, { createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id noteId: this.note.id
}); });
} }

View File

@ -48,7 +48,8 @@ export default async function(user: User, note: Note, choice: number) {
}); });
// Notify // Notify
createNotification(note.userId, user.id, 'pollVote', { createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
choice: choice choice: choice
}); });
@ -60,7 +61,8 @@ export default async function(user: User, note: Note, choice: number) {
}) })
.then(watchers => { .then(watchers => {
for (const watcher of watchers) { for (const watcher of watchers) {
createNotification(watcher.userId, user.id, 'pollVote', { createNotification(watcher.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
choice: choice choice: choice
}); });

View File

@ -66,7 +66,8 @@ export default async (user: User, note: Note, reaction?: string) => {
// リアクションされたユーザーがローカルユーザーなら通知を作成 // リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) { if (note.userHost === null) {
createNotification(note.userId, user.id, 'reaction', { createNotification(note.userId, 'reaction', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
reaction: reaction reaction: reaction
}); });
@ -78,7 +79,8 @@ export default async (user: User, note: Note, reaction?: string) => {
userId: Not(user.id) userId: Not(user.id)
}).then(watchers => { }).then(watchers => {
for (const watcher of watchers) { for (const watcher of watchers) {
createNotification(watcher.userId, user.id, 'reaction', { createNotification(watcher.userId, 'reaction', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
reaction: reaction reaction: reaction
}); });

View File

@ -63,7 +63,7 @@ describe('Chart', () => {
after(async(async () => { after(async(async () => {
await connection.close(); await connection.close();
await initDb(true, undefined, undefined, true); await initDb(true, undefined, true);
})); }));
beforeEach(done => { beforeEach(done => {

View File

@ -6111,6 +6111,11 @@ map-visit@^1.0.0:
dependencies: dependencies:
object-visit "^1.0.0" object-visit "^1.0.0"
markdown-it-anchor@5.2.5:
version "5.2.5"
resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-5.2.5.tgz#dbf13cfcdbffd16a510984f1263e1d479a47d27a"
integrity sha512-xLIjLQmtym3QpoY9llBgApknl7pxAcN3WDRc2d3rwpl+/YvDZHPmKscGs+L6E05xf2KrCXPBvosWt7MZukwSpQ==
markdown-it@10.0.0: markdown-it@10.0.0:
version "10.0.0" version "10.0.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc"