Merge branch 'fix/security'

This commit is contained in:
ThatOneCalculator 2023-03-14 16:26:42 -07:00
commit ff26728f52
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
61 changed files with 6796 additions and 4675 deletions

View File

@ -1,16 +1,48 @@
.autogen # Visual Studio Code
.github /.vscode
.travis !/.vscode/extensions.json
.vscode
.config # Intelij-IDEA
/.idea
packages/backend/.idea/backend.iml
packages/backend/.idea/modules.xml
packages/backend/.idea/vcs.xml
# Node.js
node_modules
report.*.json
# Cypress
cypress/screenshots
cypress/videos
# Coverage
coverage
# config
/.config/*
!/.config/example.yml
!/.config/docker_example.env
#docker dev config
/dev/docker-compose.yml
# misskey
built
db
elasticsearch
redis
npm-debug.log
*.pem
run.bat
api-docs.json
*.log
*.code-workspace
.DS_Store
files
ormconfig.json
packages/backend/assets/instance.css
# dockerignore custom
.git
Dockerfile Dockerfile
build/
built/
db/
docker-compose.yml
elasticsearch/
node_modules/
redis/
files/
misskey-assets/
.pnp.*

12
.gitignore vendored
View File

@ -28,23 +28,19 @@ coverage
/dev/docker-compose.yml /dev/docker-compose.yml
# misskey # misskey
/build
built built
/data db
/.cache-loader elasticsearch
/db redis
/elasticsearch
npm-debug.log npm-debug.log
*.pem *.pem
run.bat run.bat
api-docs.json api-docs.json
*.log *.log
/redis
*.code-workspace *.code-workspace
.DS_Store .DS_Store
/files files
ormconfig.json ormconfig.json
/custom
packages/backend/assets/instance.css packages/backend/assets/instance.css
# blender backups # blender backups

View File

@ -12,4 +12,4 @@ pipeline:
# Secret 'docker_password' needs to be set in the CI settings # Secret 'docker_password' needs to be set in the CI settings
from_secret: docker_password from_secret: docker_password
branch: main branches: main

View File

@ -0,0 +1,15 @@
pipeline:
publish-docker-latest:
image: plugins/kaniko
settings:
repo: thatonecalculator/calckey
tags: rc
dockerfile: Dockerfile
username:
# Secret 'docker_username' needs to be set in the CI settings
from_secret: docker_username
password:
# Secret 'docker_password' needs to be set in the CI settings
from_secret: docker_password
branches: beta

View File

@ -8,4 +8,4 @@ pipeline:
no_push: true no_push: true
branches: branches:
include: [ main, develop ] include: [ main, develop, beta ]

View File

@ -2,7 +2,34 @@
All changes from v13.0.0 onwards, for a full list of differences read CALCKEY.md All changes from v13.0.0 onwards, for a full list of differences read CALCKEY.md
## [13.1.1] - 2023-02-04 ## [13.1.3] - 2023-02-09
### Bug Fixes
- Fix: Hide unmute option when the user is blocked
### Features
- Feat: Mute and unfollow when blocking a user
- Feat: Unblock with follow button
- Refresh user when changed
- Feature/help_menu ([#9587](https://github.com/orhun/git-cliff/issues/9587))
Co-authored-by: ThatOneCalculator <kainoa@t1c.dev>
Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9587
### Miscellaneous Tasks
- Chore: up vite
- Chore: update credits
## [13.1.2] - 2023-02-06
### Bug Fixes ### Bug Fixes
@ -95,6 +122,14 @@ Closes #9426
- Fix: Use ❤️ instead of ♥️ - Fix: Use ❤️ instead of ♥️
- Fix: :bug: following issues
Closes #9544
- Fix: :lock: improve tag search security
- Fix: reactions using unicode weren't processed
### Documentation ### Documentation
@ -129,6 +164,10 @@ closes #9501
- Feat: PWA icons - Feat: PWA icons
- Feat: :sparkles: dialog to remove follower
co-authored-by: atsu1125 <atsu1125@github>
### Miscellaneous Tasks ### Miscellaneous Tasks
@ -170,6 +209,10 @@ why was this ever needed
- Chore: update german translations - Chore: update german translations
- Update changelog
- Chore: formatting
### Performance ### Performance

View File

@ -1,25 +1,51 @@
FROM node:19-alpine ## Install dev and compilation dependencies, build files
ARG NODE_ENV=production FROM node:19-alpine as build
WORKDIR /calckey WORKDIR /calckey
# Copy Files # Install compilation dependencies
COPY . ./ RUN apk add --no-cache --no-progress git alpine-sdk python3
# Install Dependencies # Copy only the dependency-related files first, to cache efficiently
RUN apk update COPY package.json pnpm*.yaml ./
RUN apk add git ffmpeg tini alpine-sdk python3 COPY packages/backend/package.json packages/backend/package.json
COPY packages/client/package.json packages/client/package.json
COPY packages/sw/package.json packages/sw/package.json
# Configure corepack and pnpm # Configure corepack and pnpm
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@latest --activate RUN corepack prepare pnpm@latest --activate
RUN pnpm i --frozen-lockfile
ARG NODE_ENV=production
# Build project (pnp dependencies are installed) # Install dev mode dependencies for compilation
RUN pnpm i --frozen-lockfile
# Copy in the rest of the files, to compile from TS to JS
COPY . ./
RUN pnpm run build RUN pnpm run build
# Remove git files # Trim down the dependencies to only the prod deps
RUN rm -rf .git RUN pnpm i --prod --frozen-lockfile
## Runtime container
FROM node:19-alpine
WORKDIR /calckey
# Install runtime dependencies
RUN apk add --no-cache --no-progress tini ffmpeg
COPY . ./
# Copy node modules
COPY --from=build /calckey/node_modules /calckey/node_modules
COPY --from=build /calckey/packages/backend/node_modules /calckey/packages/backend/node_modules
COPY --from=build /calckey/packages/sw/node_modules /calckey/packages/sw/node_modules
COPY --from=build /calckey/packages/client/node_modules /calckey/packages/client/node_modules
# Copy the finished compiled files
COPY --from=build /calckey/built /calckey/built
COPY --from=build /calckey/packages/backend/built /calckey/packages/backend/built
COPY --from=build /calckey/packages/backend/assets/instance.css /calckey/packages/backend/assets/instance.css
RUN corepack enable
ENTRYPOINT [ "/sbin/tini", "--" ] ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "pnpm", "run", "migrateandstart" ] CMD [ "pnpm", "run", "migrateandstart" ]

View File

@ -1786,3 +1786,20 @@ _deck:
list: "List" list: "List"
mentions: "Mentions" mentions: "Mentions"
direct: "Direct messages" direct: "Direct messages"
_apps:
apps: "Apps"
crossPlatform: "Cross platform"
mobile: "Mobile"
firstParty: "First party"
firstClass: "First class"
secondClass: "Second class"
thirdClass: "Third class"
free: "Free"
paid: "Paid"
pwa: "Install PWA"
kaiteki: "Kaiteki"
milktea: "Milktea"
subwayTooter: "Subway Tooter"
kimis: "Kimis"
theDesk: "TheDesk"
lesskey: "Lesskey"

View File

@ -1,12 +1,12 @@
{ {
"name": "calckey", "name": "calckey",
"version": "13.1.2", "version": "13.1.3-rc",
"codename": "aqua", "codename": "aqua",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://codeberg.org/calckey/calckey.git" "url": "https://codeberg.org/calckey/calckey.git"
}, },
"packageManager": "pnpm@7.26.3", "packageManager": "pnpm@7.27.0",
"private": true, "private": true,
"scripts": { "scripts": {
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp", "rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
@ -20,6 +20,7 @@
"gulp": "gulp build", "gulp": "gulp build",
"watch": "pnpm run dev", "watch": "pnpm run dev",
"dev": "pnpm node ./scripts/dev.js", "dev": "pnpm node ./scripts/dev.js",
"dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development pnpm run build && pnpm run start",
"lint": "pnpm -r run lint", "lint": "pnpm -r run lint",
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "cypress run", "cy:run": "cypress run",
@ -32,22 +33,14 @@
"cleanall": "pnpm run clean-all" "cleanall": "pnpm run clean-all"
}, },
"resolutions": { "resolutions": {
"chokidar": "^3.3.1", "chokidar": "^3.3.1"
"lodash": "^4.17.21"
}, },
"dependencies": { "dependencies": {
"@bull-board/api": "^4.10.2", "@bull-board/api": "^4.10.2",
"@bull-board/ui": "^4.10.2", "@bull-board/ui": "^4.10.2",
"@tensorflow/tfjs": "^3.21.0", "@tensorflow/tfjs": "^3.21.0",
"calckey-js": "^0.0.20", "calckey-js": "^0.0.20",
"execa": "5.1.1",
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",
"gulp-rename": "2.0.0",
"gulp-replace": "1.1.4",
"gulp-terser": "2.1.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"long": "^5.2.1",
"phosphor-icons": "^1.4.2", "phosphor-icons": "^1.4.2",
"seedrandom": "^3.0.5" "seedrandom": "^3.0.5"
}, },
@ -56,6 +49,12 @@
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "10.11.0", "cypress": "10.11.0",
"execa": "5.1.1",
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",
"gulp-rename": "2.0.0",
"gulp-replace": "1.1.4",
"gulp-terser": "2.1.0",
"install-peers": "^1.0.4", "install-peers": "^1.0.4",
"rome": "^11.0.0", "rome": "^11.0.0",
"start-server-and-test": "1.15.2", "start-server-and-test": "1.15.2",

View File

@ -15,8 +15,7 @@
"test": "pnpm run mocha" "test": "pnpm run mocha"
}, },
"resolutions": { "resolutions": {
"chokidar": "^3.3.1", "chokidar": "^3.3.1"
"lodash": "^4.17.21"
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-android-arm64": "1.3.11", "@swc/core-android-arm64": "1.3.11",
@ -34,13 +33,14 @@
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@redocly/openapi-core": "1.0.0-beta.120", "@redocly/openapi-core": "1.0.0-beta.120",
"@sinonjs/fake-timers": "9.1.2", "@sinonjs/fake-timers": "9.1.2",
"@swc/cli": "^0.1.59",
"@swc/core": "^1.3.26",
"@syuilo/aiscript": "0.11.1", "@syuilo/aiscript": "0.11.1",
"@tensorflow/tfjs": "^4.2.0", "@tensorflow/tfjs": "^4.2.0",
"ajv": "8.11.2", "ajv": "8.11.2",
"archiver": "5.3.1", "archiver": "5.3.1",
"koa-body": "^6.0.1",
"autobind-decorator": "2.4.0", "autobind-decorator": "2.4.0",
"autolinker": "4.0.0",
"axios": "^1.3.2",
"autwh": "0.1.0", "autwh": "0.1.0",
"aws-sdk": "2.1277.0", "aws-sdk": "2.1277.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
@ -68,8 +68,6 @@
"is-svg": "4.3.2", "is-svg": "4.3.2",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "20.0.3", "jsdom": "20.0.3",
"json5": "2.2.3",
"json5-loader": "4.0.1",
"jsonld": "6.0.0", "jsonld": "6.0.0",
"jsrsasign": "10.6.1", "jsrsasign": "10.6.1",
"koa": "2.13.4", "koa": "2.13.4",
@ -81,9 +79,9 @@
"koa-send": "5.0.1", "koa-send": "5.0.1",
"koa-slow": "2.1.0", "koa-slow": "2.1.0",
"koa-views": "7.0.2", "koa-views": "7.0.2",
"@cutls/megalodon": "5.1.15",
"mfm-js": "0.23.2", "mfm-js": "0.23.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"mocha": "10.2.0",
"multer": "1.4.4-lts.1", "multer": "1.4.4-lts.1",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.0", "node-fetch": "3.3.0",
@ -96,7 +94,6 @@
"private-ip": "2.3.4", "private-ip": "2.3.4",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"pug": "3.0.2",
"punycode": "2.1.1", "punycode": "2.1.1",
"pureimage": "0.3.15", "pureimage": "0.3.15",
"qrcode": "1.5.1", "qrcode": "1.5.1",
@ -108,13 +105,11 @@
"rename": "1.0.4", "rename": "1.0.4",
"rndstr": "1.0.0", "rndstr": "1.0.0",
"rss-parser": "3.12.0", "rss-parser": "3.12.0",
"s-age": "1.1.2",
"sanitize-html": "2.8.1", "sanitize-html": "2.8.1",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "0.31.3", "sharp": "0.31.3",
"speakeasy": "2.0.0", "speakeasy": "2.0.0",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "2.7.0", "summaly": "2.7.0",
"syslog-pro": "1.0.0", "syslog-pro": "1.0.0",
@ -122,9 +117,6 @@
"tesseract.js": "^3.0.3", "tesseract.js": "^3.0.3",
"tinycolor2": "1.5.2", "tinycolor2": "1.5.2",
"tmp": "0.2.1", "tmp": "0.2.1",
"ts-loader": "9.4.2",
"ts-node": "10.9.1",
"tsconfig-paths": "4.1.2",
"twemoji-parser": "14.0.0", "twemoji-parser": "14.0.0",
"typeorm": "0.3.11", "typeorm": "0.3.11",
"ulid": "2.3.0", "ulid": "2.3.0",
@ -132,10 +124,11 @@
"uuid": "9.0.0", "uuid": "9.0.0",
"web-push": "3.5.0", "web-push": "3.5.0",
"websocket": "1.0.34", "websocket": "1.0.34",
"ws": "8.11.0",
"xev": "3.0.2" "xev": "3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.59",
"@swc/core": "^1.3.26",
"@types/bcryptjs": "2.4.2", "@types/bcryptjs": "2.4.2",
"@types/bull": "3.15.9", "@types/bull": "3.15.9",
"@types/cbor": "6.0.0", "@types/cbor": "6.0.0",
@ -179,11 +172,21 @@
"@types/web-push": "3.3.2", "@types/web-push": "3.3.2",
"@types/websocket": "1.0.5", "@types/websocket": "1.0.5",
"@types/ws": "8.5.3", "@types/ws": "8.5.3",
"autobind-decorator": "2.4.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"execa": "6.1.0", "execa": "6.1.0",
"json5": "2.2.3",
"json5-loader": "4.0.1",
"mocha": "10.2.0",
"pug": "3.0.2",
"strict-event-emitter-types": "2.0.0",
"swc-loader": "^0.2.3", "swc-loader": "^0.2.3",
"ts-loader": "9.4.2",
"ts-node": "10.9.1",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"webpack": "^5.75.0" "webpack": "^5.75.0",
"ws": "8.11.0"
} }
} }

View File

@ -2,3 +2,4 @@ import twemoji from "twemoji-parser/dist/lib/regex.js";
const twemojiRegex = twemoji.default; const twemojiRegex = twemoji.default;
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`);

View File

@ -197,6 +197,8 @@ export const NoteRepository = db.getRepository(Note).extend({
.map((x) => decodeReaction(x).reaction) .map((x) => decodeReaction(x).reaction)
.map((x) => x.replace(/:/g, "")); .map((x) => x.replace(/:/g, ""));
const noteEmoji = await populateEmojis(note.emojis.concat(reactionEmojiNames), host);
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
const packed: Packed<"Note"> = await awaitAll({ const packed: Packed<"Note"> = await awaitAll({
id: note.id, id: note.id,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
@ -213,8 +215,9 @@ export const NoteRepository = db.getRepository(Note).extend({
renoteCount: note.renoteCount, renoteCount: note.renoteCount,
repliesCount: note.repliesCount, repliesCount: note.repliesCount,
reactions: convertLegacyReactions(note.reactions), reactions: convertLegacyReactions(note.reactions),
reactionEmojis: reactionEmoji,
emojis: noteEmoji,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host),
fileIds: note.fileIds, fileIds: note.fileIds,
files: DriveFiles.packMany(note.fileIds), files: DriveFiles.packMany(note.fileIds),
replyId: note.replyId, replyId: note.replyId,

View File

@ -161,26 +161,8 @@ export const packedNoteSchema = {
nullable: false, nullable: false,
}, },
emojis: { emojis: {
type: "array", type: 'object',
optional: false, optional: true, nullable: true,
nullable: false,
items: {
type: "object",
optional: false,
nullable: false,
properties: {
name: {
type: "string",
optional: false,
nullable: false,
},
url: {
type: "string",
optional: false,
nullable: true,
},
},
},
}, },
reactions: { reactions: {
type: "object", type: "object",

View File

@ -111,6 +111,16 @@ export async function createNote(
const note: IPost = object; const note: IPost = object;
if (note.id && !note.id.startsWith('https://')) {
throw new Error(`unexpected shcema of note.id: ${note.id}`);
}
const url = getOneApHrefNullable(note.url);
if (url && !url.startsWith('https://')) {
throw new Error(`unexpected shcema of note url: ${url}`);
}
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
logger.info(`Creating the Note: ${note.id}`); logger.info(`Creating the Note: ${note.id}`);
@ -123,7 +133,8 @@ export async function createNote(
// Skip if author is suspended. // Skip if author is suspended.
if (actor.isSuspended) { if (actor.isSuspended) {
throw new Error("actor has been suspended"); logger.debug(`User ${actor.usernameLower}@${actor.host} suspended; discarding.`)
return null;
} }
const noteAudience = await parseAudience(actor, note.to, note.cc); const noteAudience = await parseAudience(actor, note.to, note.cc);
@ -344,7 +355,7 @@ export async function createNote(
apEmojis, apEmojis,
poll, poll,
uri: note.id, uri: note.id,
url: getOneApHrefNullable(note.url), url: url,
}, },
silent, silent,
); );

View File

@ -195,6 +195,12 @@ export async function createPerson(
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith('https://')) {
throw new Error(`unexpected shcema of person url: ${url}`);
}
// Create user // Create user
let user: IRemoteUser; let user: IRemoteUser;
try { try {
@ -237,7 +243,7 @@ export async function createPerson(
description: person.summary description: person.summary
? htmlToMfm(truncate(person.summary, summaryLength), person.tag) ? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
: null, : null,
url: getOneApHrefNullable(person.url), url: url,
fields, fields,
birthday: bday ? bday[0] : null, birthday: bday ? bday[0] : null,
location: person["vcard:Address"] || null, location: person["vcard:Address"] || null,
@ -387,6 +393,12 @@ export async function updatePerson(
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith('https://')) {
throw new Error(`unexpected shcema of person url: ${url}`);
}
const updates = { const updates = {
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
inbox: person.inbox, inbox: person.inbox,
@ -430,7 +442,7 @@ export async function updatePerson(
await UserProfiles.update( await UserProfiles.update(
{ userId: exist.id }, { userId: exist.id },
{ {
url: getOneApHrefNullable(person.url), url: url,
fields, fields,
description: person.summary description: person.summary
? htmlToMfm(truncate(person.summary, summaryLength), person.tag) ? htmlToMfm(truncate(person.summary, summaryLength), person.tag)

View File

@ -198,6 +198,7 @@ import * as ep___i_readAnnouncement from "./endpoints/i/read-announcement.js";
import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js"; import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js";
import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js"; import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js";
import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js"; import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js";
import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js';
import * as ep___i_registry_get from "./endpoints/i/registry/get.js"; import * as ep___i_registry_get from "./endpoints/i/registry/get.js";
import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js"; import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js";
import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js"; import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js";
@ -538,6 +539,7 @@ const eps = [
["i/regenerate-token", ep___i_regenerateToken], ["i/regenerate-token", ep___i_regenerateToken],
["i/registry/get-all", ep___i_registry_getAll], ["i/registry/get-all", ep___i_registry_getAll],
["i/registry/get-detail", ep___i_registry_getDetail], ["i/registry/get-detail", ep___i_registry_getDetail],
["i/registry/get-unsecure", ep___i_registry_getUnsecure],
["i/registry/get", ep___i_registry_get], ["i/registry/get", ep___i_registry_get],
["i/registry/keys-with-type", ep___i_registry_keysWithType], ["i/registry/keys-with-type", ep___i_registry_keysWithType],
["i/registry/keys", ep___i_registry_keys], ["i/registry/keys", ep___i_registry_keys],
@ -765,17 +767,17 @@ export interface IEndpointMeta {
} }
export interface IEndpoint { export interface IEndpoint {
name: string; name: string,
exec: any; exec: any, // TODO: may be obosolete @ThatOneCalculator
meta: IEndpointMeta; meta: IEndpointMeta,
params: Schema; params: Schema,
} }
const endpoints: IEndpoint[] = eps.map(([name, ep]) => { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return { return {
name: name, name: name,
exec: ep.default, exec: ep.default,
meta: ep.meta || {}, meta: ep.meta ?? {},
params: ep.paramDef, params: ep.paramDef,
}; };
}); });

View File

@ -0,0 +1,50 @@
import { ApiError } from "../../../error.js";
import define from "../../../define.js";
import { RegistryItems } from "../../../../../models/index.js";
export const meta = {
requireCredential: true,
secure: false,
errors: {
noSuchKey: {
message: "No such key.",
code: "NO_SUCH_KEY",
id: "ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a",
},
},
} as const;
export const paramDef = {
type: "object",
properties: {
key: { type: "string" },
scope: {
type: "array",
default: [],
items: {
type: "string",
pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1),
},
},
},
required: ["key"],
} as const;
export default define(meta, paramDef, async (ps, user) => {
if (ps.key !== "reactions") return;
const query = RegistryItems.createQueryBuilder("item")
.where("item.domain IS NULL")
.andWhere("item.userId = :userId", { userId: user.id })
.andWhere("item.key = :key", { key: ps.key })
.andWhere("item.scope = :scope", { scope: ps.scope });
const item = await query.getOne();
if (item == null) {
throw new ApiError(meta.errors.noSuchKey);
}
return item.value;
});

View File

@ -7,6 +7,7 @@ import Router from "@koa/router";
import multer from "@koa/multer"; import multer from "@koa/multer";
import bodyParser from "koa-bodyparser"; import bodyParser from "koa-bodyparser";
import cors from "@koa/cors"; import cors from "@koa/cors";
import { apiMastodonCompatible } from './mastodon/ApiMastodonCompatibleService.js';
import { Instances, AccessTokens, Users } from "@/models/index.js"; import { Instances, AccessTokens, Users } from "@/models/index.js";
import config from "@/config/index.js"; import config from "@/config/index.js";
import endpoints from "./endpoints.js"; import endpoints from "./endpoints.js";
@ -57,6 +58,8 @@ const upload = multer({
// Init router // Init router
const router = new Router(); const router = new Router();
apiMastodonCompatible(router);
/** /**
* Register endpoint handlers * Register endpoint handlers
*/ */

