Merge branch 'develop' into feature/edits

This commit is contained in:
Kainoa Kanter 2023-02-19 18:29:40 +00:00
commit 361109e601
178 changed files with 17198 additions and 2412 deletions

View File

@ -1,16 +1,48 @@
.autogen
.github
.travis
.vscode
.config
# Visual Studio Code
/.vscode
!/.vscode/extensions.json
# 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
build/
built/
db/
docker-compose.yml
elasticsearch/
node_modules/
redis/
files/
misskey-assets/
.pnp.*

14
.gitignore vendored
View File

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

View File

@ -11,10 +11,5 @@ pipeline:
password:
# Secret 'docker_password' needs to be set in the CI settings
from_secret: docker_password
when:
# Push new version of tag latest if new push on main-branch
event: push
branch: main
depends_on:
- prSecurityCheck
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

@ -17,5 +17,3 @@ pipeline:
event: tag
tag: v*
depends_on:
- prSecurityCheck

View File

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

10329
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,51 @@
FROM node:19-alpine
ARG NODE_ENV=production
## Install dev and compilation dependencies, build files
FROM node:19-alpine as build
WORKDIR /calckey
# Copy Files
COPY . ./
# Install compilation dependencies
RUN apk add --no-cache --no-progress git alpine-sdk python3
# Install Dependencies
RUN apk update
RUN apk add git ffmpeg tini alpine-sdk python3
# Copy only the dependency-related files first, to cache efficiently
COPY package.json pnpm*.yaml ./
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
RUN corepack enable
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
# Remove git files
RUN rm -rf .git
# Trim down the dependencies to only the prod deps
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 vips-dev
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", "--" ]
CMD [ "pnpm", "run", "migrateandstart" ]

View File

