From 18e1efc7ecd3f5a6d774c16f17526d12ae46b2f5 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 4 May 2021 21:15:57 +0900
Subject: [PATCH] Ad (#7495)

* wip

* Update ad.vue

* Update default.widgets.vue

* wip

* Create 1620019354680-ad.ts

* wip

* Update ads.vue

* wip

* Update ad.vue
---
 locales/ja-JP.yml                             |   7 +
 migration/1620019354680-ad.ts                 |  18 +++
 package.json                                  |   1 -
 src/client/components/date-separated-list.vue |  22 ++-
 src/client/components/global/ad.vue           | 142 ++++++++++++++++++
 src/client/components/index.ts                |   4 +-
 src/client/components/notes.vue               |   2 +-
 src/client/pages/gallery/post.vue             |   1 +
 src/client/pages/instance/ads.vue             | 125 +++++++++++++++
 src/client/pages/instance/index.vue           |   2 +
 src/client/pages/page.vue                     |   1 +
 src/client/scripts/paging.ts                  |   8 +-
 src/client/style.scss                         |   2 +
 src/client/ui/chat/date-separated-list.vue    |   6 +-
 src/client/ui/default.widgets.vue             |   1 +
 src/db/postgre.ts                             |   2 +
 src/models/entities/ad.ts                     |  53 +++++++
 src/models/index.ts                           |   2 +
 src/models/repositories/note.ts               |  11 +-
 src/server/api/endpoints/admin/ad/create.ts   |  45 ++++++
 src/server/api/endpoints/admin/ad/delete.ts   |  34 +++++
 src/server/api/endpoints/admin/ad/list.ts     |  36 +++++
 src/server/api/endpoints/admin/ad/update.ts   |  59 ++++++++
 src/server/api/endpoints/meta.ts              |  39 ++++-
 24 files changed, 596 insertions(+), 27 deletions(-)
 create mode 100644 migration/1620019354680-ad.ts
 create mode 100644 src/client/components/global/ad.vue
 create mode 100644 src/client/pages/instance/ads.vue
 create mode 100644 src/models/entities/ad.ts
 create mode 100644 src/server/api/endpoints/admin/ad/create.ts
 create mode 100644 src/server/api/endpoints/admin/ad/delete.ts
 create mode 100644 src/server/api/endpoints/admin/ad/list.ts
 create mode 100644 src/server/api/endpoints/admin/ad/update.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5a3f40a6dc..0f786a6b14 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -748,6 +748,13 @@ gallery: "ギャラリー"
 recentPosts: "最近の投稿"
 popularPosts: "人気の投稿"
 shareWithNote: "ノートで共有"
+ads: "広告"
+expiration: "期限"
+memo: "メモ"
+priority: "優先度"
+high: "高"
+middle: "中"
+low: "低"
 emailNotConfiguredWarning: "メールアドレスの設定がされていません。"
 
 _forgotPassword:
diff --git a/migration/1620019354680-ad.ts b/migration/1620019354680-ad.ts
new file mode 100644
index 0000000000..27fb99f181
--- /dev/null
+++ b/migration/1620019354680-ad.ts
@@ -0,0 +1,18 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class ad1620019354680 implements MigrationInterface {
+    name = 'ad1620019354680'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TABLE "ad" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "place" character varying(32) NOT NULL, "priority" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "imageUrl" character varying(1024) NOT NULL, "memo" character varying(8192) NOT NULL, CONSTRAINT "PK_0193d5ef09746e88e9ea92c634d" PRIMARY KEY ("id")); COMMENT ON COLUMN "ad"."createdAt" IS 'The created date of the Ad.'; COMMENT ON COLUMN "ad"."expiresAt" IS 'The expired date of the Ad.'`);
+        await queryRunner.query(`CREATE INDEX "IDX_1129c2ef687fc272df040bafaa" ON "ad" ("createdAt") `);
+        await queryRunner.query(`CREATE INDEX "IDX_2da24ce20ad209f1d9dc032457" ON "ad" ("expiresAt") `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP INDEX "IDX_2da24ce20ad209f1d9dc032457"`);
+        await queryRunner.query(`DROP INDEX "IDX_1129c2ef687fc272df040bafaa"`);
+        await queryRunner.query(`DROP TABLE "ad"`);
+    }
+
+}
diff --git a/package.json b/package.json
index 25ebacaa7c..9a14373667 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,6 @@
 	"resolutions": {
 		"chokidar": "^3.3.1",
 		"constantinople": "^4.0.1",
-		"gulp/gulp-cli/yargs/yargs-parser": "5.0.0-security.0",
 		"jsonld/rdf-canonize/node-forge": "0.10.0",
 		"lodash": "^4.17.20"
 	},
diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue
index 2a861adb09..d458a0eeb8 100644
--- a/src/client/components/date-separated-list.vue
+++ b/src/client/components/date-separated-list.vue
@@ -1,5 +1,6 @@
 <script lang="ts">
 import { defineComponent, h, TransitionGroup } from 'vue';
+import MkAd from '@client/components/global/ad.vue';
 
 export default defineComponent({
 	props: {
@@ -22,6 +23,11 @@ export default defineComponent({
 			required: false,
 			default: false
 		},
+		ad: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
 	},
 
 	methods: {
@@ -58,11 +64,7 @@ export default defineComponent({
 
 			if (
 				i != this.items.length - 1 &&
-				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
-				!item._prId_ &&
-				!this.items[i + 1]._prId_ &&
-				!item._featuredId_ &&
-				!this.items[i + 1]._featuredId_
+				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
 			) {
 				const separator = h('div', {
 					class: 'separator',
@@ -86,7 +88,15 @@ export default defineComponent({
 
 				return [el, separator];
 			} else {
-				return el;
+				if (this.ad && item._shouldInsertAd_) {
+					return [h(MkAd, {
+						class: 'ad',
+						key: item.id + ':ad',
+						prefer: 'horizontal',
+					}), el];
+				} else {
+					return el;
+				}
 			}
 		}));
 	},
diff --git a/src/client/components/global/ad.vue b/src/client/components/global/ad.vue
new file mode 100644
index 0000000000..00592e4ca2
--- /dev/null
+++ b/src/client/components/global/ad.vue
@@ -0,0 +1,142 @@
+<template>
+<div class="qiivuoyo" v-if="ad">
+	<div class="main" :class="ad.place" v-if="!showMenu">
+		<a :href="ad.url" target="_blank">
+			<img :src="ad.imageUrl">
+			<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
+		</a>
+	</div>
+	<div class="menu" v-else>
+		<div class="body">
+			<div>Ads by {{ host }}</div>
+			<!--<MkButton>{{ $ts.stopThisAd }}</MkButton>-->
+			<button class="_textButton" @click="toggleMenu">{{ $ts.close }}</button>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { instance } from '@client/instance';
+import { host } from '@client/config';
+import MkButton from '@client/components/ui/button.vue';
+
+export default defineComponent({
+	components: {
+		MkButton
+	},
+
+	props: {
+		prefer: {
+			type: String,
+			required: true
+		},
+		ad: {
+			type: Object,
+			required: false
+		},
+	},
+
+	setup(props) {
+		const showMenu = ref(false);
+		const toggleMenu = () => {
+			showMenu.value = !showMenu.value;
+		};
+
+		let ad = null;
+
+		if (props.ad) {
+			ad = props.ad;
+		} else {
+			let ads = instance.ads.filter(ad => ad.place === props.prefer);
+
+			if (ads.length === 0) {
+				ads = instance.ads.filter(ad => ad.place === 'square');
+			}
+
+			const high = ads.filter(ad => ad.priority === 'high');
+			const middle = ads.filter(ad => ad.priority === 'middle');
+			const low = ads.filter(ad => ad.priority === 'low');
+
+			if (high.length > 0) {
+				ad = high[Math.floor(Math.random() * high.length)];
+			} else if (middle.length > 0) {
+				ad = middle[Math.floor(Math.random() * middle.length)];
+			} else if (low.length > 0) {
+				ad = low[Math.floor(Math.random() * low.length)];
+			}
+		}
+
+		return {
+			ad,
+			showMenu,
+			toggleMenu,
+			host,
+		};
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+	background-size: auto auto;
+	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+	> .main {
+		> a {
+			display: block;
+			position: relative;
+			margin: 0 auto;
+
+			> img {
+				display: block;
+				width: 100%;
+				height: 100%;
+				object-fit: contain;
+			}
+
+			> .menu {
+				position: absolute;
+				top: 0;
+				right: 0;
+				background: var(--panel);
+			}
+		}
+
+		&.square {
+			> a {
+				max-width: min(300px, 100%);
+				max-height: min(300px, 100%);
+			}
+		}
+
+		&.horizontal {
+			padding: 8px;
+
+			> a {
+				max-width: min(600px, 100%);
+				max-height: min(100px, 100%);
+			}
+		}
+
+		&.vertical {
+			> a {
+				max-width: min(100px, 100%);
+			}
+		}
+	}
+
+	> .menu {
+		padding: 8px;
+		text-align: center;
+
+		> .body {
+			padding: 8px;
+			margin: 0 auto;
+			max-width: 400px;
+			border: solid 1px var(--divider);
+		}
+	}
+}
+</style>
diff --git a/src/client/components/index.ts b/src/client/components/index.ts
index 0630ed3d8c..8b914c5eec 100644
--- a/src/client/components/index.ts
+++ b/src/client/components/index.ts
@@ -12,8 +12,10 @@ import url from './global/url.vue';
 import i18n from './global/i18n';
 import loading from './global/loading.vue';
 import error from './global/error.vue';
+import ad from './global/ad.vue';
 
 export default function(app: App) {
+	app.component('I18n', i18n);
 	app.component('Mfm', mfm);
 	app.component('MkA', a);
 	app.component('MkAcct', acct);
@@ -25,5 +27,5 @@ export default function(app: App) {
 	app.component('MkUrl', url);
 	app.component('MkLoading', loading);
 	app.component('MkError', error);
-	app.component('I18n', i18n);
+	app.component('MkAd', ad);
 }
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index 675748d540..e90102921a 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -17,7 +17,7 @@
 			</MkButton>
 		</div>
 
-		<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap">
+		<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true">
 			<XNote :note="note" class="_block" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
 		</XList>
 
diff --git a/src/client/pages/gallery/post.vue b/src/client/pages/gallery/post.vue
index 703506a78d..50f81376ec 100644
--- a/src/client/pages/gallery/post.vue
+++ b/src/client/pages/gallery/post.vue
@@ -33,6 +33,7 @@
 					<MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
 				</div>
 			</div>
+			<MkAd prefer="horizontal"/>
 			<MkContainer :max-height="300" :foldable="true" class="other">
 				<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
 				<MkPagination :pagination="otherPostsPagination" #default="{items}">
diff --git a/src/client/pages/instance/ads.vue b/src/client/pages/instance/ads.vue
new file mode 100644
index 0000000000..4297e56c37
--- /dev/null
+++ b/src/client/pages/instance/ads.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="uqshojas">
+	<MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+	<section class="_card _gap ads" v-for="ad in ads">
+		<div class="_content ad">
+			<MkAd v-if="ad.url" :ad="ad"/>
+			<MkInput v-model:value="ad.url" type="url">
+				<span>URL</span>
+			</MkInput>
+			<MkInput v-model:value="ad.imageUrl">
+				<span>{{ $ts.imageUrl }}</span>
+			</MkInput>
+			<div style="margin: 32px 0;">
+				<MkRadio v-model="ad.place" value="square">square</MkRadio>
+				<MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
+			</div>
+			<div style="margin: 32px 0;">
+				{{ $ts.priority }}
+				<MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
+				<MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
+				<MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
+			</div>
+			<MkInput v-model:value="ad.expiresAt" type="date">
+				<span>{{ $ts.expiration }}</span>
+			</MkInput>
+			<MkTextarea v-model:value="ad.memo">
+				<span>{{ $ts.memo }}</span>
+			</MkTextarea>
+			<div class="buttons">
+				<MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+				<MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+			</div>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@client/components/ui/button.vue';
+import MkInput from '@client/components/ui/input.vue';
+import MkTextarea from '@client/components/ui/textarea.vue';
+import MkRadio from '@client/components/ui/radio.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkInput,
+		MkTextarea,
+		MkRadio,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			[symbols.PAGE_INFO]: {
+				title: this.$ts.ads,
+				icon: 'fas fa-audio-description'
+			},
+			ads: [],
+		}
+	},
+
+	created() {
+		os.api('admin/ad/list').then(ads => {
+			this.ads = ads;
+		});
+	},
+
+	mounted() {
+		this.$emit('info', this[symbols.PAGE_INFO]);
+	},
+
+	methods: {
+		add() {
+			this.ads.unshift({
+				id: null,
+				memo: '',
+				place: 'square',
+				priority: 'middle',
+				url: '',
+				imageUrl: null,
+				expiresAt: null,
+			});
+		},
+
+		remove(ad) {
+			os.dialog({
+				type: 'warning',
+				text: this.$t('removeAreYouSure', { x: ad.url }),
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+				this.ads = this.ads.filter(x => x != ad);
+				os.apiWithDialog('admin/ad/delete', {
+					id: ad.id
+				});
+			});
+		},
+
+		save(ad) {
+			if (ad.id == null) {
+				os.apiWithDialog('admin/ad/create', {
+					...ad,
+					expiresAt: new Date(ad.expiresAt).getTime()
+				});
+			} else {
+				os.apiWithDialog('admin/ad/update', {
+					...ad,
+					expiresAt: new Date(ad.expiresAt).getTime()
+				});
+			}
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+	margin: var(--margin);
+}
+</style>
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
index 5972a02de0..974c4345bb 100644
--- a/src/client/pages/instance/index.vue
+++ b/src/client/pages/instance/index.vue
@@ -23,6 +23,7 @@
 				<FormLink :active="page === 'queue'" replace to="/instance/queue"><template #icon><i class="fas fa-clipboard-list"></i></template>{{ $ts.jobQueue }}</FormLink>
 				<FormLink :active="page === 'files'" replace to="/instance/files"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
 				<FormLink :active="page === 'announcements'" replace to="/instance/announcements"><template #icon><i class="fas fa-broadcast-tower"></i></template>{{ $ts.announcements }}</FormLink>
+				<FormLink :active="page === 'ads'" replace to="/instance/ads"><template #icon><i class="fas fa-audio-description"></i></template>{{ $ts.ads }}</FormLink>
 				<FormLink :active="page === 'abuses'" replace to="/instance/abuses"><template #icon><i class="fas fa-exclamation-circle"></i></template>{{ $ts.abuseReports }}</FormLink>
 			</FormGroup>
 			<FormGroup>
@@ -102,6 +103,7 @@ export default defineComponent({
 				case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
 				case 'files': return defineAsyncComponent(() => import('./files.vue'));
 				case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+				case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
 				case 'database': return defineAsyncComponent(() => import('./database.vue'));
 				case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
 				case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
diff --git a/src/client/pages/page.vue b/src/client/pages/page.vue
index 6ee3ee8d26..4e237c2186 100644
--- a/src/client/pages/page.vue
+++ b/src/client/pages/page.vue
@@ -45,6 +45,7 @@
 				<div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
 				<div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
 			</div>
+			<MkAd prefer="horizontal"/>
 			<MkContainer :max-height="300" :foldable="true" class="other">
 				<template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
 				<MkPagination :pagination="otherPostsPagination" #default="{items}">
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
index 2e49f1a64c..bcb0d7f2b0 100644
--- a/src/client/scripts/paging.ts
+++ b/src/client/scripts/paging.ts
@@ -91,8 +91,10 @@ export default (opts) => ({
 				...params,
 				limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
 			}).then(items => {
-				for (const item of items) {
+				for (let i = 0; i < items.length; i++) {
+					const item = items[i];
 					markRaw(item);
+					if (i === 3) item._shouldInsertAd_ = true;
 				}
 				if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
 					items.pop();
@@ -128,8 +130,10 @@ export default (opts) => ({
 					untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
 				}),
 			}).then(items => {
-				for (const item of items) {
+				for (let i = 0; i < items.length; i++) {
+					const item = items[i];
 					markRaw(item);
+					if (i === 10) item._shouldInsertAd_ = true;
 				}
 				if (items.length > SECOND_FETCH_LIMIT) {
 					items.pop();
diff --git a/src/client/style.scss b/src/client/style.scss
index 523ab13034..39bf6ef2d5 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -11,6 +11,8 @@
 	@media (max-width: 500px) {
 		--margin: var(--marginHalf);
 	}
+
+	//--ad: rgb(255 169 0 / 10%);
 }
 
 ::selection {
diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue
index b073a38eb1..bc7fc91d38 100644
--- a/src/client/ui/chat/date-separated-list.vue
+++ b/src/client/ui/chat/date-separated-list.vue
@@ -42,11 +42,7 @@ export default defineComponent({
 
 			if (
 				i != this.items.length - 1 &&
-				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
-				!item._prId_ &&
-				!this.items[i + 1]._prId_ &&
-				!item._featuredId_ &&
-				!this.items[i + 1]._featuredId_
+				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
 			) {
 				const separator = h('div', {
 					class: 'separator',
diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue
index cabd83937e..0dd073409b 100644
--- a/src/client/ui/default.widgets.vue
+++ b/src/client/ui/default.widgets.vue
@@ -1,6 +1,7 @@
 <template>
 <div class="efzpzdvf">
 	<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+	<MkAd prefer="square"/>
 
 	<button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
 	<button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index e2a779a52d..3ad81203f2 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -70,6 +70,7 @@ import { Channel } from '../models/entities/channel';
 import { ChannelFollowing } from '../models/entities/channel-following';
 import { ChannelNotePining } from '../models/entities/channel-note-pining';
 import { RegistryItem } from '../models/entities/registry-item';
+import { Ad } from '../models/entities/ad';
 import { PasswordResetRequest } from '@/models/entities/password-reset-request';
 
 const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@@ -170,6 +171,7 @@ export const entities = [
 	ChannelFollowing,
 	ChannelNotePining,
 	RegistryItem,
+	Ad,
 	PasswordResetRequest,
 	...charts as any
 ];
diff --git a/src/models/entities/ad.ts b/src/models/entities/ad.ts
new file mode 100644
index 0000000000..3279de29ea
--- /dev/null
+++ b/src/models/entities/ad.ts
@@ -0,0 +1,53 @@
+import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
+import { id } from '../id';
+
+@Entity()
+export class Ad {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The created date of the Ad.'
+	})
+	public createdAt: Date;
+
+	@Index()
+	@Column('timestamp with time zone', {
+		comment: 'The expired date of the Ad.'
+	})
+	public expiresAt: Date;
+
+	@Column('varchar', {
+		length: 32, nullable: false
+	})
+	public place: string;
+
+	@Column('varchar', {
+		length: 32, nullable: false
+	})
+	public priority: string;
+
+	@Column('varchar', {
+		length: 1024, nullable: false
+	})
+	public url: string;
+
+	@Column('varchar', {
+		length: 1024, nullable: false
+	})
+	public imageUrl: string;
+
+	@Column('varchar', {
+		length: 8192, nullable: false
+	})
+	public memo: string;
+
+	constructor(data: Partial<Ad>) {
+		if (data == null) return;
+
+		for (const [k, v] of Object.entries(data)) {
+			(this as any)[k] = v;
+		}
+	}
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index 6ce453ef33..9f8bd104e9 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -60,6 +60,7 @@ import { MutedNote } from './entities/muted-note';
 import { ChannelFollowing } from './entities/channel-following';
 import { ChannelNotePining } from './entities/channel-note-pining';
 import { RegistryItem } from './entities/registry-item';
+import { Ad } from './entities/ad';
 import { PasswordResetRequest } from './entities/password-reset-request';
 
 export const Announcements = getRepository(Announcement);
@@ -123,4 +124,5 @@ export const Channels = getCustomRepository(ChannelRepository);
 export const ChannelFollowings = getRepository(ChannelFollowing);
 export const ChannelNotePinings = getRepository(ChannelNotePining);
 export const RegistryItems = getRepository(RegistryItem);
+export const Ads = getRepository(Ad);
 export const PasswordResetRequests = getRepository(PasswordResetRequest);
diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts
index cdf4841918..7b1df73024 100644
--- a/src/models/repositories/note.ts
+++ b/src/models/repositories/note.ts
@@ -200,8 +200,6 @@ export class NoteRepository extends Repository<Note> {
 			mentions: note.mentions.length > 0 ? note.mentions : undefined,
 			uri: note.uri || undefined,
 			url: note.url || undefined,
-			_featuredId_: (note as any)._featuredId_ || undefined,
-			_prId_: (note as any)._prId_ || undefined,
 
 			...(opts.detail ? {
 				reply: note.replyId ? this.pack(note.reply || note.replyId, me, {
@@ -448,14 +446,7 @@ export const packedNoteSchema = {
 			optional: false as const, nullable: true as const,
 			description: 'The human readable url of a note. it will be null when the note is local.',
 		},
-		_featuredId_: {
-			type: 'string' as const,
-			optional: false as const, nullable: true as const,
-		},
-		_prId_: {
-			type: 'string' as const,
-			optional: false as const, nullable: true as const,
-		},
+
 		myReaction: {
 			type: 'object' as const,
 			optional: true as const, nullable: true as const,
diff --git a/src/server/api/endpoints/admin/ad/create.ts b/src/server/api/endpoints/admin/ad/create.ts
new file mode 100644
index 0000000000..7777e95e6e
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/create.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { Ads } from '../../../../../models';
+import { genId } from '@/misc/gen-id';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true,
+
+	params: {
+		url: {
+			validator: $.str.min(1)
+		},
+		memo: {
+			validator: $.str
+		},
+		place: {
+			validator: $.str
+		},
+		priority: {
+			validator: $.str
+		},
+		expiresAt: {
+			validator: $.num.int()
+		},
+		imageUrl: {
+			validator: $.str.min(1)
+		}
+	},
+};
+
+export default define(meta, async (ps) => {
+	await Ads.insert({
+		id: genId(),
+		createdAt: new Date(),
+		expiresAt: new Date(ps.expiresAt),
+		url: ps.url,
+		imageUrl: ps.imageUrl,
+		priority: ps.priority,
+		place: ps.place,
+		memo: ps.memo,
+	});
+});
diff --git a/src/server/api/endpoints/admin/ad/delete.ts b/src/server/api/endpoints/admin/ad/delete.ts
new file mode 100644
index 0000000000..6a5f92193e
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/delete.ts
@@ -0,0 +1,34 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { ID } from '@/misc/cafy-id';
+import { Ads } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true,
+
+	params: {
+		id: {
+			validator: $.type(ID)
+		}
+	},
+
+	errors: {
+		noSuchAd: {
+			message: 'No such ad.',
+			code: 'NO_SUCH_AD',
+			id: 'ccac9863-3a03-416e-b899-8a64041118b1'
+		}
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	const ad = await Ads.findOne(ps.id);
+
+	if (ad == null) throw new ApiError(meta.errors.noSuchAd);
+
+	await Ads.delete(ad.id);
+});
diff --git a/src/server/api/endpoints/admin/ad/list.ts b/src/server/api/endpoints/admin/ad/list.ts
new file mode 100644
index 0000000000..a323f2a9ed
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/list.ts
@@ -0,0 +1,36 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { Ads } from '../../../../../models';
+import { makePaginationQuery } from '../../../common/make-pagination-query';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true,
+
+	params: {
+		limit: {
+			validator: $.optional.num.range(1, 100),
+			default: 10
+		},
+
+		sinceId: {
+			validator: $.optional.type(ID),
+		},
+
+		untilId: {
+			validator: $.optional.type(ID),
+		},
+	},
+};
+
+export default define(meta, async (ps) => {
+	const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId)
+		.andWhere('ad.expiresAt > :now', { now: new Date() });
+
+	const ads = await query.take(ps.limit!).getMany();
+
+	return ads;
+});
diff --git a/src/server/api/endpoints/admin/ad/update.ts b/src/server/api/endpoints/admin/ad/update.ts
new file mode 100644
index 0000000000..694af98394
--- /dev/null
+++ b/src/server/api/endpoints/admin/ad/update.ts
@@ -0,0 +1,59 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { ID } from '@/misc/cafy-id';
+import { Ads } from '../../../../../models';
+import { ApiError } from '../../../error';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true,
+
+	params: {
+		id: {
+			validator: $.type(ID)
+		},
+		memo: {
+			validator: $.str
+		},
+		url: {
+			validator: $.str.min(1)
+		},
+		imageUrl: {
+			validator: $.str.min(1)
+		},
+		place: {
+			validator: $.str
+		},
+		priority: {
+			validator: $.str
+		},
+		expiresAt: {
+			validator: $.num.int()
+		},
+	},
+
+	errors: {
+		noSuchAd: {
+			message: 'No such ad.',
+			code: 'NO_SUCH_AD',
+			id: 'b7aa1727-1354-47bc-a182-3a9c3973d300'
+		}
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	const ad = await Ads.findOne(ps.id);
+
+	if (ad == null) throw new ApiError(meta.errors.noSuchAd);
+
+	await Ads.update(ad.id, {
+		url: ps.url,
+		place: ps.place,
+		priority: ps.priority,
+		memo: ps.memo,
+		imageUrl: ps.imageUrl,
+		expiresAt: new Date(ps.expiresAt),
+	});
+});
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 3760c8b37b..5b7292ef16 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -2,8 +2,9 @@ import $ from 'cafy';
 import config from '@/config';
 import define from '../define';
 import { fetchMeta } from '@/misc/fetch-meta';
-import { Emojis, Users } from '../../../models';
+import { Ads, Emojis, Users } from '../../../models';
 import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits';
+import { MoreThan } from 'typeorm';
 
 export const meta = {
 	desc: {
@@ -193,6 +194,30 @@ export const meta = {
 					}
 				}
 			},
+			ads: {
+				type: 'array' as const,
+				optional: false as const, nullable: false as const,
+				items: {
+					type: 'object' as const,
+					optional: false as const, nullable: false as const,
+					properties: {
+						place: {
+							type: 'string' as const,
+							optional: false as const, nullable: false as const
+						},
+						url: {
+							type: 'string' as const,
+							optional: false as const, nullable: false as const,
+							format: 'url'
+						},
+						imageUrl: {
+							type: 'string' as const,
+							optional: false as const, nullable: false as const,
+							format: 'url'
+						},
+					}
+				}
+			},
 			requireSetup: {
 				type: 'boolean' as const,
 				optional: false as const, nullable: false as const,
@@ -443,6 +468,12 @@ export default define(meta, async (ps, me) => {
 		}
 	});
 
+	const ads = await Ads.find({
+		where: {
+			expiresAt: MoreThan(new Date())
+		},
+	});
+
 	const response: any = {
 		maintainerName: instance.maintainerName,
 		maintainerEmail: instance.maintainerEmail,
@@ -477,6 +508,12 @@ export default define(meta, async (ps, me) => {
 		logoImageUrl: instance.logoImageUrl,
 		maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH),
 		emojis: await Emojis.packMany(emojis),
+		ads: ads.map(ad => ({
+			url: ad.url,
+			place: ad.place,
+			priority: ad.priority,
+			imageUrl: ad.imageUrl,
+		})),
 		enableEmail: instance.enableEmail,
 
 		enableTwitterIntegration: instance.enableTwitterIntegration,
-- 
GitLab