View File

@ -0,0 +1,58 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import { apiAuthMastodon } from './endpoints/auth.js';
import { apiAccountMastodon } from './endpoints/account.js';
import { apiStatusMastodon } from './endpoints/status.js';
import { apiFilterMastodon } from './endpoints/filter.js';
import { apiTimelineMastodon } from './endpoints/timeline.js';
import { apiNotificationsMastodon } from './endpoints/notifications.js';
import { apiSearchMastodon } from './endpoints/search.js';
import { getInstance } from './endpoints/meta.js';
export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
const accessTokenArr = authorization?.split(' ') ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default
const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
return client
}
export function apiMastodonCompatible(router: Router): void {
apiAuthMastodon(router)
apiAccountMastodon(router)
apiStatusMastodon(router)
apiFilterMastodon(router)
apiTimelineMastodon(router)
apiNotificationsMastodon(router)
apiSearchMastodon(router)
router.get('/v1/custom_emojis', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get('/v1/instance', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstance();
ctx.body = getInstance(data.data);
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,323 @@
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import { getClient } from '../ApiMastodonCompatibleService.js';
import { toLimitToInt } from './timeline.js';
export function apiAccountMastodon(router: Router): void {
router.get('/v1/accounts/verify_credentials', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.verifyAccountCredentials();
const acct = data.data;
acct.url = `${BASE_URL}/@${acct.url}`
acct.note = ''
acct.avatar_static = acct.avatar
acct.header = acct.header || ''
acct.header_static = acct.header || ''
acct.source = {
note: acct.note,
fields: acct.fields,
privacy: 'public',
sensitive: false,
language: ''
}
ctx.body = acct
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.patch('/v1/accounts/update_credentials', async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateCredentials((ctx.request as any).body as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/accounts/:id', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccount(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountStatuses(ctx.params.id, toLimitToInt(ctx.query as any));
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowers(ctx.params.id, ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowing(ctx.params.id, ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.followAccount(ctx.params.id);
const acct = data.data;
acct.following = true;
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unfollowAccount(ctx.params.id);
const acct = data.data;
acct.following = false;
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/block', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.blockAccount(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unblockAccount(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.muteAccount(ctx.params.id, (ctx.request as any).body as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unmuteAccount(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/accounts/relationships', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const idsRaw = (ctx.query as any)['id[]']
const ids = typeof idsRaw === 'string' ? [idsRaw] : idsRaw
const data = await client.getRelationships(ids) as any;
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/bookmarks', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBookmarks(ctx.query as any) as any;
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/favourites', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFavourites(ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/mutes', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMutes(ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/blocks', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBlocks(ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.get('/v1/follow_ctxs', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFollowRequests((ctx.query as any || { limit: 20 }).limit);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/follow_ctxs/:id/authorize', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.acceptFollowRequest(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/follow_ctxs/:id/reject', async (ctx, next) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.rejectFollowRequest(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status =(401);
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,81 @@
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import { getClient } from '../ApiMastodonCompatibleService.js';
const readScope = [
'read:account',
'read:drive',
'read:blocks',
'read:favorites',
'read:following',
'read:messaging',
'read:mutes',
'read:notifications',
'read:reactions',
'read:pages',
'read:page-likes',
'read:user-groups',
'read:channels',
'read:gallery',
'read:gallery-likes'
]
const writeScope = [
'write:account',
'write:drive',
'write:blocks',
'write:favorites',
'write:following',
'write:messaging',
'write:mutes',
'write:notes',
'write:notifications',
'write:reactions',
'write:votes',
'write:pages',
'write:page-likes',
'write:user-groups',
'write:channels',
'write:gallery',
'write:gallery-likes'
]
export function apiAuthMastodon(router: Router): void {
router.post('/v1/apps', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
let scope = body.scopes
console.log(body)
if (typeof scope === 'string') scope = scope.split(' ')
const pushScope = new Set<string>()
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r)
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r)
}
const scopeArr = Array.from(pushScope)
let red = body.redirect_uris
if (red === 'urn:ietf:wg:oauth:2.0:oob') {
red = 'https://thedesk.top/hello.html'
}
const appData = await client.registerApp(body.client_name, { scopes: scopeArr, redirect_uris: red, website: body.website });
ctx.body = {
id: appData.id,
name: appData.name,
website: appData.website,
redirect_uri: red,
client_id: Buffer.from(appData.url || '').toString('base64'),
client_secret: appData.clientSecret,
}
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,83 @@
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import { getClient } from '../ApiMastodonCompatibleService.js';
export function apiFilterMastodon(router: Router): void {
router.get('/v1/filters', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get('/v1/filters/:id', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post('/v1/filters', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post('/v1/filters/:id', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(ctx.params.id, body.phrase, body.context);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.delete('/v1/filters/:id', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,97 @@
import { Entity } from "@cutls/megalodon";
// TODO: add calckey features
export function getInstance(response: Entity.Instance) {
return {
uri: response.uri,
title: response.title || "",
short_description: response.description || "",
description: response.description || "",
email: response.email || "",
version: "3.0.0 compatible (Calckey)",
urls: response.urls,
stats: response.stats,
thumbnail: response.thumbnail || "",
languages: ["en", "de", "ja"],
registrations: response.registrations,
approval_required: !response.registrations,
invites_enabled: response.registrations,
configuration: {
accounts: {
max_featured_tags: 20,
},
statuses: {
max_characters: 3000,
max_media_attachments: 4,
characters_reserved_per_url: response.uri.length,
},
media_attachments: {
supported_mime_types: [
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"image/webp",
"image/avif",
"video/webm",
"video/mp4",
"video/quicktime",
"video/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wave",
"audio/vnd.wave",
"audio/ogg",
"audio/vorbis",
"audio/mpeg",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"audio/3gpp",
"video/x-ms-asf",
],
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_limit: 60,
video_matrix_limit: 2304000,
},
polls: {
max_options: 8,
max_characters_per_option: 50,
min_expiration: 300,
max_expiration: 2629746,
},
},
contact_account: {
id: "1",
username: "admin",
acct: "admin",
display_name: "admin",
locked: true,
bot: true,
discoverable: false,
group: false,
created_at: "1971-01-01T00:00:00.000Z",
note: "",
url: "https://http.cat/404",
avatar: "https://http.cat/404",
avatar_static: "https://http.cat/404",
header: "https://http.cat/404",
header_static: "https://http.cat/404",
followers_count: -1,
following_count: 0,
statuses_count: 0,
last_status_at: "1971-01-01T00:00:00.000Z",
noindex: true,
emojis: [],
fields: [],
},
rules: [],
};
}

View File

@ -0,0 +1,89 @@
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import { getClient } from '../ApiMastodonCompatibleService.js';
import { toTextWithReaction } from './timeline.js';
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10)
return q
}
export function apiNotificationsMastodon(router: Router): void {
router.get('/v1/notifications', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getNotifications(toLimitToInt(ctx.query));
const notfs = data.data;
const ret = notfs.map((n) => {
if(n.type !== 'follow' && n.type !== 'follow_request') {
if (n.type === 'reaction') n.type = 'favourite'
n.status = toTextWithReaction(n.status ? [n.status] : [], ctx.hostname)[0]
return n
} else {
return n
}
})
ctx.body = ret;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get('/v1/notification/:id', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const dataRaw = await client.getNotification(ctx.params.id);
const data = dataRaw.data;
if(data.type !== 'follow' && data.type !== 'follow_request') {
if (data.type === 'reaction') data.type = 'favourite'
ctx.body = toTextWithReaction([data as any], ctx.request.hostname)[0]
} else {
ctx.body = data
}
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post('/v1/notifications/clear', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotifications();
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post('/v1/notification/:id/dismiss', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotification(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,25 @@
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import { getClient } from '../ApiMastodonCompatibleService.js';
export function apiSearchMastodon(router: Router): void {
router.get('/v1/search', async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const query: any = ctx.query
const type = query.type || ''
const data = await client.search(query.q, type, query);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,403 @@
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import { getClient } from '../ApiMastodonCompatibleService.js';
import fs from 'fs'
import { pipeline } from 'node:stream';
import { promisify } from 'node:util';
import { createTemp } from '@/misc/create-temp.js';
import { emojiRegex, emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
import axios from 'axios';
const pump = promisify(pipeline);
export function apiStatusMastodon(router: Router): void {
router.post('/v1/statuses', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const body: any = ctx.request.body
const text = body.status
const removed = text.replace(/@\S+/g, '').replaceAll(' ', '')
const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed)
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed)
if (body.in_reply_to_id && isDefaultEmoji || isCustomEmoji) {
const a = await client.createEmojiReaction(body.in_reply_to_id, removed)
ctx.body = a.data
}
if (body.in_reply_to_id && removed === '/unreact') {
try {
const id = body.in_reply_to_id
const post = await client.getStatus(id)
const react = post.data.emoji_reactions.filter((e) => e.me)[0].name
const data = await client.deleteEmojiReaction(id, react);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
}
if (!body.media_ids) body.media_ids = undefined
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined
const data = await client.postStatus(text, body);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/statuses/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
interface IReaction {
id: string
createdAt: string
user: MisskeyEntity.User,
type: string
}
router.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const id = ctx.params.id
const data = await client.getStatusContext(id, ctx.query as any);
const status = await client.getStatus(id);
const reactionsAxios = await axios.get(`${BASE_URL}/api/notes/reactions?noteId=${id}`)
const reactions: IReaction[] = reactionsAxios.data
const text = reactions.map((r) => `${r.type.replace('@.', '')} ${r.user.username}`).join('<br />')
data.data.descendants.unshift(statusModel(status.data.id, status.data.account.id, status.data.emojis, text))
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusRebloggedBy(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (ctx, reply) => {
ctx.body = []
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const a = await client.createEmojiReaction(ctx.params.id, react) as any;
//const data = await client.favouriteStatus(ctx.params.id) as any;
ctx.body = a.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const data = await client.deleteEmojiReaction(ctx.params.id, react);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reblogStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreblogStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.bookmarkStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unbookmarkStatus(ctx.params.id) as any;
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.pinStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unpinStatus(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post('/v1/media', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: 'No image' };
return;
}
const [path] = await createTemp();
await pump(multipartData.buffer, fs.createWriteStream(path));
const image = fs.readFileSync(path);
const data = await client.uploadMedia(image);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post('/v2/media', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const multipartData = await ctx.file;
if (!multipartData) {
ctx.body = { error: 'No image' };
return;
}
const [path] = await createTemp();
await pump(multipartData.buffer, fs.createWriteStream(path));
const image = fs.readFileSync(path);
const data = await client.uploadMedia(image);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/media/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMedia(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>('/v1/media/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateMedia(ctx.params.id, ctx.request.body as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/polls/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getPoll(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.votePoll(ctx.params.id, (ctx.request.body as any).choices);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
ctx.status = (401);
ctx.body = e.response.data;
}
});
}
async function getFirstReaction(BASE_URL: string, accessTokens: string | undefined) {
const accessTokenArr = accessTokens?.split(' ') ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
let react = '👍'
try {
const api = await axios.post(`${BASE_URL}/api/i/registry/get-unsecure`, {
scope: ['client', 'base'],
key: 'reactions',
i: accessToken
})
const reactRaw = api.data
react = Array.isArray(reactRaw) ? api.data[0] : '👍'
console.log(api.data)
return react
} catch (e) {
return react
}
}
export function statusModel(id: string | null, acctId: string | null, emojis: MastodonEntity.Emoji[], content: string) {
const now = "1970-01-02T00:00:00.000Z"
return {
id: '9atm5frjhb',
uri: 'https://http.cat/404', // ""
url: 'https://http.cat/404', // "",
account: {
id: '9arzuvv0sw',
username: 'ReactionBot',
acct: 'ReactionBot',
display_name: 'ReactionOfThisPost',
locked: false,
created_at: now,
followers_count: 0,
following_count: 0,
statuses_count: 0,
note: '',
url: 'https://http.cat/404',
avatar: 'https://http.cat/404',
avatar_static: 'https://http.cat/404',
header: 'https://http.cat/404', // ""
header_static: 'https://http.cat/404', // ""
emojis: [],
fields: [],
moved: null,
bot: false,
},
in_reply_to_id: id,
in_reply_to_account_id: acctId,
reblog: null,
content: `<p>${content}</p>`,
plain_content: null,
created_at: now,
emojis: emojis,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
sensitive: false,
spoiler_text: '',
visibility: 'public' as const,
media_attachments: [],
mentions: [],
tags: [],
card: null,
poll: null,
application: null,
language: null,
pinned: false,
emoji_reactions: [],
bookmarked: false,
quote: false,
}
}

View File

@ -0,0 +1,246 @@
import Router from "@koa/router";
import { koaBody } from 'koa-body';
import megalodon, { Entity, MegalodonInterface } from '@cutls/megalodon';
import { getClient } from '../ApiMastodonCompatibleService.js'
import { statusModel } from './status.js';
import Autolinker from 'autolinker';
import { ParsedUrlQuery } from "querystring";
export function toLimitToInt(q: ParsedUrlQuery) {
if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10).toString()
return q
}
export function toTextWithReaction(status: Entity.Status[], host: string) {
return status.map((t) => {
if (!t) return statusModel(null, null, [], 'no content')
if (!t.emoji_reactions) return t
if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0]
const reactions = t.emoji_reactions.map((r) => `${r.name.replace('@.', '')} (${r.count}${r.me ? "* " : ''})`);
//t.emojis = getEmoji(t.content, host)
t.content = `<p>${autoLinker(t.content, host)}</p><p>${reactions.join(', ')}</p>`
return t
})
}
export function autoLinker(input: string, host: string) {
return Autolinker.link(input, {
hashtag: 'twitter',
mention: 'twitter',
email: false,
stripPrefix: false,
replaceFn : function (match) {
switch(match.type) {
case 'url':
return true
case 'mention':
console.log("Mention: ", match.getMention());
console.log("Mention Service Name: ", match.getServiceName());
return `<a href="https://${host}/@${encodeURIComponent(match.getMention())}" target="_blank">@${match.getMention()}</a>`;
case 'hashtag':
console.log("Hashtag: ", match.getHashtag());
return `<a href="https://${host}/tags/${encodeURIComponent(match.getHashtag())}" target="_blank">#${match.getHashtag()}</a>`;
}
return false
}
} );
}
export function apiTimelineMastodon(router: Router): void {
router.get('/v1/timelines/public', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = ctx.query
const data = query.local ? await client.getLocalTimeline(toLimitToInt(query)) : await client.getPublicTimeline(toLimitToInt(query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getTagTimeline(ctx.params.hashtag, toLimitToInt(ctx.query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>('/v1/timelines/home', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getHomeTimeline(toLimitToInt(ctx.query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { listId: string } }>('/v1/timelines/list/:listId', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(ctx.params.listId, toLimitToInt(ctx.query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get('/v1/conversations', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(toLimitToInt(ctx.query));
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get('/v1/lists', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getLists();
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/lists/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getList(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post('/v1/lists', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.query as any).title);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>('/v1/lists/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(ctx.params.id, ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.delete<{ Params: { id: string } }>('/v1/lists/:id', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountsInList(ctx.params.id, ctx.query as any);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(ctx.params.id, (ctx.query as any).account_ids);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
router.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(ctx.params.id, (ctx.query as any).account_ids);
ctx.body = data.data;
} catch (e: any) {
console.error(e)
console.error(e.response.data)
ctx.status = (401);
ctx.body = e.response.data;
}
});
}
function escapeHTML(str: string) {
if (!str) {
return ''
}
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function nl2br(str: string) {
if (!str) {
return ''
}
str = str.replace(/\r\n/g, '<br />')
str = str.replace(/(\n|\r)/g, '<br />')
return str
}

View File

@ -24,6 +24,9 @@ import { readNotification } from "../common/read-notification.js";
import channels from "./channels/index.js"; import channels from "./channels/index.js";
import type Channel from "./channel.js"; import type Channel from "./channel.js";
import type { StreamEventEmitter, StreamMessages } from "./types.js"; import type { StreamEventEmitter, StreamMessages } from "./types.js";
import { Converter } from "@cutls/megalodon";
import { getClient } from "../mastodon/ApiMastodonCompatibleService.js";
import { toTextWithReaction } from "../mastodon/endpoints/timeline.js";
/** /**
* Main stream connection * Main stream connection
@ -41,17 +44,27 @@ export default class Connection {
private channels: Channel[] = []; private channels: Channel[] = [];
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private cachedNotes: Packed<"Note">[] = []; private cachedNotes: Packed<"Note">[] = [];
private isMastodonCompatible: boolean = false;
private host: string;
private accessToken: string;
private currentSubscribe: string[][] = [];
constructor( constructor(
wsConnection: websocket.connection, wsConnection: websocket.connection,
subscriber: EventEmitter, subscriber: EventEmitter,
user: User | null | undefined, user: User | null | undefined,
token: AccessToken | null | undefined, token: AccessToken | null | undefined,
host: string,
accessToken: string,
prepareStream: string | undefined,
) { ) {
console.log("constructor", prepareStream);
this.wsConnection = wsConnection; this.wsConnection = wsConnection;
this.subscriber = subscriber; this.subscriber = subscriber;
if (user) this.user = user; if (user) this.user = user;
if (token) this.token = token; if (token) this.token = token;
if (host) this.host = host;
if (accessToken) this.accessToken = accessToken;
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
this.onUserEvent = this.onUserEvent.bind(this); this.onUserEvent = this.onUserEvent.bind(this);
@ -73,6 +86,13 @@ export default class Connection {
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
} }
console.log("prepare", prepareStream);
if (prepareStream) {
this.onWsConnectionMessage({
type: "utf8",
utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }),
});
}
} }
private onUserEvent(data: StreamMessages["user"]["payload"]) { private onUserEvent(data: StreamMessages["user"]["payload"]) {
@ -125,58 +145,149 @@ export default class Connection {
if (data.type !== "utf8") return; if (data.type !== "utf8") return;
if (data.utf8Data == null) return; if (data.utf8Data == null) return;
let obj: Record<string, any>; let objs: Record<string, any>[];
try { try {
obj = JSON.parse(data.utf8Data); objs = [JSON.parse(data.utf8Data)];
} catch (e) { } catch (e) {
return; return;
} }
const { type, body } = obj; const simpleObj = objs[0];
if (simpleObj.stream) {
// is Mastodon Compatible
this.isMastodonCompatible = true;
if (simpleObj.type === "subscribe") {
let forSubscribe = [];
if (simpleObj.stream === "user") {
this.currentSubscribe.push(["user"]);
objs = [
{
type: "connect",
body: {
channel: "main",
id: simpleObj.stream,
},
},
{
type: "connect",
body: {
channel: "homeTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
try {
const tl = await client.getHomeTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} catch (e: any) {
console.log(e);
console.error(e.response.data);
}
} else if (simpleObj.stream === "public:local") {
this.currentSubscribe.push(["public:local"]);
objs = [
{
type: "connect",
body: {
channel: "localTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getLocalTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} else if (simpleObj.stream === "public") {
this.currentSubscribe.push(["public"]);
objs = [
{
type: "connect",
body: {
channel: "globalTimeline",
id: simpleObj.stream,
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getPublicTimeline();
for (const t of tl.data) forSubscribe.push(t.id);
} else if (simpleObj.stream === "list") {
this.currentSubscribe.push(["list", simpleObj.list]);
objs = [
{
type: "connect",
body: {
channel: "list",
id: simpleObj.stream,
params: {
listId: simpleObj.list,
},
},
},
];
const client = getClient(this.host, this.accessToken);
const tl = await client.getListTimeline(simpleObj.list);
for (const t of tl.data) forSubscribe.push(t.id);
}
for (const s of forSubscribe) {
objs.push({
type: "s",
body: {
id: s,
},
});
}
}
}
switch (type) { for (const obj of objs) {
case "readNotification": const { type, body } = obj;
this.onReadNotification(body); console.log(type, body);
break; switch (type) {
case "subNote": case "readNotification":
this.onSubscribeNote(body); this.onReadNotification(body);
break; break;
case "s": case "subNote":
this.onSubscribeNote(body); this.onSubscribeNote(body);
break; // alias break;
case "sr": case "s":
this.onSubscribeNote(body); this.onSubscribeNote(body);
this.readNote(body); break; // alias
break; case "sr":
case "unsubNote": this.onSubscribeNote(body);
this.onUnsubscribeNote(body); this.readNote(body);
break; break;
case "un": case "unsubNote":
this.onUnsubscribeNote(body); this.onUnsubscribeNote(body);
break; // alias break;
case "connect": case "un":
this.onChannelConnectRequested(body); this.onUnsubscribeNote(body);
break; break; // alias
case "disconnect": case "connect":
this.onChannelDisconnectRequested(body); this.onChannelConnectRequested(body);
break; break;
case "channel": case "disconnect":
this.onChannelMessageRequested(body); this.onChannelDisconnectRequested(body);
break; break;
case "ch": case "channel":
this.onChannelMessageRequested(body); this.onChannelMessageRequested(body);
break; // alias break;
case "ch":
this.onChannelMessageRequested(body);
break; // alias
// 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、
// クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別
// なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。
case "typingOnChannel": case "typingOnChannel":
this.typingOnChannel(body.channel); this.typingOnChannel(body.channel);
break; break;
case "typingOnMessaging": case "typingOnMessaging":
this.typingOnMessaging(body); this.typingOnMessaging(body);
break; break;
}
} }
} }
@ -280,12 +391,75 @@ export default class Connection {
* *
*/ */
public sendMessageToWs(type: string, payload: any) { public sendMessageToWs(type: string, payload: any) {
this.wsConnection.send( console.log(payload, this.isMastodonCompatible);
JSON.stringify({ if (this.isMastodonCompatible) {
type: type, if (payload.type === "note") {
body: payload, this.wsConnection.send(
}), JSON.stringify({
); stream: [payload.id],
event: "update",
payload: JSON.stringify(
toTextWithReaction(
[Converter.note(payload.body, this.host)],
this.host,
)[0],
),
}),
);
this.onSubscribeNote({
id: payload.body.id,
});
} else if (payload.type === "reacted" || payload.type === "unreacted") {
// reaction
const client = getClient(this.host, this.accessToken);
client.getStatus(payload.id).then((data) => {
const newPost = toTextWithReaction([data.data], this.host);
for (const stream of this.currentSubscribe) {
this.wsConnection.send(
JSON.stringify({
stream,
event: "status.update",
payload: JSON.stringify(newPost[0]),
}),
);
}
});
} else if (payload.type === "deleted") {
// delete
for (const stream of this.currentSubscribe) {
this.wsConnection.send(
JSON.stringify({
stream,
event: "delete",
payload: payload.id,
}),
);
}
} else if (payload.type === "unreadNotification") {
if (payload.id === "user") {
const body = Converter.notification(payload.body, this.host);
if (body.type === "reaction") body.type = "favourite";
body.status = toTextWithReaction(
body.status ? [body.status] : [],
"",
)[0];
this.wsConnection.send(
JSON.stringify({
stream: ["user"],
event: "notification",
payload: JSON.stringify(body),
}),
);
}
}
} else {
this.wsConnection.send(
JSON.stringify({
type: type,
body: payload,
}),
);
}
} }
/** /**

View File

@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => {
ws.on("request", async (request) => { ws.on("request", async (request) => {
const q = request.resourceURL.query as ParsedUrlQuery; const q = request.resourceURL.query as ParsedUrlQuery;
const headers = request.httpRequest.headers['sec-websocket-protocol'] || '';
const cred = q.i || q.access_token || headers;
const accessToken = cred.toString();
const [user, app] = await authenticate( const [user, app] = await authenticate(
request.httpRequest.headers.authorization, request.httpRequest.headers.authorization,
q.i, accessToken,
).catch((err) => { ).catch((err) => {
request.reject(403, err.message); request.reject(403, err.message);
return []; return [];
@ -43,8 +46,11 @@ export const initializeStreamingServer = (server: http.Server) => {
} }
redisClient.on("message", onRedisMessage); redisClient.on("message", onRedisMessage);
const host = `https://${request.host}`;
const prepareStream = q.stream?.toString();
console.log('start', q);
const main = new MainStreamConnection(connection, ev, user, app); const main = new MainStreamConnection(connection, ev, user, app, host, accessToken, prepareStream);
const intervalId = user const intervalId = user
? setInterval(() => { ? setInterval(() => {

View File

@ -20,6 +20,8 @@ import { createTemp } from "@/misc/create-temp.js";
import { publishMainStream } from "@/services/stream.js"; import { publishMainStream } from "@/services/stream.js";
import * as Acct from "@/misc/acct.js"; import * as Acct from "@/misc/acct.js";
import { envOption } from "@/env.js"; import { envOption } from "@/env.js";
import { koaBody } from 'koa-body';
import megalodon, { MegalodonInterface } from '@cutls/megalodon';
import activityPub from "./activitypub.js"; import activityPub from "./activitypub.js";
import nodeinfo from "./nodeinfo.js"; import nodeinfo from "./nodeinfo.js";
import wellKnown from "./well-known.js"; import wellKnown from "./well-known.js";
@ -133,6 +135,34 @@ router.get("/verify-email/:code", async (ctx) => {
} }
}); });
router.get("/oauth/authorize", async (ctx) => {
const client_id = ctx.request.query.client_id;
console.log(ctx.request.req);
ctx.redirect(Buffer.from(client_id?.toString() || '', 'base64').toString());
});
router.post("/oauth/token", async (ctx) => {
const body: any = ctx.request.body;
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const generator = (megalodon as any).default;
const client = generator('misskey', BASE_URL, null) as MegalodonInterface;
const m = body.code.match(/^[a-zA-Z0-9-]+/);
if (!m.length) return { error: 'Invalid code' }
try {
const atData = await client.fetchAccessToken(null, body.client_secret, m[0]);
ctx.body = {
access_token: atData.accessToken,
token_type: 'Bearer',
scope: 'read write follow',
created_at: new Date().getTime() / 1000
};
} catch (err: any) {
console.error(err);
ctx.status = 401;
ctx.body = err.response.data;
}
});
// Register router // Register router
app.use(router.routes()); app.use(router.routes());

View File

@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => {
ctx.status = 503; ctx.status = 503;
ctx.set("Cache-Control", "private, max-age=0"); ctx.set("Cache-Control", "private, max-age=0");
}); });
router.get("/api/v1/streaming", async (ctx) => {
ctx.status = 503;
ctx.set("Cache-Control", "private, max-age=0");
});
// Render base html for all requests // Render base html for all requests
router.get("(.*)", async (ctx) => { router.get("(.*)", async (ctx) => {

View File

@ -44,6 +44,14 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
logger.succ(`Got preview of ${url}: ${summary.title}`); logger.succ(`Got preview of ${url}: ${summary.title}`);
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
summary.icon = wrap(summary.icon); summary.icon = wrap(summary.icon);
summary.thumbnail = wrap(summary.thumbnail); summary.thumbnail = wrap(summary.thumbnail);

View File

@ -7,11 +7,25 @@
"lint": "pnpm rome check \"src/**/*.{ts,vue}\"" "lint": "pnpm rome check \"src/**/*.{ts,vue}\""
}, },
"dependencies": { "dependencies": {
"@khmyznikov/pwa-install": "^0.2.0"
},
"devDependencies": {
"@discordapp/twemoji": "14.0.2", "@discordapp/twemoji": "14.0.2",
"@rollup/plugin-alias": "3.1.9", "@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0", "@rollup/plugin-json": "4.1.0",
"@rollup/pluginutils": "^4.2.1", "@rollup/pluginutils": "^4.2.1",
"@syuilo/aiscript": "0.11.1", "@syuilo/aiscript": "0.11.1",
"@types/escape-regexp": "0.0.1",
"@types/glob": "8.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/katex": "0.14.0",
"@types/matter-js": "0.18.2",
"@types/punycode": "2.1.0",
"@types/seedrandom": "3.0.4",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "8.3.4",
"@vitejs/plugin-vue": "4.0.0", "@vitejs/plugin-vue": "4.0.0",
"@vue/compiler-sfc": "3.2.45", "@vue/compiler-sfc": "3.2.45",
"autobind-decorator": "2.4.0", "autobind-decorator": "2.4.0",
@ -26,6 +40,8 @@
"chartjs-plugin-zoom": "1.2.1", "chartjs-plugin-zoom": "1.2.1",
"compare-versions": "5.0.3", "compare-versions": "5.0.3",
"cropperjs": "2.0.0-beta.2", "cropperjs": "2.0.0-beta.2",
"cross-env": "7.0.3",
"cypress": "10.11.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"eventemitter3": "4.0.7", "eventemitter3": "4.0.7",
@ -40,9 +56,11 @@
"punycode": "2.1.1", "punycode": "2.1.1",
"querystring": "0.2.1", "querystring": "0.2.1",
"rndstr": "1.0.0", "rndstr": "1.0.0",
"rollup": "3.9.1",
"s-age": "1.1.2", "s-age": "1.1.2",
"sass": "1.57.1", "sass": "1.57.1",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"start-server-and-test": "1.15.2",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"swiper": "^8.4.5", "swiper": "^8.4.5",
@ -57,28 +75,11 @@
"typescript": "4.9.4", "typescript": "4.9.4",
"uuid": "9.0.0", "uuid": "9.0.0",
"vanilla-tilt": "1.8.0", "vanilla-tilt": "1.8.0",
"vite": "^4.1.0-beta.1", "vite": "^4.1.1",
"vue": "3.2.45", "vue": "3.2.45",
"vue-isyourpasswordsafe": "^2.0.0", "vue-isyourpasswordsafe": "^2.0.0",
"vue-plyr": "^7.0.0", "vue-plyr": "^7.0.0",
"vue-prism-editor": "2.0.0-alpha.2", "vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "4.1.0" "vuedraggable": "4.1.0"
},
"devDependencies": {
"@types/escape-regexp": "0.0.1",
"@types/glob": "8.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/katex": "0.14.0",
"@types/matter-js": "0.18.2",
"@types/punycode": "2.1.0",
"@types/seedrandom": "3.0.4",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "8.3.4",
"cross-env": "7.0.3",
"cypress": "10.11.0",
"rollup": "3.9.1",
"start-server-and-test": "1.15.2"
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<button class="nrvgflfu _button" @click="toggle"> <button class="nrvgflfu _button" @click.stop.prevent="toggle">
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b> <b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
<span v-if="!modelValue">{{ label }}</span> <span v-if="!modelValue">{{ label }}</span>
</button> </button>

View File

@ -1,12 +1,21 @@
<template> <template>
<button <button
class="kpoogebi _button" class="kpoogebi _button"
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" :class="{
wait,
active: isFollowing || hasPendingFollowRequestFromYou,
full,
large,
blocking: isBlocking
}"
:disabled="wait" :disabled="wait"
@click="onClick" @click="onClick"
> >
<template v-if="!wait"> <template v-if="!wait">
<template v-if="hasPendingFollowRequestFromYou && user.isLocked"> <template v-if="isBlocking">
<span v-if="full">{{ i18n.ts.blocked }}</span><i class="ph-prohibit-bold ph-lg"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium-bold ph-lg"></i> <span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium-bold ph-lg"></i>
</template> </template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
@ -30,12 +39,13 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted } from 'vue'; import { computed, onBeforeUnmount, onMounted } from 'vue';
import type * as Misskey from 'calckey-js'; import type * as Misskey from 'calckey-js';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const emit = defineEmits(['refresh'])
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed, user: Misskey.entities.UserDetailed,
full?: boolean, full?: boolean,
@ -45,6 +55,8 @@ const props = withDefaults(defineProps<{
large: false, large: false,
}); });
const isBlocking = computed(() => props.user.isBlocking);
let isFollowing = $ref(props.user.isFollowing); let isFollowing = $ref(props.user.isFollowing);
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
let wait = $ref(false); let wait = $ref(false);
@ -68,7 +80,24 @@ async function onClick() {
wait = true; wait = true;
try { try {
if (isFollowing) { if (isBlocking.value) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('unblockConfirm'),
});
if (canceled) return
await os.api("blocking/delete", {
userId: props.user.id,
})
if (props.user.isMuted) {
await os.api("mute/delete", {
userId: props.user.id,
})
}
emit('refresh')
}
else if (isFollowing) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
@ -184,4 +213,9 @@ onBeforeUnmount(() => {
margin-right: 6px; margin-right: 6px;
} }
} }
.blocking {
background-color: var(--bg) !important;
border: none;
}
</style> </style>

View File

@ -2,7 +2,7 @@
<div class="hoawjimk"> <div class="hoawjimk">
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }"> <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }">
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length"> <div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop.prevent>
<template v-for="media in mediaList.filter(media => previewable(media))"> <template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/> <XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/> <XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>

View File

@ -26,7 +26,7 @@
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span> <span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
</a> </a>
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <button v-else-if="item.type === 'user' && !items.hidden" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span> <span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
</button> </button>
@ -38,7 +38,7 @@
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
<span class="caret"><i class="ph-caret-right-bold ph-lg ph-fw ph-lg"></i></span> <span class="caret"><i class="ph-caret-right-bold ph-lg ph-fw ph-lg"></i></span>
</button> </button>
<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <button v-else-if="!item.hidden" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ph-fw ph-lg" :class="item.icon"></i> <i v-if="item.icon" class="ph-fw ph-lg" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span> <span>{{ item.text }}</span>

View File

@ -62,10 +62,10 @@
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> <XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> <div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false"> <button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
<span>{{ i18n.ts.showMore }}</span> <span>{{ i18n.ts.showMore }}</span>
</button> </button>
<button v-else-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true"> <button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
<span>{{ i18n.ts.showLess }}</span> <span>{{ i18n.ts.showLess }}</span>
</button> </button>
</div> </div>

View File

@ -14,10 +14,10 @@
<summary>{{ i18n.ts.poll }}</summary> <summary>{{ i18n.ts.poll }}</summary>
<XPoll :note="note"/> <XPoll :note="note"/>
</div> </div>
<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false"> <button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
<span>{{ i18n.ts.showMore }}</span> <span>{{ i18n.ts.showMore }}</span>
</button> </button>
<button v-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true"> <button v-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
<span>{{ i18n.ts.showLess }}</span> <span>{{ i18n.ts.showLess }}</span>
</button> </button>
</div> </div>

View File

@ -67,6 +67,7 @@ const embedId = `embed${Math.random().toString().replace(/\D/,'')}`;
let tweetHeight = $ref(150); let tweetHeight = $ref(150);
const requestUrl = new URL(props.url); const requestUrl = new URL(props.url);
if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url');
if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') { if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') {
const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);

View File

@ -169,13 +169,12 @@ const props = withDefaults(defineProps<{
text-overflow: ellipsis; text-overflow: ellipsis;
} }
::v-deep(.quote) { ::v-deep(blockquote) {
display: block; display: block;
margin: 8px; margin: 8px 0;
padding: 6px 0 6px 12px; padding-left: 12px;
color: var(--fg); color: var(--fgTransparentWeak);
border-left: solid 3px var(--fg); border-left: solid 4px var(--fgTransparent);
opacity: 0.7;
} }
::v-deep(pre) { ::v-deep(pre) {

View File

@ -16,10 +16,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="tabs"> <div ref="tabsEl" v-if="hasTabs" class="tabs">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<i v-if="tab.icon" class="icon" :class="tab.icon"></i> <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
<span v-if="!tab.iconOnly && !narrow" class="title">{{ tab.title }}</span> <span v-if="deviceKind !== 'desktop' || isTouchUsing || (!tab.iconOnly && !narrow)" class="title">{{ tab.title }}</span>
</button> </button>
<div ref="tabHighlightEl" class="highlight"></div> <div ref="tabHighlightEl" class="highlight"></div>
</div> </div>
@ -39,6 +39,8 @@ import { popupMenu } from '@/os';
import { scrollToTop } from '@/scripts/scroll'; import { scrollToTop } from '@/scripts/scroll';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { globalEvents } from '@/events'; import { globalEvents } from '@/events';
import { deviceKind } from '@/scripts/device-kind';
import { isTouchUsing } from '@/scripts/touch';
import { injectPageMetadata } from '@/scripts/page-metadata'; import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
@ -74,6 +76,7 @@ const thin_ = props.thin || inject('shouldHeaderThin', false);
const el = $ref<HTMLElement | null>(null); const el = $ref<HTMLElement | null>(null);
const tabRefs = {}; const tabRefs = {};
const tabHighlightEl = $ref<HTMLElement | null>(null); const tabHighlightEl = $ref<HTMLElement | null>(null);
const tabsEl = $ref<HTMLElement | null>(null);
const bg = ref(null); const bg = ref(null);
let narrow = $ref(false); let narrow = $ref(false);
const height = ref(0); const height = ref(0);
@ -150,10 +153,12 @@ onMounted(() => {
if (tabEl && tabHighlightEl) { if (tabEl && tabHighlightEl) {
// offsetWidth offsetLeft getBoundingClientRect 使 // offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabEl.parentElement.getBoundingClientRect(); const parentRect = tabsEl.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect(); const rect = tabEl.getBoundingClientRect();
const left = (rect.left - parentRect.left + tabsEl?.scrollLeft);
tabHighlightEl.style.width = rect.width + 'px'; tabHighlightEl.style.width = rect.width + 'px';
tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; tabHighlightEl.style.left = left + 'px';
tabsEl.scrollTo({left: left - 80, behavior: "smooth"});
} }
}); });
}, { }, {
@ -199,8 +204,6 @@ onUnmounted(() => {
} }
&.slim { &.slim {
text-align: center;
> .titleContainer { > .titleContainer {
flex: 1; flex: 1;
margin: 0 auto; margin: 0 auto;
@ -213,13 +216,26 @@ onUnmounted(() => {
margin-right: auto; margin-right: auto;
} }
} }
> .tabs {
padding-inline: 12px;
mask: linear-gradient(to right, black 80%, transparent);
-webkit-mask: linear-gradient(to right, black 80%, transparent);
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&::after { // Force right padding
content: "";
display: inline-block;
min-width: 20%;
}
}
} }
> .buttons { > .buttons {
--margin: 8px; --margin: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
min-width: var(--height);
height: var(--height); height: var(--height);
margin: 0 var(--margin); margin: 0 var(--margin);
@ -242,7 +258,7 @@ onUnmounted(() => {
} }
&:empty { &:empty {
width: var(--height); display: none;
} }
> .button { > .button {
@ -331,16 +347,18 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
font-size: 1em; font-size: 1em;
overflow: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
> .tab { > .tab {
display: inline-block; display: inline-flex;
align-items: center;
position: relative; position: relative;
padding: 0 10px; padding: 0 10px;
height: 100%; height: 100%;
font-weight: normal; font-weight: normal;
opacity: 0.7; opacity: 0.7;
transition: color .2s, opacity .2s;
&:hover { &:hover {
opacity: 1; opacity: 1;
@ -348,6 +366,8 @@ onUnmounted(() => {
&.active { &.active {
opacity: 1; opacity: 1;
color: var(--accent);
font-weight: 600;
} }
> .icon + .title { > .icon + .title {

View File

@ -33,6 +33,7 @@ const props = defineProps<{
const self = props.url.startsWith(local); const self = props.url.startsWith(local);
const url = new URL(props.url); const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref(); const el = ref();
useTooltip(el, (showing) => { useTooltip(el, (showing) => {

View File

@ -379,10 +379,7 @@ export default defineComponent({
if (!this.nowrap) { if (!this.nowrap) {
return [ return [
h( h(
"div", "blockquote",
{
class: "quote",
},
genEl(token.children), genEl(token.children),
), ),
]; ];

View File

@ -5,6 +5,10 @@ import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { ui } from "@/config"; import { ui } from "@/config";
import { unisonReload } from "@/scripts/unison-reload"; import { unisonReload } from "@/scripts/unison-reload";
import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { host } from '@/config';
import XTutorial from '@/components/MkTutorialDialog.vue';
export const navbarItemDef = reactive({ export const navbarItemDef = reactive({
notifications: { notifications: {
@ -144,4 +148,59 @@ export const navbarItemDef = reactive({
location.reload(); location.reload();
}, },
}, },
help: {
title: "help",
icon: "ph-question-bold ph-lg",
action: (ev) => {
os.popupMenu([{
text: instance.name ?? host,
type: 'label',
}, {
type: 'link',
text: i18n.ts.instanceInfo,
icon: 'ph-info-bold ph-lg',
to: '/about',
}, {
type: 'link',
text: i18n.ts.aboutMisskey,
icon: 'ph-lightbulb-bold ph-lg',
to: '/about-calckey',
}, {
type: 'link',
text: i18n.ts._apps.apps,
icon: 'ph-device-mobile-bold ph-lg',
to: '/apps',
}, {
type: 'button',
action: async () => {
defaultStore.set('tutorial', 0);
os.popup(XTutorial, {}, {}, 'closed');
},
text: i18n.ts.replayTutorial,
icon: 'ph-circle-wavy-question-bold ph-lg',
}, null, {
type: 'parent',
text: i18n.ts.developer,
icon: 'ph-code-bold ph-lg',
children: [{
type: 'link',
to: '/api-console',
text: 'API Console',
icon: 'ph-terminal-window-bold ph-lg',
}, {
text: i18n.ts.document,
icon: 'ph-file-doc-bold ph-lg',
action: () => {
window.open('/api-doc', '_blank');
},
}, {
type: 'link',
to: '/scratchpad',
text: 'AiScript Scratchpad',
icon: 'ph-scribble-loop-bold ph-lg',
}]
}], ev.currentTarget ?? ev.target,
);
},
},
}); });

View File

@ -39,9 +39,10 @@
<FormSection> <FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template> <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<div class="_formLinks"> <div class="_formLinks">
<FormLink to="/@t1c@i.calckey.cloud"><Mfm :text="'$[sparkle @t1c@i.calckey.cloud] (Main developer)'"/></FormLink> <FormLink to="/@kainoa@calckey.social"><Mfm :text="'$[sparkle @kainoa@calckey.social] (Main developer)'"/></FormLink>
<FormLink to="/@cleo@bz.pawdev.me"><Mfm :text="'@cleo@bz.pawdev.me (Maintainer)'"/></FormLink> <FormLink to="/@cleo@bz.pawdev.me"><Mfm :text="'@cleo@bz.pawdev.me (Maintainer)'"/></FormLink>
<FormLink to="/@syuilo@misskey.io"><Mfm :text="'@syuilo@misskey.io (Original Misskey developer)'"/></FormLink> <FormLink to="/@panos@i.calckey.cloud"><Mfm :text="'@panos@i.calckey.cloud (Management)'"/></FormLink>
<FormLink to="/@freeplay@bz.pawdev.me"><Mfm :text="'@freeplay@bz.pawdev.me (UI/UX Designer)'"/></FormLink>
<FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink> <FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink>
</div> </div>
<template #caption><MkLink url="https://codeberg.org/calckey/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> <template #caption><MkLink url="https://codeberg.org/calckey/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>

View File

@ -0,0 +1,99 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<div style="overflow: clip;">
<MkSpacer :content-max="600" :margin-min="20">
<div class="_formRoot">
<FormSection>
<template #label>{{ i18n.ts._apps.crossPlatform }}</template>
<div class="_formBlock" style="text-align: center;">
<pwa-install/>
<MkButton primary rounded inline @click="installPwa">{{ i18n.ts._apps.pwa }}</MkButton>
</div>
<div class="_formLinks">
<FormLink to="https://kaiteki.app" external>
<template #icon>
<i class="ph-android-logo-bold ph-xl"/>
<i class="ph-windows-logo-bold ph-xl"/>
<i class="ph-linux-logo-bold ph-xl"/>
</template>
{{ i18n.ts._apps.kaiteki }}
<template #suffix>{{ i18n.ts._apps.firstClass }}, {{ i18n.ts._apps.free }}</template>
</FormLink>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._apps.mobile }}</template>
<div class="_formLinks">
<FormLink to="https://play.google.com/store/apps/details?id=jp.panta.misskeyandroidclient" external>
<template #icon>
<i class="ph-android-logo-bold ph-xl"/>
</template>
{{ i18n.ts._apps.milktea }}
<template #suffix>{{ i18n.ts._apps.firstClass }}, {{ i18n.ts._apps.free }}</template>
</FormLink>
<FormLink to="https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter&gl=US" external>
<template #icon>
<i class="ph-android-logo-bold ph-xl"/>
</template>
{{ i18n.ts._apps.subwayTooter }}
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
</FormLink>
<!-- <FormLink to="https://apps.apple.com/app/kimis-a-client-for-misskey/id1667275125" external>
<template #icon>
<i class="ph-apple-logo-bold ph-xl"/>
</template>
{{ i18n.ts._apps.kimis }}
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.paid }}</template>
</FormLink> -->
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.desktop }}</template>
<div class="_formLinks">
<FormLink to="https://thedesk.top/" external>
<template #icon>
<i class="ph-apple-logo-bold ph-xl"/>
<i class="ph-windows-logo-bold ph-xl"/>
<i class="ph-linux-logo-bold ph-xl"/>
</template>
{{ i18n.ts._apps.theDesk }}
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
</FormLink>
<FormLink to="https://github.com/AsPulse/lesskey" external>
<template #icon>
<i class="ph-terminal-window-bold ph-xl"/>
</template>
{{ i18n.ts._apps.lesskey }}
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
</FormLink>
</div>
</FormSection>
</div>
</MkSpacer>
</div>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import '@khmyznikov/pwa-install';
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
function installPwa(ev: MouseEvent) {
const pwaInstall = document.getElementsByTagName('pwa-install')[0];
pwaInstall.showDialog();
}
definePageMetadata({
title: i18n.ts._apps.apps,
icon: null,
});
</script>

View File

@ -78,8 +78,11 @@ export default defineComponent({
methods: { methods: {
accepted() { accepted() {
this.state = 'accepted'; this.state = 'accepted';
const getUrlParams = () => window.location.search.substring(1).split('&').reduce((result, query) => { const [k, v] = query.split('='); result[k] = decodeURI(v); return result; }, {});
if (this.session.app.callbackUrl) { if (this.session.app.callbackUrl) {
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; const url = new URL(this.session.app.callbackUrl);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url');
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}&code=${this.session.token}&state=${getUrlParams().state || ''}`;
} }
}, onLogin(res) { }, onLogin(res) {
login(res.i); login(res.i);

View File

@ -70,13 +70,14 @@ async function accept(): Promise<void> {
state = 'accepted'; state = 'accepted';
if (props.callback) { if (props.callback) {
const cbUrl = new URL(props.callback);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url');
location.href = appendQuery(props.callback, query({ location.href = appendQuery(props.callback, query({
session: props.session, session: props.session,
})); }));
} }
} }
function deny(): void {
state = 'denied'; state = 'denied';
} }

View File

@ -44,7 +44,7 @@
</div> </div>
<div class="follow-container"> <div class="follow-container">
<div class="actions"> <div class="actions">
<MkFollowButton v-if="$i != null && $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="!narrow" class="koudoku"/> <MkFollowButton v-if="$i != null && $i.id != user.id" :user="user" @refresh="emit('refresh')" :inline="true" :transparent="false" :full="!narrow" class="koudoku"/>
<button class="menu _button" @click="menu"><i class="ph-dots-three-outline-bold ph-lg"></i></button> <button class="menu _button" @click="menu"><i class="ph-dots-three-outline-bold ph-lg"></i></button>
<!-- <MkFollowButton v-else-if="$i == null" :user="user" :remote="true" :inline="true" :transparent="false" :full="true" class="koudoku"/> --> <!-- <MkFollowButton v-else-if="$i == null" :user="user" :remote="true" :inline="true" :transparent="false" :full="true" class="koudoku"/> -->
</div> </div>
@ -142,6 +142,7 @@ import { host } from '@/config';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const emit = defineEmits(['refresh']);
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: misskey.entities.UserDetailed; user: misskey.entities.UserDetailed;
}>(), { }>(), {

View File

@ -10,7 +10,7 @@
<div> <div>
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<div v-if="user"> <div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/> <XHome v-if="tab === 'home'" :user="user" @refresh="fetchUser()"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/> <XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/> <XClips v-else-if="tab === 'clips'" :user="user"/>
<XPages v-else-if="tab === 'pages'" :user="user"/> <XPages v-else-if="tab === 'pages'" :user="user"/>

View File

@ -282,6 +282,10 @@ export const routes = [
path: "/about-calckey", path: "/about-calckey",
component: page(() => import("./pages/about-calckey.vue")), component: page(() => import("./pages/about-calckey.vue")),
}, },
{
path: "/apps",
component: page(() => import("./pages/apps.vue")),
},
{ {
path: "/theme-editor", path: "/theme-editor",
component: page(() => import("./pages/theme-editor.vue")), component: page(() => import("./pages/theme-editor.vue")),

View File

@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
return confirm.canceled ? values.FALSE : values.TRUE; return confirm.canceled ? values.FALSE : values.TRUE;
}), }),
"Mk:api": values.FN_NATIVE(async ([ep, param, token]) => { "Mk:api": values.FN_NATIVE(async ([ep, param, token]) => {
if (token) utils.assertString(token); if (token) {
utils.assertString(token);
// バグがあればundefinedもあり得るため念のため
if (typeof token.value !== 'string') throw new Error('invalid token');
}
apiRequests++; apiRequests++;
if (apiRequests > 16) return values.NULL; if (apiRequests > 16) return values.NULL;
const res = await os.api( const res = await os.api(

View File

@ -125,11 +125,20 @@ export function getUserMenu(user, router: Router = mainRouter) {
) )
return; return;
os.apiWithDialog(user.isBlocking ? "blocking/delete" : "blocking/create", { await os.apiWithDialog(user.isBlocking ? "blocking/delete" : "blocking/create", {
userId: user.id, userId: user.id,
}).then(() => { })
user.isBlocking = !user.isBlocking; user.isBlocking = !user.isBlocking;
}); await os.api(user.isBlocking ? "mute/create" : "mute/delete", {
userId: user.id,
})
user.isMuted = user.isBlocking;
if (user.isBlocking) {
await os.api('following/delete', {
userId: user.id,
});
user.isFollowing = false
}
} }
async function toggleSilence() { async function toggleSilence() {
@ -252,6 +261,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
{ {
icon: user.isMuted ? "ph-eye-bold ph-lg" : "ph-eye-slash-bold ph-lg", icon: user.isMuted ? "ph-eye-bold ph-lg" : "ph-eye-slash-bold ph-lg",
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
hidden: user.isBlocking === true,
action: toggleMute, action: toggleMute,
}, },
{ {

View File

@ -28,6 +28,7 @@ export type MenuUser = {
user: Misskey.entities.User; user: Misskey.entities.User;
active?: boolean; active?: boolean;
indicate?: boolean; indicate?: boolean;
hidden?: boolean;
action: MenuAction; action: MenuAction;
}; };
export type MenuSwitch = { export type MenuSwitch = {
@ -43,6 +44,7 @@ export type MenuButton = {
indicate?: boolean; indicate?: boolean;
danger?: boolean; danger?: boolean;
active?: boolean; active?: boolean;
hidden?: boolean;
avatar?: Misskey.entities.User; avatar?: Misskey.entities.User;
action: MenuAction; action: MenuAction;
}; };

View File

@ -34,9 +34,6 @@
<button class="item _button post" data-cy-open-post-form @click="os.post"> <button class="item _button post" data-cy-open-post-form @click="os.post">
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span> <i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
</button> </button>
<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -44,12 +41,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue'; import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
import { host } from '@/config';
import * as os from '@/os'; import * as os from '@/os';
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from '@/navbar';
import { openAccountMenu as openAccountMenu_ } from '@/account'; import { openAccountMenu as openAccountMenu_ } from '@/account';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
const menu = toRef(defaultStore.state, 'menu'); const menu = toRef(defaultStore.state, 'menu');
@ -67,50 +62,6 @@ function openAccountMenu(ev: MouseEvent) {
}, ev); }, ev);
} }
function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
text: instance.name ?? host,
type: 'label',
}, {
type: 'link',
text: i18n.ts.instanceInfo,
icon: 'ph-info-bold ph-lg',
to: '/about',
}, null, {
type: 'parent',
text: i18n.ts.help,
icon: 'ph-question-bold ph-lg',
children: [{
type: 'link',
to: '/mfm-cheat-sheet',
text: i18n.ts._mfm.cheatSheet,
icon: 'ph-code-bold ph-lg',
}, {
type: 'link',
to: '/scratchpad',
text: i18n.ts.scratchpad,
icon: 'ph-terminal-window-bold ph-lg',
}, {
type: 'link',
to: '/api-console',
text: 'API Console',
icon: 'ph-terminal-window-bold ph-lg',
}, null, {
text: i18n.ts.document,
icon: 'ph-question-bold ph-lg',
action: () => {
window.open('/api-doc', '_blank');
},
}],
}, {
type: 'link',
text: i18n.ts.aboutMisskey,
to: '/about-calckey',
}], ev.currentTarget ?? ev.target, {
align: 'left',
});
}
function more() { function more() {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, { os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
}, 'closed'); }, 'closed');

View File

@ -44,9 +44,14 @@
<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post"> <button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span> <i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
</button> </button>
<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> <!-- <div class="help">
<button v-tooltip.noDelay.right="i18n.ts.help" class="item _button" @click="openHelpMenu">
<i class="icon ph-info-bold ph-xl ph-fw ph-lg"></i>
</button>
</div> -->
<!-- <button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
</button> </button> -->
<!-- <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu"> <!-- <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="account"/><MkAcct class="text" :user="$i"/> <MkAvatar :user="$i" class="account"/><MkAcct class="text" :user="$i"/>
</button> --> </button> -->
@ -57,14 +62,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { computed, defineAsyncComponent, ref, watch } from 'vue';
import XTutorial from '@/components/MkTutorialDialog.vue';
import * as os from '@/os'; import * as os from '@/os';
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from '@/navbar';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { host, version } from '@/config'; import { version } from '@/config';
const isEmpty = (x: string | null) => x == null || x === ''; const isEmpty = (x: string | null) => x == null || x === '';
@ -122,58 +126,6 @@ function openAccountMenu(ev: MouseEvent) {
}, ev); }, ev);
} }
function openInstanceMenu(ev: MouseEvent) {
os.popupMenu([{
text: instance.name ?? host,
type: 'label',
}, {
type: 'link',
text: i18n.ts.instanceInfo,
icon: 'ph-info-bold ph-lg',
to: '/about',
}, null, {
type: 'parent',
text: i18n.ts.help,
icon: 'ph-question-bold ph-lg',
children: [{
type: 'link',
to: '/mfm-cheat-sheet',
text: i18n.ts._mfm.cheatSheet,
icon: 'ph-code-bold ph-lg',
}, {
type: 'button',
action: async () => {
defaultStore.set('tutorial', 0);
os.popup(XTutorial, {}, {}, 'closed');
},
text: i18n.ts.replayTutorial,
icon: 'ph-circle-wavy-question-bold ph-lg',
}, {
type: 'link',
to: '/scratchpad',
text: i18n.ts.scratchpad,
icon: 'ph-terminal-window-bold ph-lg',
}, {
type: 'link',
to: '/api-console',
text: 'API Console',
icon: 'ph-terminal-window-bold ph-lg',
}, null, {
text: i18n.ts.document,
icon: 'ph-question-bold ph-lg',
action: () => {
window.open('https://misskey-hub.net/help.html', '_blank');
},
}],
}, {
type: 'link',
text: i18n.ts.aboutMisskey,
to: '/about-calckey',
}], ev.currentTarget ?? ev.target, {
align: 'left',
});
}
function more(ev: MouseEvent) { function more(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
src: ev.currentTarget ?? ev.target, src: ev.currentTarget ?? ev.target,
@ -296,7 +248,6 @@ function more(ev: MouseEvent) {
> .text { > .text {
margin-left: 1rem; margin-left: 1rem;
} }
} }
> .instance { > .instance {

View File

@ -46,10 +46,10 @@ export default defineConfig(({ command, mode }) => {
build: { build: {
target: [ target: [
'chrome108', 'chrome87',
'firefox109', 'firefox78',
'safari16', 'safari14',
'es2022', 'es2017',
], ],
manifest: 'manifest.json', manifest: 'manifest.json',
rollupOptions: { rollupOptions: {

View File

@ -7,15 +7,14 @@
"lint": "pnpm rome check \"src/**/*.ts\"" "lint": "pnpm rome check \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@swc/cli": "^0.1.59",
"@swc/core": "^1.3.26",
"calckey-js": "^0.0.20",
"idb-keyval": "^6.2.0"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11"
}, },
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.59",
"@swc/core": "^1.3.26",
"@swc/core-android-arm64": "1.3.11",
"calckey-js": "^0.0.20",
"idb-keyval": "^6.2.0",
"swc-loader": "^0.2.3", "swc-loader": "^0.2.3",
"webpack": "^5.75.0" "webpack": "^5.75.0"
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"version": "13.1.0", "version": "13.1.3-rc",
"notes": "This release includes many changes, including:\n\n• New post and thread layout\n• Automatic subdomain blocks\n• Customizable default reactions\n• Federation improvements\n• Many bug fixes and performance improvements", "notes": "This release candidate has the following changes:\n• Better blocking/muting\n• Better user refreshing\n• New help menu with app list (More! > Help)\n• Bug fixes and performance improvements",
"screenshots": [] "screenshots": []
} }