@ -97,9 +97,10 @@ If you have access to a server that supports one of the sources below, I recomme
```sh
git clone --depth 1 https://codeberg.org/calckey/calckey.git
cd calckey/
# git checkout main # if you want only stable versions
```
By default, you're on the development branch. Run `git checkout beta` or `git checkout main` to switch to the Beta/Main branches.
## 📩 Install dependencies
```sh
@ -123,6 +124,8 @@ psql postgres -c "create database calckey with encoding = 'UTF8';"
- To add custom CSS for all users, edit `./custom/assets/instance.css`.
- To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be available on `https://yourinstance.tld/static-assets/filename.ext`.
- To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`)
- To add custom error images, place them in the `./custom/assets/badges` directory, replacing the files already there.
- To add custom sounds, place only mp3 files in the `./custom/assets/sounds` directory.
- To update custom assets without rebuilding, just run `pnpm run gulp`.
## 🧑‍🔬 Configuring a new instance

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -15,8 +15,9 @@ gulp.task('copy:backend:views', () =>
gulp.src('./packages/backend/src/server/web/views/**/*').pipe(gulp.dest('./packages/backend/built/server/web/views'))
);
gulp.task('copy:backend:custom', () =>
gulp.src('./custom/assets/*').pipe(gulp.dest('./packages/backend/assets/'))
gulp.src('./custom/assets/**/*').pipe(gulp.dest('./packages/backend/assets/'))
);
gulp.task('copy:client:fonts', () =>

View File

@ -1,7 +1,7 @@
---
_lang_: "Deutsch"
headlineMisskey: "Ein durch Notizen verbundenes Netzwerk"
introMisskey: "Willkommen! Misskey ist eine dezentralisierte Open-Source Microblogging-Platform.\nVerfasse „Notizen“ um mitzuteilen, was gerade passiert oder um Ereignisse mit anderen zu teilen. 📡\nMit „Reaktionen“ kannst du außerdem schnell deine Gefühle über Notizen anderer Benutzer zum Ausdruck bringen. 👍\nEine neue Welt wartet auf dich! 🚀"
headlineMisskey: "Ein durch Posts verbundenes Netzwerk"
introMisskey: "Willkommen! Calckey ist eine dezentralisierte Open-Source Microblogging-Platform.\nVerfasse „Posts“ um mitzuteilen, was gerade passiert oder um Ereignisse mit anderen zu teilen. 📡\nMit „Reaktionen“ kannst du außerdem schnell deine Gefühle über Posts anderer Benutzer zum Ausdruck bringen. 👍\nEine neue Welt wartet auf dich! 🚀"
monthAndDay: "{day}.{month}."
search: "Suchen"
notifications: "Benachrichtigungen"

View File

@ -32,6 +32,7 @@ uploading: "Uploading..."
save: "Save"
users: "Users"
addUser: "Add a user"
addInstance: "Add an instance"
favorite: "Add to bookmarks"
favorites: "Bookmarks"
unfavorite: "Remove from bookmarks"
@ -160,6 +161,7 @@ proxyAccount: "Proxy account"
proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead."
host: "Host"
selectUser: "Select a user"
selectInstance: "Select an instance"
recipient: "Recipient(s)"
annotation: "Comments"
federation: "Federation"
@ -197,6 +199,7 @@ muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users"
blockedUsers: "Blocked users"
noUsers: "There are no users"
noInstances: "There are no instances"
editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this post?"
pinLimitExceeded: "You cannot pin any more posts"
@ -363,6 +366,7 @@ notifyAntenna: "Notify about new posts"
withFileAntenna: "Only posts with files"
enableServiceworker: "Enable Push-Notifications for your Browser"
antennaUsersDescription: "List one username per line"
antennaInstancesDescription: "List one instance host per line"
caseSensitive: "Case sensitive"
withReplies: "Include replies"
connectedTo: "Following account(s) are connected"
@ -816,6 +820,7 @@ lastCommunication: "Last communication"
resolved: "Resolved"
unresolved: "Unresolved"
breakFollow: "Remove follower"
breakFollowConfirm: "Are you sure want to remove follower?"
itsOn: "Enabled"
itsOff: "Disabled"
emailRequiredForSignup: "Require email address for sign-up"
@ -1293,12 +1298,14 @@ _auth:
pleaseGoBack: "Please go back to the application"
callback: "Returning to the application"
denied: "Access denied"
copyAsk: "Please paste the following authorization code to the application"
_antennaSources:
all: "All posts"
homeTimeline: "Posts from followed users"
users: "Posts from specific users"
userList: "Posts from a specified list of users"
userGroup: "Posts from users in a specified group"
instances: "Posts from all users on an instance"
_weekday:
sunday: "Sunday"
monday: "Monday"
@ -1393,6 +1400,7 @@ _profile:
metadataContent: "Content"
changeAvatar: "Change avatar"
changeBanner: "Change banner"
locationDescription: "If entered properly, this will display your local time to other users."
_exportOrImport:
allNotes: "All posts"
followingList: "Followed users"
@ -1785,3 +1793,20 @@ _deck:
list: "List"
mentions: "Mentions"
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

@ -816,6 +816,7 @@ lastCommunication: "直近の通信"
resolved: "解決済み"
unresolved: "未解決"
breakFollow: "フォロワーを解除"
breakFollowConfirm: "フォロワー解除しますか?"
itsOn: "オンになっています"
itsOff: "オフになっています"
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする"

View File

@ -1,12 +1,12 @@
{
"name": "calckey",
"version": "13.1.0",
"version": "13.2.0-dev11",
"codename": "aqua",
"repository": {
"type": "git",
"url": "https://codeberg.org/calckey/calckey.git"
},
"packageManager": "pnpm@7.26.3",
"packageManager": "pnpm@7.27.1",
"private": true,
"scripts": {
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
@ -20,6 +20,7 @@
"gulp": "gulp build",
"watch": "pnpm run dev",
"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",
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
"cy:run": "cypress run",
@ -32,22 +33,14 @@
"cleanall": "pnpm run clean-all"
},
"resolutions": {
"chokidar": "^3.3.1",
"lodash": "^4.17.21"
"chokidar": "^3.3.1"
},
"dependencies": {
"@bull-board/api": "^4.10.2",
"@bull-board/ui": "^4.10.2",
"@tensorflow/tfjs": "^3.21.0",
"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",
"calckey-js": "^0.0.22",
"js-yaml": "4.1.0",
"long": "^5.2.1",
"phosphor-icons": "^1.4.2",
"seedrandom": "^3.0.5"
},
@ -56,6 +49,12 @@
"@types/gulp-rename": "2.0.1",
"cross-env": "7.0.3",
"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",
"rome": "^11.0.0",
"start-server-and-test": "1.15.2",

View File

@ -0,0 +1,25 @@
<svg id="svg10" version="1.1" sodipodi:docname="title_float.svg" inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="1.95 0.97 167.97 103.23">
<sodipodi:namedview id="namedview21" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showgrid="false" inkscape:zoom="1.1507704" inkscape:cx="260.69492" inkscape:cy="102.54" inkscape:window-width="1600" inkscape:window-height="931" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="svg10"/>
<metadata id="metadata16">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<linearGradient id="myGradient" gradientTransform="rotate(90)">
<stop offset="5%" stop-color="#9ccfd8" id="stop5" style="--darkreader-inline-stopcolor: #265760;" data-darkreader-inline-stopcolor=""/>
<stop offset="95%" stop-color="#31748f" id="stop7" style="--darkreader-inline-stopcolor: #275d72;" data-darkreader-inline-stopcolor=""/>
</linearGradient>
<defs id="defs14"/>
<g id="g8" fill="url('#myGradient')" word-spacing="0" letter-spacing="0" font-family="OTADESIGN Rounded" font-weight="400">
<g id="g17">
<g transform="matrix(.26953 0 0 .26953 -55.341 -52.023)" id="g11"/>
<g transform="matrix(3.6954 0 0 3.6954 208.34 -284.25)" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" id="g15">
<path d="m -41.8312,77.19 c -3.8683,0 -7.1782,1.3578 -9.9311,4.0734 -2.716,2.7525 -4.0734,6.0628 -4.0734,9.9311 5.0539,0.04979 6.082,0.01348 8.7525,0.0011 0.0024,-4.51e-4 0.0044,-6.05e-4 0.0069,-0.0011 0.03779,-2.8423 2.2103,-4.9346 5.2451,-5.2451 1.4137,0 2.6227,0.52089 3.6268,1.5629 0.855,0.8548 1.897,1.2822 3.1257,1.2822 1.2258,0 2.2676,-0.42741 3.1236,-1.2822 0.85499,-0.85567 1.2833,-1.8976 1.2833,-3.1257 0,-1.2264 -0.42828,-2.2673 -1.2833,-3.1231 -2.7528,-2.7156 -6.0446,-4.0734 -9.8761,-4.0734 z m -5.252,14.006 c -3.4453,-5.5934 -3.4667,0.08539 -8.7525,-0.0011 0,3.8683 1.3584,7.0406 4.0744,9.7931 2.7528,2.7156 6.0623,4.0734 9.9305,4.0734 3.8315,0 7.1238,-1.3578 9.8766,-4.0734 0.85499,-0.85577 1.2827,-1.8967 1.2827,-3.1231 0,-1.2282 -0.42775,-2.2701 -1.2827,-3.1257 -0.85596,-0.8548 -1.8978,-1.2822 -3.1236,-1.2822 -1.2287,0 -2.2707,0.42741 -3.1257,1.2822 -1.0041,1.042 -2.2136,1.5623 -3.6273,1.5623 -3.0348,-0.31051 -5.2084,-2.2633 -5.2462,-5.1056 -0.0024,1.1e-5 -0.0039,-1.2e-5 -0.0063,0 z m 26.154,-7.0965 c -2.8795,0 -5.3538,1.0204 -7.4227,3.0612 -0.64257,0.64316 -0.96404,1.4255 -0.96404,2.3472 0,0.92303 0.32146,1.7062 0.96404,2.3493 0.64331,0.64243 1.4265,0.96351 2.3477,0.96351 0.92347,0 1.7068,-0.32108 2.3493,-0.96351 0.75465,-0.78308 1.6632,-1.1744 2.7256,-1.1744 1.0894,0 2.0261,0.37695 2.8091,1.1316 0.75536,0.78309 1.1332,1.7201 1.1332,2.8102 0,1.0617 -0.39242,1.9703 -1.1754,2.7256 -0.39149,0.4193 -0.86676,0.69884 -1.4249,0.83878 -0.14116,0.02773 -0.25248,0.01369 -0.33614,-0.04175 -0.05605,-0.08456 -0.02751,-0.16827 0.08456,-0.25211 l 0.83825,-0.88053 c 0.64329,-0.64315 0.9651,-1.412 0.9651,-2.306 0,-0.92236 -0.27937,-1.6632 -0.83825,-2.2225 -0.55888,-0.55932 -1.3422,-0.83878 -2.3493,-0.83878 -0.6986,0 -1.397,0.34902 -2.0956,1.0475 l -4.8651,4.8223 c -0.64328,0.6438 -0.96457,1.4271 -0.96457,2.3488 0,0.92302 0.32128,1.7053 0.96457,2.3477 1.9568,1.9293 4.3751,2.8942 7.2546,2.8942 2.9072,0 5.3945,-1.0343 7.4634,-3.103 2.0412,-2.0409 3.0618,-4.501 3.0618,-7.3804 0,-2.9072 -1.0206,-5.3952 -3.0618,-7.4639 -2.0689,-2.0409 -4.5562,-3.0612 -7.4634,-3.0612 z" clip-rule="evenodd" fill-rule="nonzero" stroke-miterlimit="2" stroke-width="0" id="path13" sodipodi:nodetypes="cccccccscsccccccscscscccccccscscscscccccscsccscscsccc"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512.0px"
height="512.0px"
viewBox="0 0 512.0 512.0"
version="1.1"
id="SVGRoot"
sodipodi:docname="inverse wordmark.svg"
xml:space="preserve"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview71"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="0.50150542"
inkscape:cx="59.819892"
inkscape:cy="189.42966"
inkscape:window-width="1600"
inkscape:window-height="931"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"><inkscape:grid
type="xygrid"
id="grid77" /></sodipodi:namedview><defs
id="defs66" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><path
id="rect136"
style="fill:#31748f;fill-opacity:1;stroke-width:0.996356"
d="M 0,0 V 512 H 512 V 0 Z" /><g
id="g17"
style="font-weight:400;font-family:'OTADESIGN Rounded';letter-spacing:0;word-spacing:0;fill:#ffffff"
transform="matrix(2.0847185,0,0,2.0847185,76.820648,146.38203)"><g
transform="matrix(0.26953,0,0,0.26953,-55.341,-52.023)"
id="g11"
style="fill:#ffffff" /><g
transform="matrix(3.6954,0,0,3.6954,208.34,-284.25)"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
id="g15"
style="fill:#ffffff"><path
d="m -41.8312,77.19 c -3.8683,0 -7.1782,1.3578 -9.9311,4.0734 -2.716,2.7525 -4.0734,6.0628 -4.0734,9.9311 5.0539,0.04979 6.082,0.01348 8.7525,0.0011 0.0024,-4.51e-4 0.0044,-6.05e-4 0.0069,-0.0011 0.03779,-2.8423 2.2103,-4.9346 5.2451,-5.2451 1.4137,0 2.6227,0.52089 3.6268,1.5629 0.855,0.8548 1.897,1.2822 3.1257,1.2822 1.2258,0 2.2676,-0.42741 3.1236,-1.2822 0.85499,-0.85567 1.2833,-1.8976 1.2833,-3.1257 0,-1.2264 -0.42828,-2.2673 -1.2833,-3.1231 -2.7528,-2.7156 -6.0446,-4.0734 -9.8761,-4.0734 z m -5.252,14.006 c -3.4453,-5.5934 -3.4667,0.08539 -8.7525,-0.0011 0,3.8683 1.3584,7.0406 4.0744,9.7931 2.7528,2.7156 6.0623,4.0734 9.9305,4.0734 3.8315,0 7.1238,-1.3578 9.8766,-4.0734 0.85499,-0.85577 1.2827,-1.8967 1.2827,-3.1231 0,-1.2282 -0.42775,-2.2701 -1.2827,-3.1257 -0.85596,-0.8548 -1.8978,-1.2822 -3.1236,-1.2822 -1.2287,0 -2.2707,0.42741 -3.1257,1.2822 -1.0041,1.042 -2.2136,1.5623 -3.6273,1.5623 -3.0348,-0.31051 -5.2084,-2.2633 -5.2462,-5.1056 -0.0024,1.1e-5 -0.0039,-1.2e-5 -0.0063,0 z m 26.154,-7.0965 c -2.8795,0 -5.3538,1.0204 -7.4227,3.0612 -0.64257,0.64316 -0.96404,1.4255 -0.96404,2.3472 0,0.92303 0.32146,1.7062 0.96404,2.3493 0.64331,0.64243 1.4265,0.96351 2.3477,0.96351 0.92347,0 1.7068,-0.32108 2.3493,-0.96351 0.75465,-0.78308 1.6632,-1.1744 2.7256,-1.1744 1.0894,0 2.0261,0.37695 2.8091,1.1316 0.75536,0.78309 1.1332,1.7201 1.1332,2.8102 0,1.0617 -0.39242,1.9703 -1.1754,2.7256 -0.39149,0.4193 -0.86676,0.69884 -1.4249,0.83878 -0.14116,0.02773 -0.25248,0.01369 -0.33614,-0.04175 -0.05605,-0.08456 -0.02751,-0.16827 0.08456,-0.25211 l 0.83825,-0.88053 c 0.64329,-0.64315 0.9651,-1.412 0.9651,-2.306 0,-0.92236 -0.27937,-1.6632 -0.83825,-2.2225 -0.55888,-0.55932 -1.3422,-0.83878 -2.3493,-0.83878 -0.6986,0 -1.397,0.34902 -2.0956,1.0475 l -4.8651,4.8223 c -0.64328,0.6438 -0.96457,1.4271 -0.96457,2.3488 0,0.92302 0.32128,1.7053 0.96457,2.3477 1.9568,1.9293 4.3751,2.8942 7.2546,2.8942 2.9072,0 5.3945,-1.0343 7.4634,-3.103 2.0412,-2.0409 3.0618,-4.501 3.0618,-7.3804 0,-2.9072 -1.0206,-5.3952 -3.0618,-7.4639 -2.0689,-2.0409 -4.5562,-3.0612 -7.4634,-3.0612 z"
clip-rule="evenodd"
fill-rule="nonzero"
stroke-miterlimit="2"
stroke-width="0"
id="path13"
sodipodi:nodetypes="cccccccscsccccccscscscccccccscscscscccccscsccscscsccc"
style="fill:#ffffff" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,17 @@
export class AntennaInstances1676093997212 {
name = 'AntennaInstances1676093997212'
async up(queryRunner) {
await queryRunner.query(`ALTER TYPE "antenna_src_enum" ADD VALUE 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" ADD "instances" jsonb NOT NULL DEFAULT '[]'`);
}
async down(queryRunner) {
await queryRunner.query(`DELETE FROM "antenna" WHERE "src" = 'instances'`);
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "instances"`);
await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`);
await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`);
await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`);
await queryRunner.query(`ALTER TYPE "public"."antenna_src_enum_old" RENAME TO "antenna_src_enum"`);
}
}

View File

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

View File

@ -80,6 +80,13 @@ export async function checkHitAntenna(
)
)
return false;
} else if (antenna.src === "instances") {
const instances = antenna.instances
.filter((x) => x !== "")
.map((host) => {
return host.toLowerCase();
});
if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
}
const keywords = antenna.keywords

View File

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

View File

@ -11,26 +11,41 @@ const size = 128; // px
const n = 5; // resolution
const margin = size / 4;
const colors = [
["#FF512F", "#DD2476"],
["#FF61D2", "#FE9090"],
["#72FFB6", "#10D164"],
["#FD8451", "#FFBD6F"],
["#305170", "#6DFC6B"],
["#00C0FF", "#4218B8"],
["#009245", "#FCEE21"],
["#0100EC", "#FB36F4"],
["#FDABDD", "#374A5A"],
["#38A2D7", "#561139"],
["#121C84", "#8278DA"],
["#5761B2", "#1FC5A8"],
["#FFDB01", "#0E197D"],
["#FF3E9D", "#0E1F40"],
["#766eff", "#00d4ff"],
["#9bff6e", "#00d4ff"],
["#ff6e94", "#00d4ff"],
["#ffa96e", "#00d4ff"],
["#ffa96e", "#ff009d"],
["#ffdd6e", "#ff009d"],
["#eb6f92", "#b4637a"],
["#f6c177", "#ea9d34"],
["#ebbcba", "#d7827e"],
["#9ccfd8", "#56949f"],
["#c4a7e7", "#907aa9"],
["#eb6f92", "#f6c177"],
["#eb6f92", "#ebbcba"],
["#eb6f92", "#31748f"],
["#eb6f92", "#9ccfd8"],
["#eb6f92", "#c4a7e7"],
["#f6c177", "#eb6f92"],
["#f6c177", "#ebbcba"],
["#f6c177", "#31748f"],
["#f6c177", "#9ccfd8"],
["#f6c177", "#c4a7e7"],
["#ebbcba", "#eb6f92"],
["#ebbcba", "#f6c177"],
["#ebbcba", "#31748f"],
["#ebbcba", "#9ccfd8"],
["#ebbcba", "#c4a7e7"],
["#31748f", "#eb6f92"],
["#31748f", "#f6c177"],
["#31748f", "#ebbcba"],
["#31748f", "#9ccfd8"],
["#31748f", "#c4a7e7"],
["#9ccfd8", "#eb6f92"],
["#9ccfd8", "#f6c177"],
["#9ccfd8", "#ebbcba"],
["#9ccfd8", "#31748f"],
["#9ccfd8", "#c4a7e7"],
["#c4a7e7", "#eb6f92"],
["#c4a7e7", "#f6c177"],
["#c4a7e7", "#ebbcba"],
["#c4a7e7", "#31748f"],
["#c4a7e7", "#9ccfd8"],
];
const actualSize = size - margin * 2;

View File

@ -2,9 +2,9 @@ export function nyaize(text: string): string {
return (
text
// ja-JP
.replace(/な/g, "にゃ")
.replace(/ナ/g, "ニャ")
.replace(/ナ/g, "ニャ")
.replaceAll("な", "にゃ")
.replaceAll("ナ", "ニャ")
.replaceAll("ナ", "ニャ")
// en-US
.replace(/(?<=n)a/gi, (x) => (x === "A" ? "YA" : "ya"))
.replace(/(?<=morn)ing/gi, (x) => (x === "ING" ? "YAN" : "yan"))

View File

@ -4,78 +4,84 @@ import { Emojis } from "@/models/index.js";
import { toPunyNullable } from "./convert-host.js";
import { IsNull } from "typeorm";
const legacies: Record<string, string> = {
like: "👍",
love: "❤️", // ここに記述する場合は異体字セレクタを入れない <- not that good because modern browsers just display it as the red heart so just convert it to it to not end up with two seperate reactions of "the same emoji" for the user
laugh: "😆",
hmm: "🤔",
surprise: "😮",
congrats: "🎉",
angry: "💢",
confused: "😥",
rip: "😇",
pudding: "🍮",
star: "⭐",
};
const legacies = new Map([
["like", "👍"],
["love", "❤️"],
["laugh", "😆"],
["hmm", "🤔"],
["surprise", "😮"],
["congrats", "🎉"],
["angry", "💢"],
["confused", "😥"],
["rip", "😇"],
["pudding", "🍮"],
["star", "⭐"],
]);
export async function getFallbackReaction(): Promise<string> {
export async function getFallbackReaction() {
const meta = await fetchMeta();
return meta.defaultReaction;
}
export function convertLegacyReactions(reactions: Record<string, number>) {
const _reactions = {} as Record<string, number>;
const _reactions = new Map();
const decodedReactions = new Map();
for (const reaction of Object.keys(reactions)) {
for (const reaction in reactions) {
if (reactions[reaction] <= 0) continue;
if (Object.keys(legacies).includes(reaction)) {
if (_reactions[legacies[reaction]]) {
_reactions[legacies[reaction]] += reactions[reaction];
let decodedReaction;
if (decodedReactions.has(reaction)) {
decodedReaction = decodedReactions.get(reaction);
} else {
_reactions[legacies[reaction]] = reactions[reaction];
decodedReaction = decodeReaction(reaction);
decodedReactions.set(reaction, decodedReaction);
}
let emoji = legacies.get(decodedReaction.reaction);
if (emoji) {
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
} else {
if (_reactions[reaction]) {
_reactions[reaction] += reactions[reaction];
} else {
_reactions[reaction] = reactions[reaction];
}
_reactions.set(
reaction,
(_reactions.get(reaction) || 0) + reactions[reaction],
);
}
}
const _reactions2 = {} as Record<string, number>;
for (const reaction of Object.keys(_reactions)) {
_reactions2[decodeReaction(reaction).reaction] = _reactions[reaction];
const _reactions2 = new Map();
for (const [reaction, count] of _reactions) {
const decodedReaction = decodedReactions.get(reaction);
_reactions2.set(decodedReaction.reaction, count);
}
return _reactions2;
return Object.fromEntries(_reactions2);
}
export async function toDbReaction(
reaction?: string | null,
reacterHost?: string | null,
): Promise<string> {
if (reaction == null) return await getFallbackReaction();
if (!reaction) return await getFallbackReaction();
reacterHost = toPunyNullable(reacterHost);
// 文字列タイプのリアクションを絵文字に変換
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
// Convert string-type reactions to unicode
const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null);
if (emoji) return emoji;
// Unicode絵文字
// Allow unicode reactions
const match = emojiRegex.exec(reaction);
if (match) {
const unicode = match[0];
return unicode.match("\u200d") ? unicode : unicode.replace(/\ufe0f/g, "");
return unicode;
}
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
if (custom) {
const name = custom[1];
const emoji = await Emojis.findOneBy({
host: reacterHost ?? IsNull(),
host: reacterHost || IsNull(),
name,
});
@ -124,7 +130,7 @@ export function decodeReaction(str: string): DecodedReaction {
}
export function convertLegacyReaction(reaction: string): string {
reaction = decodeReaction(reaction).reaction;
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
return reaction;
const decoded = decodeReaction(reaction).reaction;
if (legacies.has(decoded)) return legacies.get(decoded)!;
return decoded;
}

View File

@ -40,8 +40,8 @@ export class Antenna {
})
public name: string;
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] })
public src: "home" | "all" | "users" | "list" | "group";
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'group', 'instances'] })
public src: "home" | "all" | "users" | "list" | "group" | "instances";
@Column({
...id(),
@ -73,6 +73,11 @@ export class Antenna {
})
public users: string[];
@Column('jsonb', {
default: [],
})
public instances: string[];
@Column('jsonb', {
default: [],
})

View File

@ -25,6 +25,7 @@ export const AntennaRepository = db.getRepository(Antenna).extend({
userListId: antenna.userListId,
userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null,
users: antenna.users,
instances: antenna.instances,
caseSensitive: antenna.caseSensitive,
notify: antenna.notify,
withReplies: antenna.withReplies,

View File

@ -197,6 +197,11 @@ export const NoteRepository = db.getRepository(Note).extend({
.map((x) => decodeReaction(x).reaction)
.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({
id: note.id,
createdAt: note.createdAt.toISOString(),
@ -213,8 +218,9 @@ export const NoteRepository = db.getRepository(Note).extend({
renoteCount: note.renoteCount,
repliesCount: note.repliesCount,
reactions: convertLegacyReactions(note.reactions),
reactionEmojis: reactionEmoji,
emojis: noteEmoji,
tags: note.tags.length > 0 ? note.tags : undefined,
emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host),
fileIds: note.fileIds,
files: DriveFiles.packMany(note.fileIds),
replyId: note.replyId,

View File

@ -52,7 +52,7 @@ export const packedAntennaSchema = {
type: "string",
optional: false,
nullable: false,
enum: ["home", "all", "users", "list", "group"],
enum: ["home", "all", "users", "list", "group", "instances"],
},
userListId: {
type: "string",
@ -76,6 +76,16 @@ export const packedAntennaSchema = {
nullable: false,
},
},
instances: {
type: "array",
optional: false,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false,
},
},
caseSensitive: {
type: "boolean",
optional: false,

View File

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

View File

@ -111,10 +111,37 @@ export async function createNote(
const note: IPost = object;
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
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.info(`Creating the Note: ${note.id}`);
// Skip if note is made before 2007 (1yr before Fedi was created)
// OR skip if note is made 3 days in advance
if (note.published) {
const DateChecker = new Date(note.published);
const FutureCheck = new Date();
FutureCheck.setDate(FutureCheck.getDate() + 3); // Allow some wiggle room for misconfigured hosts
if (DateChecker.getFullYear() < 2007) {
logger.warn(
"Note somehow made before Activitypub was created; discarding",
);
return null;
}
if (DateChecker > FutureCheck) {
logger.warn("Note somehow made after today; discarding");
return null;
}
}
// Fetch author
const actor = (await resolvePerson(
getOneApId(note.attributedTo),
@ -123,7 +150,10 @@ export async function createNote(
// Skip if author is suspended.
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);
@ -344,7 +374,7 @@ export async function createNote(
apEmojis,
poll,
uri: note.id,
url: getOneApHrefNullable(note.url),
url: url,
},
silent,
);

View File

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

View File

@ -107,6 +107,7 @@ export async function signup(opts: {
isAdmin:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0,
}),
);

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_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_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_keysWithType from "./endpoints/i/registry/keys-with-type.js";
import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js";
@ -221,6 +222,7 @@ import * as ep___messaging_messages_create from "./endpoints/messaging/messages/
import * as ep___messaging_messages_delete from "./endpoints/messaging/messages/delete.js";
import * as ep___messaging_messages_read from "./endpoints/messaging/messages/read.js";
import * as ep___meta from "./endpoints/meta.js";
import * as ep___sounds from "./endpoints/get-sounds.js";
import * as ep___miauth_genToken from "./endpoints/miauth/gen-token.js";
import * as ep___mute_create from "./endpoints/mute/create.js";
import * as ep___mute_delete from "./endpoints/mute/delete.js";
@ -538,6 +540,7 @@ const eps = [
["i/regenerate-token", ep___i_regenerateToken],
["i/registry/get-all", ep___i_registry_getAll],
["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/keys-with-type", ep___i_registry_keysWithType],
["i/registry/keys", ep___i_registry_keys],
@ -666,6 +669,7 @@ const eps = [
["users/stats", ep___users_stats],
["admin/drive-capacity-override", ep___admin_driveCapOverride],
["fetch-rss", ep___fetchRss],
["get-sounds", ep___sounds],
];
export interface IEndpointMeta {
@ -766,16 +770,16 @@ export interface IEndpointMeta {
export interface IEndpoint {
name: string;
exec: any;
exec: any; // TODO: may be obosolete @ThatOneCalculator
meta: IEndpointMeta;
params: Schema;
}
const endpoints: IEndpoint[] = eps.map(([name, ep]) => {
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return {
name: name,
exec: ep.default,
meta: ep.meta || {},
meta: ep.meta ?? {},
params: ep.paramDef,
};
});

View File

@ -35,6 +35,7 @@ export default define(meta, paramDef, async (ps, _me) => {
const noUsers =
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0;
if (!(noUsers || me?.isAdmin)) throw new Error("access denied");

View File

@ -1,7 +1,7 @@
import define from "../../define.js";
import { Users } from "@/models/index.js";
import { User } from "@/models/entities/user.js";
import { insertModerationLog } from "@/services/insert-moderation-log.js";
import { publishInternalEvent } from "@/services/stream.js";
export const meta = {
tags: ["admin"],
@ -29,17 +29,14 @@ export default define(meta, paramDef, async (ps, me) => {
throw new Error("user is not local user");
}
/*if (user.isAdmin) {
throw new Error('cannot suspend admin');
}
if (user.isModerator) {
throw new Error('cannot suspend moderator');
}*/
await Users.update(user.id, {
driveCapacityOverrideMb: ps.overrideMb,
});
publishInternalEvent("localUserUpdated", {
id: user.id,
});
insertModerationLog(me, "change-drive-capacity-override", {
targetId: user.id,
});

View File

@ -37,7 +37,10 @@ export const paramDef = {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] },
src: {
type: "string",
enum: ["home", "all", "users", "list", "group", "instances"],
},
userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -64,6 +67,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -75,6 +84,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -118,6 +128,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View File

@ -43,7 +43,10 @@ export const paramDef = {
properties: {
antennaId: { type: "string", format: "misskey:id" },
name: { type: "string", minLength: 1, maxLength: 100 },
src: { type: "string", enum: ["home", "all", "users", "list", "group"] },
src: {
type: "string",
enum: ["home", "all", "users", "list", "group", "instances"],
},
userListId: { type: "string", format: "misskey:id", nullable: true },
userGroupId: { type: "string", format: "misskey:id", nullable: true },
keywords: {
@ -70,6 +73,12 @@ export const paramDef = {
type: "string",
},
},
instances: {
type: "array",
items: {
type: "string",
},
},
caseSensitive: { type: "boolean" },
withReplies: { type: "boolean" },
withFile: { type: "boolean" },
@ -82,6 +91,7 @@ export const paramDef = {
"keywords",
"excludeKeywords",
"users",
"instances",
"caseSensitive",
"withReplies",
"withFile",
@ -131,6 +141,7 @@ export default define(meta, paramDef, async (ps, user) => {
keywords: ps.keywords,
excludeKeywords: ps.excludeKeywords,
users: ps.users,
instances: ps.instances,
caseSensitive: ps.caseSensitive,
withReplies: ps.withReplies,
withFile: ps.withFile,

View File

@ -1,6 +1,5 @@
import define from "../../define.js";
import { Channels, ChannelFollowings } from "@/models/index.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js";
export const meta = {
tags: ["channels", "account"],
@ -33,11 +32,24 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async (ps, me) => {
const query = makePaginationQuery(
ChannelFollowings.createQueryBuilder(),
ps.sinceId,
ps.untilId,
).andWhere({ followerId: me.id });
const query = ChannelFollowings.createQueryBuilder("following").andWhere({
followerId: me.id,
});
if (ps.sinceId) {
query.andWhere('following."followeeId" > :sinceId', {
sinceId: ps.sinceId,
});
}
if (ps.untilId) {
query.andWhere('following."followeeId" < :untilId', {
untilId: ps.untilId,
});
}
if (ps.sinceId && !ps.untilId) {
query.orderBy('following."followeeId"', "ASC");
} else {
query.orderBy('following."followeeId"', "DESC");
}
const followings = await query.take(ps.limit).getMany();

View File

@ -102,10 +102,13 @@ export default define(meta, paramDef, async (ps, me) => {
if (typeof ps.blocked === "boolean") {
const meta = await fetchMeta(true);
if (ps.blocked) {
if (meta.blockedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...blocks)", {
blocks: meta.blockedHosts,
});
} else {
} else if (meta.blockedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...blocks)", {
blocks: meta.blockedHosts,
});

View File

@ -0,0 +1,30 @@
import { readdir } from "fs/promises";
import define from "../define.js";
export const meta = {
tags: ["meta"],
requireCredential: false,
requireCredentialPrivateMode: false,
} as const;
export const paramDef = {
type: "object",
properties: {},
required: [],
} as const;
export default define(meta, paramDef, async () => {
const music_files: (string | null)[] = [null];
const directory = (
await readdir("./assets/sounds", { withFileTypes: true })
).filter((potentialFolder) => potentialFolder.isDirectory());
for await (const folder of directory) {
const files = (await readdir(`./assets/sounds/${folder.name}`)).filter(
(potentialSong) => potentialSong.endsWith(".mp3"),
);
for await (const file of files) {
music_files.push(`${folder.name}/${file.replace(".mp3", "")}`);
}
}
return music_files;
});

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

@ -489,6 +489,7 @@ export default define(meta, paramDef, async (ps, me) => {
requireSetup:
(await Users.countBy({
host: IsNull(),
isAdmin: true,
})) === 0,
}
: {}),

View File

@ -27,6 +27,11 @@ export const paramDef = {
properties: {
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
offset: { type: "integer", default: 0 },
origin: {
type: "string",
enum: ["combined", "local", "remote"],
default: "local",
},
},
required: [],
} as const;
@ -37,7 +42,7 @@ export default define(meta, paramDef, async (ps, user) => {
const query = Notes.createQueryBuilder("note")
.addSelect("note.score")
.where("note.userHost IS NULL")
// .where("note.userHost IS NULL")
.andWhere("note.score > 0")
.andWhere("note.createdAt > :date", { date: new Date(Date.now() - day) })
.andWhere("note.visibility = 'public'")
@ -53,6 +58,15 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
switch (ps.origin) {
case "local":
query.andWhere("note.userHost IS NULL");
break;
case "remote":
query.andWhere("note.userHost IS NOT NULL");
break;
}
if (user) generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user);

View File

@ -93,7 +93,7 @@ export default define(meta, paramDef, async (ps, me) => {
try {
if (ps.tag) {
if (!safeForSql(ps.tag)) throw new Error("Injection");
if (!safeForSql(normalizeForSearch(ps.tag))) throw "Injection";
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
} else {
query.andWhere(
@ -102,7 +102,8 @@ export default define(meta, paramDef, async (ps, me) => {
qb.orWhere(
new Brackets((qb) => {
for (const tag of tags) {
if (!safeForSql(tag)) throw new Error("Injection");
if (!safeForSql(normalizeForSearch(ps.tag)))
throw "Injection";
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
}
}),

View File

@ -7,6 +7,7 @@ import Router from "@koa/router";
import multer from "@koa/multer";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import { apiMastodonCompatible } from "./mastodon/ApiMastodonCompatibleService.js";
import { Instances, AccessTokens, Users } from "@/models/index.js";
import config from "@/config/index.js";
import endpoints from "./endpoints.js";
@ -18,6 +19,7 @@ import signupPending from "./private/signup-pending.js";
import discord from "./service/discord.js";
import github from "./service/github.js";
import twitter from "./service/twitter.js";
import { koaBody } from "koa-body";
// Init app
const app = new Koa();
@ -34,16 +36,10 @@ app.use(async (ctx, next) => {
await next();
});
app.use(
bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: (ctx) =>
!(
ctx.is("multipart/form-data") ||
ctx.is("application/x-www-form-urlencoded")
),
}),
);
// Init router
const router = new Router();
const mastoRouter = new Router();
const errorRouter = new Router();
// Init multer instance
const upload = multer({
@ -54,8 +50,36 @@ const upload = multer({
},
});
// Init router
const router = new Router();
router.use(
bodyParser({
// リクエストが multipart/form-data でない限りはJSONだと見なす
detectJSON: (ctx) =>
!(
ctx.is("multipart/form-data") ||
ctx.is("application/x-www-form-urlencoded")
),
}),
);
mastoRouter.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
apiMastodonCompatible(mastoRouter);
/**
* Register endpoint handlers
@ -141,11 +165,13 @@ router.post("/miauth/:session/check", async (ctx) => {
});
// Return 404 for unknown API
router.all("(.*)", async (ctx) => {
errorRouter.all("(.*)", async (ctx) => {
ctx.status = 404;
});
// Register router
app.use(mastoRouter.routes());
app.use(router.routes());
app.use(errorRouter.routes());
export default app;

View File

@ -0,0 +1,64 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from "@calckey/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 = await getInstance(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View File

@ -0,0 +1,436 @@
import { Users } from "@/models/index.js";
import { resolveUser } from "@/remote/resolve-user.js";
import Router from "@koa/router";
import { FindOptionsWhere, IsNull } from "typeorm";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { argsToBools, limitToInt } from "./timeline.js";
const relationshopModel = {
id: "",
following: false,
followed_by: false,
delivery_following: false,
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
showing_reblogs: false,
endorsed: false,
notifying: false,
note: "",
};
export function apiAccountMastodon(router: Router): void {
router.get("/v1/accounts/verify_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.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("/v1/accounts/lookup", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
let userArray = ctx.query.acct?.toString().split("@");
let userid;
if (userArray === undefined) {
ctx.status = 401;
ctx.body = { error: "no user specified" };
return;
}
if (userArray.length === 1) {
const q: FindOptionsWhere<User> = {
usernameLower: userArray[0].toLowerCase(),
host: IsNull(),
};
const user = await Users.findOneBy(q);
userid = user?.id;
} else {
userid = (await resolveUser(userArray[0], userArray[1])).id;
}
const data = await client.getAccount(userid ? userid : "");
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(^.*\\d.*$)",
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.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) => {
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,
argsToBools(limitToInt(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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
let users;
try {
// TODO: this should be body
const idsRaw = ctx.request.query ? ctx.request.query["id[]"] : null;
const ids = typeof idsRaw === "string" ? [idsRaw] : idsRaw;
users = ids;
relationshopModel.id = idsRaw?.toString() || "1";
if (!idsRaw) {
ctx.body = [relationshopModel];
return;
}
const data = await client.getRelationships(ids);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
let data = e.response.data;
data.users = users;
console.error(data);
ctx.status = 401;
ctx.body = data;
}
});
router.get("/v1/bookmarks", 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.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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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 "@calckey/megalodon";
import Router from "@koa/router";
import { koaBody } from "koa-body";
import { getClient } from "../ApiMastodonCompatibleService.js";
import bodyParser from "koa-bodyparser";
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);
const red = body.redirect_uris;
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,84 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
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,100 @@
import { Entity } from "@calckey/megalodon";
import { fetchMeta } from "@/misc/fetch-meta.js";
// TODO: add calckey features
export async function getInstance(response: Entity.Instance) {
const meta = await fetchMeta(true);
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: meta.langs,
registrations: !meta.disableRegistration || 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: Math.floor(new Date().getTime() / 1000),
note: "Please refer to the original instance for the actual admin contact.",
url: "/",
avatar: "/static-assets/badges/info.png",
avatar_static: "/static-assets/badges/info.png",
header: "https://http.cat/404",
header_static: "https://http.cat/404",
followers_count: -1,
following_count: 0,
statuses_count: 0,
last_status_at: Math.floor(new Date().getTime() / 1000),
noindex: true,
emojis: [],
fields: [],
},
rules: [],
};
}

View File

@ -0,0 +1,90 @@
import megalodon, { MegalodonInterface } from "@calckey/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,22 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
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,483 @@
import Router from "@koa/router";
import megalodon, { MegalodonInterface } from "@calckey/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 = Math.floor(new Date().getTime() / 1000);
return {
id: "9atm5frjhb",
uri: "https://http.cat/404", // ""
url: "https://http.cat/404", // "",
account: {
id: "9arzuvv0sw",
username: "Reactions",
acct: "Reactions",
display_name: "Reactions to this post",
locked: false,
created_at: now,
followers_count: 0,
following_count: 0,
statuses_count: 0,
note: "",
url: "https://http.cat/404",
avatar: "/static-assets/badges/info.png",
avatar_static: "/static-assets/badges/info.png",
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,315 @@
import Router from "@koa/router";
import megalodon, { Entity, MegalodonInterface } from "@calckey/megalodon";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { statusModel } from "./status.js";
import Autolinker from "autolinker";
import { ParsedUrlQuery } from "querystring";
export function limitToInt(q: ParsedUrlQuery) {
let object: any = q;
if (q.limit)
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10);
return q;
}
export function argsToBools(q: ParsedUrlQuery) {
let object: any = q;
if (q.only_media)
if (typeof q.only_media === "string")
object.only_media = q.only_media.toLowerCase() === "true";
if (q.exclude_replies)
if (typeof q.exclude_replies === "string")
object.exclude_replies = q.exclude_replies.toLowerCase() === "true";
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(limitToInt(query))
: await client.getPublicTimeline(limitToInt(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,
limitToInt(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(limitToInt(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,
limitToInt(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(limitToInt(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 type Channel from "./channel.js";
import type { StreamEventEmitter, StreamMessages } from "./types.js";
import { Converter } from "@calckey/megalodon";
import { getClient } from "../mastodon/ApiMastodonCompatibleService.js";
import { toTextWithReaction } from "../mastodon/endpoints/timeline.js";
/**
* Main stream connection
@ -41,17 +44,27 @@ export default class Connection {
private channels: Channel[] = [];
private subscribingNotes: any = {};
private cachedNotes: Packed<"Note">[] = [];
private isMastodonCompatible: boolean = false;
private host: string;
private accessToken: string;
private currentSubscribe: string[][] = [];
constructor(
wsConnection: websocket.connection,
subscriber: EventEmitter,
user: User | null | undefined,
token: AccessToken | null | undefined,
host: string,
accessToken: string,
prepareStream: string | undefined,
) {
console.log("constructor", prepareStream);
this.wsConnection = wsConnection;
this.subscriber = subscriber;
if (user) this.user = user;
if (token) this.token = token;
if (host) this.host = host;
if (accessToken) this.accessToken = accessToken;
this.onWsConnectionMessage = this.onWsConnectionMessage.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);
}
console.log("prepare", prepareStream);
if (prepareStream) {
this.onWsConnectionMessage({
type: "utf8",
utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }),
});
}
}
private onUserEvent(data: StreamMessages["user"]["payload"]) {
@ -125,16 +145,106 @@ export default class Connection {
if (data.type !== "utf8") return;
if (data.utf8Data == null) return;
let obj: Record<string, any>;
let objs: Record<string, any>[];
try {
obj = JSON.parse(data.utf8Data);
objs = [JSON.parse(data.utf8Data)];
} catch (e) {
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,
},
});
}
}
}
for (const obj of objs) {
const { type, body } = obj;
console.log(type, body);
switch (type) {
case "readNotification":
this.onReadNotification(body);
@ -179,6 +289,7 @@ export default class Connection {
break;
}
}
}
private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) {
this.sendMessageToWs(data.type, data.body);
@ -280,6 +391,68 @@ export default class Connection {
*
*/
public sendMessageToWs(type: string, payload: any) {
console.log(payload, this.isMastodonCompatible);
if (this.isMastodonCompatible) {
if (payload.type === "note") {
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,
@ -287,6 +460,7 @@ export default class Connection {
}),
);
}
}
/**
*

View File

@ -7,7 +7,6 @@ import type { Note } from "@/models/entities/note.js";
import type { Antenna } from "@/models/entities/antenna.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import type { DriveFolder } from "@/models/entities/drive-folder.js";
import { Emoji } from "@/models/entities/emoji.js";
import type { UserList } from "@/models/entities/user-list.js";
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
import type { UserGroup } from "@/models/entities/user-group.js";
@ -23,7 +22,10 @@ export interface InternalStreamTypes {
id: User["id"];
isSuspended: User["isSuspended"];
};
userChangeSilencedState: { id: User["id"]; isSilenced: User["isSilenced"] };
userChangeSilencedState: {
id: User["id"];
isSilenced: User["isSilenced"];
};
userChangeModeratorState: {
id: User["id"];
isModerator: User["isModerator"];
@ -33,7 +35,12 @@ export interface InternalStreamTypes {
oldToken: User["token"];
newToken: User["token"];
};
remoteUserUpdated: { id: User["id"] };
localUserUpdated: {
id: User["id"];
};
remoteUserUpdated: {
id: User["id"];
};
webhookCreated: Webhook;
webhookDeleted: Webhook;
webhookUpdated: Webhook;
@ -135,6 +142,9 @@ export interface NoteStreamTypes {
reaction: string;
userId: User["id"];
};
replied: {
id: Note["id"];
};
}
type NoteStreamEventTypes = {
[key in keyof NoteStreamTypes]: {

View File

@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => {
ws.on("request", async (request) => {
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(
request.httpRequest.headers.authorization,
q.i,
accessToken,
).catch((err) => {
request.reject(403, err.message);
return [];
@ -43,8 +46,19 @@ export const initializeStreamingServer = (server: http.Server) => {
}
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
? setInterval(() => {

View File

@ -20,6 +20,7 @@ import { createTemp } from "@/misc/create-temp.js";
import { publishMainStream } from "@/services/stream.js";
import * as Acct from "@/misc/acct.js";
import { envOption } from "@/env.js";
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import activityPub from "./activitypub.js";
import nodeinfo from "./nodeinfo.js";
import wellKnown from "./well-known.js";
@ -28,6 +29,7 @@ import fileServer from "./file/index.js";
import proxyServer from "./proxy/index.js";
import webServer from "./web/index.js";
import { initializeStreamingServer } from "./api/streaming.js";
import { koaBody } from "koa-body";
export const serverLogger = new Logger("server", "gray", false);
@ -68,6 +70,24 @@ app.use(mount("/proxy", proxyServer));
// Init router
const router = new Router();
const mastoRouter = new Router();
mastoRouter.use(
koaBody({
urlencoded: true,
}),
);
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
// Routing
router.use(activityPub.routes());
@ -133,7 +153,52 @@ router.get("/verify-email/:code", async (ctx) => {
}
});
mastoRouter.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());
});
mastoRouter.post("/oauth/token", async (ctx) => {
const body: any = ctx.request.body;
let client_id: any = ctx.request.query.client_id;
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const generator = (megalodon as any).default;
const client = generator("misskey", BASE_URL, null) as MegalodonInterface;
let m = null;
if (body.code) {
m = body.code.match(/^[a-zA-Z0-9-]+/);
if (!m.length) {
ctx.body = { error: "Invalid code" };
return;
}
}
if (client_id instanceof Array) {
client_id = client_id.toString();
} else if (!client_id) {
client_id = null;
}
try {
const atData = await client.fetchAccessToken(
client_id,
body.client_secret,
m ? m[0] : "",
);
ctx.body = {
access_token: atData.accessToken,
token_type: "Bearer",
scope: "read write follow",
created_at: Math.floor(new Date().getTime() / 1000),
};
} catch (err: any) {
console.error(err);
ctx.status = 401;
ctx.body = err.response.data;
}
});
// Register router
app.use(mastoRouter.routes());
app.use(router.routes());
app.use(mount(webServer));

View File

@ -1,7 +1,7 @@
'use strict';
"use strict";
window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account'));
const account = JSON.parse(localStorage.getItem("account"));
const i = account.token;
const api = (endpoint, data = {}) => {
@ -10,12 +10,13 @@ window.onload = async () => {
if (i) data.i = i;
// Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
method: 'POST',
fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, {
method: "POST",
body: JSON.stringify(data),
credentials: 'omit',
cache: 'no-cache'
}).then(async (res) => {
credentials: "omit",
cache: "no-cache",
})
.then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
@ -25,27 +26,28 @@ window.onload = async () => {
} else {
reject(body.error);
}
}).catch(reject);
})
.catch(reject);
});
return promise;
};
const content = document.getElementById('content');
const content = document.getElementById("content");
document.getElementById('ls').addEventListener('click', () => {
content.innerHTML = '';
document.getElementById("ls").addEventListener("click", () => {
content.innerHTML = "";
const lsEditor = document.createElement('div');
lsEditor.id = 'lsEditor';
const lsEditor = document.createElement("div");
lsEditor.id = "lsEditor";
const adder = document.createElement('div');
adder.classList.add('adder');
const addKeyInput = document.createElement('input');
const addValueTextarea = document.createElement('textarea');
const addButton = document.createElement('button');
addButton.textContent = 'Add';
addButton.addEventListener('click', () => {
const adder = document.createElement("div");
adder.classList.add("adder");
const addKeyInput = document.createElement("input");
const addValueTextarea = document.createElement("textarea");
const addButton = document.createElement("button");
addButton.textContent = "Add";
addButton.addEventListener("click", () => {
localStorage.setItem(addKeyInput.value, addValueTextarea.value);
location.reload();
});
@ -57,21 +59,21 @@ window.onload = async () => {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
const record = document.createElement('div');
record.classList.add('record');
const header = document.createElement('header');
const record = document.createElement("div");
record.classList.add("record");
const header = document.createElement("header");
header.textContent = k;
const textarea = document.createElement('textarea');
const textarea = document.createElement("textarea");
textarea.textContent = localStorage.getItem(k);
const saveButton = document.createElement('button');
saveButton.textContent = 'Save';
saveButton.addEventListener('click', () => {
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
saveButton.addEventListener("click", () => {
localStorage.setItem(k, textarea.value);
location.reload();
});
const removeButton = document.createElement('button');
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
const removeButton = document.createElement("button");
removeButton.textContent = "Remove";
removeButton.addEventListener("click", () => {
localStorage.removeItem(k);
location.reload();
});

View File

@ -9,120 +9,122 @@
* : webpackは介さないためこのファイルではrequireやimportは使えません
*/
'use strict';
"use strict";
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED', e);
renderError("SOMETHING_HAPPENED", e);
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
renderError("SOMETHING_HAPPENED_IN_PROMISE", e);
};
//#region Detect language & fetch translations
const v = localStorage.getItem('v') || VERSION;
const v = localStorage.getItem("v") || VERSION;
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
let lang = localStorage.getItem("lang");
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
lang = supportedLangs.find((x) => x.split("-")[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
if (lang == null) lang = "en-US";
}
}
const res = await fetch(`/assets/locales/${lang}.${v}.json`);
if (res.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await res.text());
localStorage.setItem('localeVersion', v);
localStorage.setItem("lang", lang);
localStorage.setItem("locale", await res.text());
localStorage.setItem("localeVersion", v);
} else {
await checkUpdate();
renderError('LOCALE_FETCH');
renderError("LOCALE_FETCH");
return;
}
//#endregion
//#region Script
function importAppScript() {
import(`/assets/${CLIENT_ENTRY}`)
.catch(async e => {
import(`/assets/${CLIENT_ENTRY}`).catch(async (e) => {
await checkUpdate();
console.error(e);
renderError('APP_IMPORT', e);
renderError("APP_IMPORT", e);
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
if (document.readyState !== "loading") {
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener("DOMContentLoaded", () => {
importAppScript();
});
}
//#endregion
//#region Theme
const theme = localStorage.getItem('theme');
const theme = localStorage.getItem("theme");
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
if (k === "htmlThemeColor") {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
if (
tag.tagName === "META" &&
tag.getAttribute("name") === "theme-color"
) {
tag.setAttribute("content", v);
break;
}
}
}
}
}
const colorSchema = localStorage.getItem('colorSchema');
const colorSchema = localStorage.getItem("colorSchema");
if (colorSchema) {
document.documentElement.style.setProperty('color-schema', colorSchema);
document.documentElement.style.setProperty("color-schema", colorSchema);
}
//#endregion
const fontSize = localStorage.getItem('fontSize');
const fontSize = localStorage.getItem("fontSize");
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
document.documentElement.classList.add("f-" + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
const useSystemFont = localStorage.getItem("useSystemFont");
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
document.documentElement.classList.add("useSystemFont");
}
const wallpaper = localStorage.getItem('wallpaper');
const wallpaper = localStorage.getItem("wallpaper");
if (wallpaper) {
document.documentElement.style.backgroundImage = `url(${wallpaper})`;
}
const customCss = localStorage.getItem('customCss');
const customCss = localStorage.getItem("customCss");
if (customCss && customCss.length > 0) {
const style = document.createElement('style');
const style = document.createElement("style");
style.innerHTML = customCss;
document.head.appendChild(style);
}
async function addStyle(styleText) {
let css = document.createElement('style');
let css = document.createElement("style");
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
function renderError(code, details) {
let errorsElement = document.getElementById('errors');
let errorsElement = document.getElementById("errors");
if (!errorsElement) {
document.body.innerHTML = `
@ -158,9 +160,9 @@
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
errorsElement = document.getElementById("errors");
}
const detailsElement = document.createElement('details');
const detailsElement = document.createElement("details");
detailsElement.innerHTML = `
<br>
<summary>
@ -278,25 +280,25 @@
details {
width: 50%;
}
`)
`);
}
async function checkUpdate() {
try {
const res = await fetch('/api/meta', {
method: 'POST',
cache: 'no-cache'
const res = await fetch("/api/meta", {
method: "POST",
cache: "no-cache",
});
const meta = await res.json();
if (meta.version != v) {
localStorage.setItem('v', meta.version);
localStorage.setItem("v", meta.version);
refresh();
}
} catch (e) {
console.error(e);
renderError('UPDATE_CHECK', e);
renderError("UPDATE_CHECK", e);
throw e;
}
}
@ -304,9 +306,9 @@
function refresh() {
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
navigator.serviceWorker.controller.postMessage("clear");
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
} catch (e) {
console.error(e);

View File

@ -1,7 +1,7 @@
'use strict';
"use strict";
window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account'));
const account = JSON.parse(localStorage.getItem("account"));
const i = account.token;
const api = (endpoint, data = {}) => {
@ -10,12 +10,13 @@ window.onload = async () => {
if (i) data.i = i;
// Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
method: 'POST',
fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, {
method: "POST",
body: JSON.stringify(data),
credentials: 'omit',
cache: 'no-cache'
}).then(async (res) => {
credentials: "omit",
cache: "no-cache",
})
.then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
@ -25,27 +26,28 @@ window.onload = async () => {
} else {
reject(body.error);
}
}).catch(reject);
})
.catch(reject);
});
return promise;
};
document.getElementById('submit').addEventListener('click', () => {
api('notes/create', {
text: document.getElementById('text').value
document.getElementById("submit").addEventListener("click", () => {
api("notes/create", {
text: document.getElementById("text").value,
}).then(() => {
location.reload();
});
});
api('notes/timeline').then(notes => {
const tl = document.getElementById('tl');
api("notes/timeline").then((notes) => {
const tl = document.getElementById("tl");
for (const note of notes) {
const el = document.createElement('div');
const name = document.createElement('header');
const el = document.createElement("div");
const name = document.createElement("header");
name.textContent = `${note.user.name} @${note.user.username}`;
const text = document.createElement('div');
const text = document.createElement("div");
text.textContent = `${note.text}`;
el.appendChild(name);
el.appendChild(text);

View File

@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => {
ctx.status = 503;
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
router.get("(.*)", async (ctx) => {

View File

@ -1,4 +1,4 @@
html {
html, body {
background-color: var(--bg);
color: var(--fg);
}

View File

@ -44,6 +44,23 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
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.thumbnail = wrap(summary.thumbnail);

View File

@ -23,7 +23,7 @@ block og
meta(property='og:description' content= summary)
meta(property='og:url' content= url)
meta(property='og:image' content= imageUrl)
if isImage
if isImage && !note.files[0].isSensitive
meta(property='og:image:width' content=note.files[0].properties.width)
meta(property='og:image:height' content=note.files[0].properties.height)
meta(property='og:image:type' content=note.files[0].type)

View File

@ -209,12 +209,12 @@ export default async function (
await Blockings.delete(blocking.id);
} else {
// それ以外は単純に例外
if (blocking != null)
if (blocking)
throw new IdentifiableError(
"710e8fb0-b8c3-4922-be49-d5d93d8e6a6e",
"blocking",
);
if (blocked != null)
if (blocked)
throw new IdentifiableError(
"3338392a-f764-498d-8855-db939dcf8c48",
"blocked",

View File

@ -38,8 +38,8 @@ export default async function (
}),
]);
if (blocking != null) throw new Error("blocking");
if (blocked != null) throw new Error("blocked");
if (blocking) throw new Error("blocking");
if (blocked) throw new Error("blocked");
const followRequest = await FollowRequests.insert({
id: genId(),

View File

@ -1,6 +1,10 @@
import * as mfm from "mfm-js";
import es from "../../db/elasticsearch.js";
import { publishMainStream, publishNotesStream } from "@/services/stream.js";
import {
publishMainStream,
publishNotesStream,
publishNoteStream,
} from "@/services/stream.js";
import DeliverManager from "@/remote/activitypub/deliver-manager.js";
import renderNote from "@/remote/activitypub/renderer/note.js";
import renderCreate from "@/remote/activitypub/renderer/create.js";
@ -430,6 +434,12 @@ export default async (
}
publishNotesStream(note);
if (note.replyId != null) {
// Only provide the reply note id here as the recipient may not be authorized to see the note.
publishNoteStream(note.replyId, "replied", {
id: note.id,
});
}
const webhooks = await getActiveWebhooks().then((webhooks) =>
webhooks.filter((x) => x.userId === user.id && x.on.includes("note")),

View File

@ -21,6 +21,16 @@ subscriber.on("message", async (_, data) => {
if (obj.channel === "internal") {
const { type, body } = obj.message;
switch (type) {
case "localUserUpdated": {
userByIdCache.delete(body.id);
localUserByIdCache.delete(body.id);
localUserByNativeTokenCache.cache.forEach((v, k) => {
if (v.value?.id === body.id) {
localUserByNativeTokenCache.delete(k);
}
});
break;
}
case "userChangeSuspendedState":
case "userChangeSilencedState":
case "userChangeModeratorState":

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -6,12 +6,24 @@
"build": "pnpm vite build",
"lint": "pnpm rome check \"src/**/*.{ts,vue}\""
},
"dependencies": {
"devDependencies": {
"@discordapp/twemoji": "14.0.2",
"@khmyznikov/pwa-install": "^0.2.0",
"@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0",
"@rollup/pluginutils": "^4.2.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",
"@vue/compiler-sfc": "3.2.45",
"autobind-decorator": "2.4.0",
@ -19,16 +31,21 @@
"blurhash": "1.1.5",
"broadcast-channel": "4.19.1",
"browser-image-resizer": "https://github.com/misskey-dev/browser-image-resizer.git",
"calckey-js": "^0.0.20",
"calckey-js": "^0.0.22",
"chart.js": "4.1.1",
"chartjs-adapter-date-fns": "2.0.1",
"chartjs-plugin-gradient": "0.5.1",
"chartjs-plugin-zoom": "1.2.1",
"chartjs-chart-matrix": "^2.0.1",
"city-timezones": "^1.2.1",
"compare-versions": "5.0.3",
"cropperjs": "2.0.0-beta.2",
"cross-env": "7.0.3",
"cypress": "10.11.0",
"date-fns": "2.29.3",
"escape-regexp": "0.0.1",
"eventemitter3": "4.0.7",
"gsap": "^3.11.4",
"idb-keyval": "6.2.0",
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.3",
@ -40,9 +57,11 @@
"punycode": "2.1.1",
"querystring": "0.2.1",
"rndstr": "1.0.0",
"rollup": "3.9.1",
"s-age": "1.1.2",
"sass": "1.57.1",
"seedrandom": "3.0.5",
"start-server-and-test": "1.15.2",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"swiper": "^8.4.5",
@ -57,28 +76,11 @@
"typescript": "4.9.4",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "^4.1.0-beta.1",
"vite": "^4.1.1",
"vue": "3.2.45",
"vue-isyourpasswordsafe": "^2.0.0",
"vue-plyr": "^7.0.0",
"vue-prism-editor": "2.0.0-alpha.2",
"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>
<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>
<span v-if="!modelValue">{{ label }}</span>
</button>

View File

@ -1,21 +1,21 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
<div class="mk-dialog">
<div v-if="icon" class="icon">
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
<div :class="$style.root">
<div v-if="icon" :class="$style.icon">
<i :class="icon"></i>
</div>
<div v-else-if="!input && !select" class="icon" :class="type">
<i v-if="type === 'success'" class="ph-check-bold ph-lg"></i>
<i v-else-if="type === 'error'" class="ph-circle-wavy-warning-bold ph-lg"></i>
<i v-else-if="type === 'warning'" class="ph-warning-bold ph-lg"></i>
<i v-else-if="type === 'info'" class="ph-info-bold ph-lg"></i>
<i v-else-if="type === 'question'" class="ph-question-bold ph-lg"></i>
<i v-else-if="type === 'waiting'" class="ph-circle-notch-bold ph-lg fa-pulse"></i>
<div v-else-if="!input && !select" :class="[$style.icon, $style['type_' + type]]">
<i v-if="type === 'success'" :class="$style.iconInner" class="ph-check-bold ph-lg"></i>
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ph-circle-wavy-warning-bold ph-lg"></i>
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ph-warning-bold ph-lg"></i>
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ph-info-bold ph-lg"></i>
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ph-circle-question-bold ph-lg"></i>
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div>
<header v-if="title"><Mfm :text="title"/></header>
<div v-if="text" class="body"><Mfm :text="text"/></div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ph-lock-bold ph-lg"></i></template>
<template v-if="input.type === 'password'" #prefix><i class="ph-password-bold ph-lg"></i></template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items">
@ -27,7 +27,7 @@
</optgroup>
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" class="buttons">
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<div v-if="!isYesNo">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
@ -37,15 +37,15 @@
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.no }}</MkButton>
</div>
</div>
<div v-if="actions" class="buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
</div>
</div>
</MkModal>
</MkModal>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/form/input.vue';
@ -88,12 +88,16 @@ const props = withDefaults(defineProps<{
showOkButton?: boolean;
showCancelButton?: boolean;
isYesNo?: boolean;
cancelableByBgClick?: boolean;
okText?: string;
cancelText?: string;
}>(), {
type: 'info',
showOkButton: true,
showCancelButton: false,
isYesNo: false,
cancelableByBgClick: true,
});
@ -102,7 +106,7 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const modal = ref<InstanceType<typeof MkModal>>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref(props.input?.default || null);
const selectedValue = ref(props.select?.default || null);
@ -151,9 +155,10 @@ onBeforeUnmount(() => {
});
</script>
<style lang="scss" scoped>
.mk-dialog {
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: 480px;
@ -161,56 +166,56 @@ onBeforeUnmount(() => {
text-align: center;
background: var(--panel);
border-radius: var(--radius);
}
> .icon {
font-size: 32px;
.icon {
font-size: 24px;
&.info {
color: #55c4dd;
}
&.success {
color: var(--success);
}
&.error {
color: var(--error);
}
&.warning {
color: var(--warn);
}
> * {
display: block;
margin: 0 auto;
}
& + header {
margin-top: 16px;
}
}
> header {
margin: 0 0 8px 0;
font-weight: bold;
font-size: 20px;
& + .body {
& + .title {
margin-top: 8px;
}
}
}
> .body {
margin: 16px 0 0 0;
}
.iconInner {
display: block;
margin: 0 auto;
}
> .buttons {
margin-top: 16px;
.type_info {
color: var(--accent);
}
> * {
margin: 0 8px;
}
.type_success {
color: var(--success);
}
.type_error {
color: var(--error);
}
.type_warning {
color: var(--warn);
}
.title {
margin: 0 0 8px 0;
font-weight: bold;
font-size: 1.1em;
& + .text {
margin-top: 8px;
}
}
.text {
margin: 16px 0 0 0;
}
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

Some files were not shown because too many files have changed in this diff Show More