Compare commits
29 Commits
f647108894
...
f0008814da
Author | SHA1 | Date |
---|---|---|
Linca | f0008814da | |
naskya | 14b285f882 | |
naskya | baa5c402db | |
naskya | 5b01d3574f | |
naskya | e3a98ebc72 | |
naskya | 7fe7f90350 | |
naskya | 8ed942e00f | |
naskya | ddfdd038ad | |
naskya | 7fdd44cf8d | |
naskya | 0c4826becf | |
naskya | ecd8e3d109 | |
naskya | a3b156441a | |
naskya | ecbd8a8724 | |
naskya | 442dc33a34 | |
naskya | c8372767fa | |
naskya | 8e497b41cf | |
naskya | bfdf73caeb | |
naskya | 5b18f9761c | |
naskya | 641ff742bb | |
naskya | e6121946aa | |
naskya | c6212ff8f4 | |
naskya | d582a84c57 | |
naskya | a7978e2b08 | |
naskya | 766bac3dee | |
naskya | 7360736966 | |
naskya | e797849e9b | |
eana | ef57735e6a | |
eana | e7c33835b2 | |
Lhcfl | 3061147bd3 |
192
.config/ci.yml
192
.config/ci.yml
|
@ -1,195 +1,11 @@
|
|||
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Firefish configuration
|
||||
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# ┌─────┐
|
||||
#───┘ URL └─────────────────────────────────────────────────────
|
||||
|
||||
# Final accessible URL seen by a user.
|
||||
url: https://example.tld/
|
||||
|
||||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# URL SETTINGS AFTER THAT!
|
||||
|
||||
# ┌───────────────────────┐
|
||||
#───┘ Port and TLS settings └───────────────────────────────────
|
||||
|
||||
#
|
||||
# Misskey requires a reverse proxy to support HTTPS connections.
|
||||
#
|
||||
# +----- https://example.tld/ ------------+
|
||||
# +------+ |+-------------+ +----------------+|
|
||||
# | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
|
||||
# +------+ |+-------------+ +----------------+|
|
||||
# +---------------------------------------+
|
||||
#
|
||||
# You need to set up a reverse proxy. (e.g. nginx)
|
||||
# An encrypted connection with HTTPS is highly recommended
|
||||
# because tokens may be transferred in GET requests.
|
||||
|
||||
# The port that your Misskey server should listen on.
|
||||
url: http://localhost:3000
|
||||
port: 3000
|
||||
|
||||
# ┌──────────────────────────┐
|
||||
#───┘ PostgreSQL configuration └────────────────────────────────
|
||||
|
||||
db:
|
||||
host: postgres
|
||||
port: 5432
|
||||
|
||||
# Database name
|
||||
db: postgres
|
||||
|
||||
# Auth
|
||||
user: postgres
|
||||
pass: test
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
db: firefish_db
|
||||
user: firefish
|
||||
pass: password
|
||||
redis:
|
||||
host: redis
|
||||
port: 6379
|
||||
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
#pass: example-pass
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
#elasticsearch:
|
||||
# host: localhost
|
||||
# port: 9200
|
||||
# ssl: false
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌───────────────┐
|
||||
#───┘ ID generation └───────────────────────────────────────────
|
||||
|
||||
# You can select the ID generation method.
|
||||
# You don't usually need to change this setting, but you can
|
||||
# change it according to your preferences.
|
||||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
||||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aid'
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
||||
# Max note length, should be < 8000.
|
||||
#maxNoteLength: 3000
|
||||
|
||||
# Whether disable HSTS
|
||||
#disableHsts: true
|
||||
|
||||
# Number of worker processes
|
||||
#clusterLimit: 1
|
||||
|
||||
# Job concurrency per worker
|
||||
# deliverJobConcurrency: 128
|
||||
# inboxJobConcurrency: 16
|
||||
|
||||
# Job rate limiter
|
||||
# deliverJobPerSec: 128
|
||||
# inboxJobPerSec: 16
|
||||
|
||||
# Job attempts
|
||||
# deliverJobMaxAttempts: 12
|
||||
# inboxJobMaxAttempts: 8
|
||||
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Syslog option
|
||||
#syslog:
|
||||
# host: localhost
|
||||
# port: 514
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
#proxyBypassHosts: [
|
||||
# 'example.com',
|
||||
# '192.0.2.8'
|
||||
#]
|
||||
|
||||
# Proxy for SMTP/SMTPS
|
||||
#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT
|
||||
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
|
||||
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
|
||||
|
||||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: false)
|
||||
#proxyRemoteFiles: true
|
||||
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# Managed hosting settings
|
||||
# !!!!!!!!!!
|
||||
# >>>>>> NORMAL SELF-HOSTERS, STAY AWAY! <<<<<<
|
||||
# >>>>>> YOU DON'T NEED THIS! <<<<<<
|
||||
# !!!!!!!!!!
|
||||
# Each category is optional, but if each item in each category is mandatory!
|
||||
# If you mess this up, that's on you, you've been warned...
|
||||
|
||||
#maxUserSignups: 100
|
||||
#isManagedHosting: true
|
||||
#deepl:
|
||||
# managed: true
|
||||
# authKey: ''
|
||||
# isPro: false
|
||||
#
|
||||
#email:
|
||||
# managed: true
|
||||
# address: 'example@email.com'
|
||||
# host: 'email.com'
|
||||
# port: 587
|
||||
# user: 'example@email.com'
|
||||
# pass: ''
|
||||
# useImplicitSslTls: false
|
||||
#
|
||||
#objectStorage:
|
||||
# managed: true
|
||||
# baseUrl: ''
|
||||
# bucket: ''
|
||||
# prefix: ''
|
||||
# endpoint: ''
|
||||
# region: ''
|
||||
# accessKey: ''
|
||||
# secretKey: ''
|
||||
# useSsl: true
|
||||
# connnectOverProxy: false
|
||||
# setPublicReadOnUpload: true
|
||||
# s3ForcePathStyle: true
|
||||
|
||||
# !!!!!!!!!!
|
||||
# >>>>>> AGAIN, NORMAL SELF-HOSTERS, STAY AWAY! <<<<<<
|
||||
# >>>>>> YOU DON'T NEED THIS, ABOVE SETTINGS ARE FOR MANAGED HOSTING ONLY! <<<<<<
|
||||
# !!!!!!!!!!
|
||||
|
||||
# Seriously. Do NOT fill out the above settings if you're self-hosting.
|
||||
# They're much better off being set from the control panel.
|
||||
|
|
|
@ -40,7 +40,6 @@ coverage
|
|||
|
||||
# misskey
|
||||
built
|
||||
db
|
||||
elasticsearch
|
||||
redis
|
||||
npm-debug.log
|
||||
|
@ -56,8 +55,6 @@ packages/backend/assets/instance.css
|
|||
packages/backend/assets/sounds/None.mp3
|
||||
packages/backend/assets/LICENSE
|
||||
|
||||
!/packages/backend/queue/processors/db
|
||||
!/packages/backend/src/db
|
||||
!/packages/backend/src/server/api/endpoints/drive/files
|
||||
|
||||
packages/megalodon/lib
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
image: docker.io/rust:slim-bookworm
|
||||
|
||||
services:
|
||||
- name: docker.io/groonga/pgroonga:latest-alpine-12-slim
|
||||
alias: postgres
|
||||
- name: docker.io/redis:7-alpine
|
||||
alias: redis
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
when: always
|
||||
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
|
||||
when: never
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- node_modules
|
||||
# - /usr/local/cargo/registry/index
|
||||
# - /usr/local/cargo/registry/cache
|
||||
- target/debug/deps
|
||||
- target/debug/build
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
variables:
|
||||
POSTGRES_DB: 'firefish_db'
|
||||
POSTGRES_USER: 'firefish'
|
||||
POSTGRES_PASSWORD: 'password'
|
||||
POSTGRES_HOST_AUTH_METHOD: 'trust'
|
||||
DEBIAN_FRONTEND: 'noninteractive'
|
||||
CARGO_PROFILE_DEV_OPT_LEVEL: '0'
|
||||
CARGO_PROFILE_DEV_LTO: 'off'
|
||||
CARGO_PROFILE_DEV_DEBUG: 'none'
|
||||
|
||||
default:
|
||||
before_script:
|
||||
- mkdir -p "${CARGO_HOME}"
|
||||
- apt-get update && apt-get -y upgrade
|
||||
- apt-get -y --no-install-recommends install curl
|
||||
- curl -fsSL 'https://deb.nodesource.com/setup_18.x' | bash -
|
||||
- apt-get install -y --no-install-recommends build-essential clang mold python3 perl nodejs postgresql-client
|
||||
- corepack enable
|
||||
- corepack prepare pnpm@latest --activate
|
||||
- cp .config/ci.yml .config/default.yml
|
||||
- cp ci/cargo/config.toml /usr/local/cargo/config.toml
|
||||
- export PGPASSWORD="${POSTGRES_PASSWORD}"
|
||||
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command 'CREATE EXTENSION pgroonga'
|
||||
|
||||
build_and_cargo_unit_test:
|
||||
stage: test
|
||||
script:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm run build:debug
|
||||
- pnpm run migrate
|
||||
- cargo test
|
|
@ -0,0 +1,3 @@
|
|||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "/usr/bin/clang"
|
||||
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Breaking changes are indicated by the :warning: icon.
|
||||
|
||||
- Adding `lang` to the response of `i` and the request parameter of `i/update`.
|
||||
|
||||
## v20240504
|
||||
|
||||
- :warning: Removed `release` endpoint.
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
BEGIN;
|
||||
|
||||
DELETE FROM "migrations" WHERE name IN (
|
||||
'AddUserProfileLanguage1714888400293',
|
||||
'DropUnusedIndexes1714643926317',
|
||||
'AlterAkaType1714099399879',
|
||||
'AddDriveFileUsage1713451569342',
|
||||
|
@ -764,9 +765,6 @@ CREATE SEQUENCE public.__chart_day__users_id_seq
|
|||
CACHE 1;
|
||||
ALTER SEQUENCE public.__chart_day__users_id_seq OWNED BY public.__chart_day__users.id;
|
||||
|
||||
-- drop-user-profile-language
|
||||
ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32);
|
||||
|
||||
-- emoji-moderator
|
||||
ALTER TABLE "user" DROP COLUMN "emojiModPerm";
|
||||
DROP TYPE "public"."user_emojimodperm_enum";
|
||||
|
|
|
@ -766,6 +766,9 @@ confirmToUnclipAlreadyClippedNote: "This post is already part of the \"{name}\"
|
|||
public: "Public"
|
||||
i18nInfo: "Firefish is being translated into various languages by volunteers. You
|
||||
can help at {link}."
|
||||
i18nServerInfo: "New clients will be in {language} by default."
|
||||
i18nServerChange: "Use {language} instead."
|
||||
i18nServerSet: "Use {language} for new clients."
|
||||
manageAccessTokens: "Manage access tokens"
|
||||
accountInfo: "Account Info"
|
||||
notesCount: "Number of posts"
|
||||
|
@ -1583,6 +1586,16 @@ _ago:
|
|||
weeksAgo: "{n}w ago"
|
||||
monthsAgo: "{n}mo ago"
|
||||
yearsAgo: "{n}y ago"
|
||||
_later:
|
||||
future: "Future"
|
||||
justNow: "Immediate"
|
||||
secondsAgo: "{n}s later"
|
||||
minutesAgo: "{n}m later"
|
||||
hoursAgo: "{n}h later"
|
||||
daysAgo: "{n}d later"
|
||||
weeksAgo: "{n}w later"
|
||||
monthsAgo: "{n}mo later"
|
||||
yearsAgo: "{n}y later"
|
||||
_time:
|
||||
second: "Second(s)"
|
||||
minute: "Minute(s)"
|
||||
|
@ -2241,3 +2254,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
|
|||
noteEditHistory: "Post edit history"
|
||||
slashQuote: "Chain quote"
|
||||
foldNotification: "Group similar notifications"
|
||||
scheduledPost: "Scheduled post"
|
||||
scheduledDate: "Scheduled date"
|
||||
|
|
|
@ -685,6 +685,9 @@ unclip: "クリップ解除"
|
|||
confirmToUnclipAlreadyClippedNote: "この投稿はすでにクリップ「{name}」に含まれています。投稿をこのクリップから除外しますか?"
|
||||
public: "公開"
|
||||
i18nInfo: "Firefishは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
|
||||
i18nServerInfo: "新しい端末では{language}が既定の言語になります。"
|
||||
i18nServerChange: "{language}に変更する。"
|
||||
i18nServerSet: "新しい端末での表示言語を{language}にします。"
|
||||
manageAccessTokens: "アクセストークンの管理"
|
||||
accountInfo: "アカウント情報"
|
||||
notesCount: "投稿の数"
|
||||
|
|
|
@ -667,6 +667,9 @@ unclip: "移除便签"
|
|||
confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?"
|
||||
public: "公开"
|
||||
i18nInfo: "Firefish 已经被志愿者们翻译成了各种语言。如果您也有兴趣,可以通过 {link} 帮助翻译。"
|
||||
i18nServerInfo: "新客户端将默认使用 {language}。"
|
||||
i18nServerChange: "改为 {language}。"
|
||||
i18nServerSet: "设定新客户端使用 {language}。"
|
||||
manageAccessTokens: "管理访问令牌"
|
||||
accountInfo: "账号信息"
|
||||
notesCount: "帖子数量"
|
||||
|
@ -1230,6 +1233,16 @@ _ago:
|
|||
weeksAgo: "{n} 周前"
|
||||
monthsAgo: "{n} 月前"
|
||||
yearsAgo: "{n} 年前"
|
||||
_later:
|
||||
future: "将来"
|
||||
justNow: "马上"
|
||||
secondsAgo: "{n} 秒后"
|
||||
minutesAgo: "{n} 分后"
|
||||
hoursAgo: "{n} 时后"
|
||||
daysAgo: "{n} 天后"
|
||||
weeksAgo: "{n} 周后"
|
||||
monthsAgo: "{n} 月后"
|
||||
yearsAgo: "{n} 年后"
|
||||
_time:
|
||||
second: "秒"
|
||||
minute: "分"
|
||||
|
@ -2068,3 +2081,5 @@ noteEditHistory: "帖子编辑历史"
|
|||
media: 媒体
|
||||
slashQuote: "斜杠引用"
|
||||
foldNotification: "将通知按同类型分组"
|
||||
scheduledPost: "定时发送"
|
||||
scheduledDate: "发送日期"
|
||||
|
|
|
@ -661,6 +661,9 @@ unclip: "解除摘錄"
|
|||
confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?"
|
||||
public: "公開"
|
||||
i18nInfo: "Firefish已經被志願者們翻譯成各種語言版本,如果想要幫忙的話,可以進入{link}幫助翻譯。"
|
||||
i18nServerInfo: "新客戶端將默認使用 {language}。"
|
||||
i18nServerChange: "改為 {language}。"
|
||||
i18nServerSet: "設定新客戶端使用 {language}。"
|
||||
manageAccessTokens: "管理存取權杖"
|
||||
accountInfo: "帳戶資訊"
|
||||
notesCount: "貼文數量"
|
||||
|
|
|
@ -268,6 +268,7 @@ export interface NoteLikeForGetNoteSummary {
|
|||
hasPoll: boolean
|
||||
}
|
||||
export function getNoteSummary(note: NoteLikeForGetNoteSummary): string
|
||||
export function isSafeUrl(url: string): boolean
|
||||
export function latestVersion(): Promise<string>
|
||||
export function toMastodonId(firefishId: string): string | null
|
||||
export function fromMastodonId(mastodonId: string): string | null
|
||||
|
@ -1129,6 +1130,7 @@ export interface UserProfile {
|
|||
preventAiLearning: boolean
|
||||
isIndexable: boolean
|
||||
mutedPatterns: Array<string>
|
||||
lang: string | null
|
||||
}
|
||||
export interface UserPublickey {
|
||||
userId: string
|
||||
|
|
|
@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
|
||||
|
||||
module.exports.SECOND = SECOND
|
||||
module.exports.MINUTE = MINUTE
|
||||
|
@ -339,6 +339,7 @@ module.exports.safeForSql = safeForSql
|
|||
module.exports.formatMilliseconds = formatMilliseconds
|
||||
module.exports.getImageSizeFromUrl = getImageSizeFromUrl
|
||||
module.exports.getNoteSummary = getNoteSummary
|
||||
module.exports.isSafeUrl = isSafeUrl
|
||||
module.exports.latestVersion = latestVersion
|
||||
module.exports.toMastodonId = toMastodonId
|
||||
module.exports.fromMastodonId = fromMastodonId
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
#[crate::export]
|
||||
pub fn is_safe_url(url: &str) -> bool {
|
||||
if let Ok(url) = url.parse::<url::Url>() {
|
||||
if url.host_str().unwrap_or_default() == "unix"
|
||||
|| !["http", "https"].contains(&url.scheme())
|
||||
|| ![None, Some(80), Some(443)].contains(&url.port())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::is_safe_url;
|
||||
|
||||
#[test]
|
||||
fn safe_url() {
|
||||
assert!(is_safe_url("http://firefish.dev/firefish/firefish"));
|
||||
assert!(is_safe_url("https://firefish.dev/firefish/firefish"));
|
||||
assert!(is_safe_url("http://firefish.dev:80/firefish/firefish"));
|
||||
assert!(is_safe_url("https://firefish.dev:80/firefish/firefish"));
|
||||
assert!(is_safe_url("http://firefish.dev:443/firefish/firefish"));
|
||||
assert!(is_safe_url("https://firefish.dev:443/firefish/firefish"));
|
||||
assert!(!is_safe_url("https://unix/firefish/firefish"));
|
||||
assert!(!is_safe_url("https://firefish.dev:35/firefish/firefish"));
|
||||
assert!(!is_safe_url("ftp://firefish.dev/firefish/firefish"));
|
||||
assert!(!is_safe_url("nyaa"));
|
||||
assert!(!is_safe_url(""));
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ pub mod escape_sql;
|
|||
pub mod format_milliseconds;
|
||||
pub mod get_image_size;
|
||||
pub mod get_note_summary;
|
||||
pub mod is_safe_url;
|
||||
pub mod latest_version;
|
||||
pub mod mastodon_id;
|
||||
pub mod meta;
|
||||
|
|
|
@ -78,6 +78,7 @@ pub struct Model {
|
|||
pub is_indexable: bool,
|
||||
#[sea_orm(column_name = "mutedPatterns")]
|
||||
pub muted_patterns: Vec<String>,
|
||||
pub lang: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
@ -77,6 +77,7 @@ import { NoteFile } from "@/models/entities/note-file.js";
|
|||
|
||||
import { entities as charts } from "@/services/chart/entities.js";
|
||||
import { dbLogger } from "./logger.js";
|
||||
import { ScheduledNoteCreation } from "@/models/entities/scheduled-note-creation.js";
|
||||
|
||||
const sqlLogger = dbLogger.createSubLogger("sql", "gray", false);
|
||||
|
||||
|
@ -182,6 +183,7 @@ export const entities = [
|
|||
UserPending,
|
||||
Webhook,
|
||||
UserIp,
|
||||
ScheduledNoteCreation,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import type { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateScheduledNoteCreation1714728200194
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "scheduled_note_creation" (
|
||||
"id" character varying(32) NOT NULL,
|
||||
"noteId" character varying(32) NOT NULL,
|
||||
"userId" character varying(32) NOT NULL,
|
||||
"scheduledAt" TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
CONSTRAINT "PK_id_ScheduledNoteCreation" PRIMARY KEY ("id")
|
||||
)`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "scheduled_note_creation"."noteId" IS 'The ID of note scheduled.'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_noteId_ScheduledNoteCreation" ON "scheduled_note_creation" ("noteId")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_userId_ScheduledNoteCreation" ON "scheduled_note_creation" ("userId")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note_creation"
|
||||
ADD CONSTRAINT "FK_noteId_ScheduledNoteCreation"
|
||||
FOREIGN KEY ("noteId")
|
||||
REFERENCES "note"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note_creation"
|
||||
ADD CONSTRAINT "FK_userId_ScheduledNoteCreation"
|
||||
FOREIGN KEY ("userId")
|
||||
REFERENCES "user"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_noteId_ScheduledNoteCreation"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_userId_ScheduledNoteCreation"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TABLE "scheduled_note_creation"
|
||||
`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import type { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddUserProfileLanguage1714888400293 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32)`,
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
|
||||
}
|
||||
}
|
|
@ -7,10 +7,10 @@ import chalk from "chalk";
|
|||
import Logger from "@/services/logger.js";
|
||||
import IPCIDR from "ip-cidr";
|
||||
import PrivateIp from "private-ip";
|
||||
import { isValidUrl } from "./is-valid-url.js";
|
||||
import { isSafeUrl } from "backend-rs";
|
||||
|
||||
export async function downloadUrl(url: string, path: string): Promise<void> {
|
||||
if (!isValidUrl(url)) {
|
||||
if (!isSafeUrl(url)) {
|
||||
throw new StatusError("Invalid URL", 400);
|
||||
}
|
||||
|
||||
|
@ -43,8 +43,8 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
|
|||
limit: 0,
|
||||
},
|
||||
})
|
||||
.on("redirect", (res: Got.Response, opts: Got.NormalizedOptions) => {
|
||||
if (!isValidUrl(opts.url)) {
|
||||
.on("redirect", (_res: Got.Response, opts: Got.NormalizedOptions) => {
|
||||
if (!isSafeUrl(opts.url)) {
|
||||
downloadLogger.warn(`Invalid URL: ${opts.url}`);
|
||||
req.destroy();
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import CacheableLookup from "cacheable-lookup";
|
|||
import fetch, { type RequestRedirect } from "node-fetch";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
import { config } from "@/config.js";
|
||||
import { isValidUrl } from "./is-valid-url.js";
|
||||
import { isSafeUrl } from "backend-rs";
|
||||
|
||||
export async function getJson(
|
||||
url: string,
|
||||
|
@ -60,7 +60,7 @@ export async function getResponse(args: {
|
|||
size?: number;
|
||||
redirect?: RequestRedirect;
|
||||
}) {
|
||||
if (!isValidUrl(args.url)) {
|
||||
if (!isSafeUrl(args.url)) {
|
||||
throw new StatusError("Invalid URL", 400);
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@ export async function getResponse(args: {
|
|||
});
|
||||
|
||||
if (args.redirect === "manual" && [301, 302, 307, 308].includes(res.status)) {
|
||||
if (!isValidUrl(res.url)) {
|
||||
if (!isSafeUrl(res.url)) {
|
||||
throw new StatusError("Invalid URL", 400);
|
||||
}
|
||||
return res;
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
export function isValidUrl(url: string | URL | undefined): boolean {
|
||||
if (process.env.NODE_ENV !== "production") return true;
|
||||
|
||||
try {
|
||||
if (url == null) return false;
|
||||
|
||||
const u = typeof url === "string" ? new URL(url) : url;
|
||||
if (!u.protocol.match(/^https?:$/) || u.hostname === "unix") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (u.port !== "" && !["80", "443"].includes(u.port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
Entity,
|
||||
JoinColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
Index,
|
||||
} from "typeorm";
|
||||
import { Note } from "./note.js";
|
||||
import { id } from "../id.js";
|
||||
import { User } from "./user.js";
|
||||
|
||||
@Entity()
|
||||
export class ScheduledNoteCreation {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: "The ID of note scheduled.",
|
||||
})
|
||||
public noteId: Note["id"];
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User["id"];
|
||||
|
||||
@Column("timestamp without time zone")
|
||||
public scheduledAt: Date;
|
||||
|
||||
//#region Relations
|
||||
@ManyToOne(() => Note, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note;
|
||||
@ManyToOne(() => User, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User;
|
||||
//#endregion
|
||||
}
|
|
@ -50,6 +50,12 @@ export class UserProfile {
|
|||
verified?: boolean;
|
||||
}[];
|
||||
|
||||
@Column("varchar", {
|
||||
length: 32,
|
||||
nullable: true,
|
||||
})
|
||||
public lang: string | null;
|
||||
|
||||
@Column("varchar", {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
|
|
|
@ -67,6 +67,7 @@ import { Webhook } from "./entities/webhook.js";
|
|||
import { UserIp } from "./entities/user-ip.js";
|
||||
import { NoteFileRepository } from "./repositories/note-file.js";
|
||||
import { NoteEditRepository } from "./repositories/note-edit.js";
|
||||
import { ScheduledNoteCreation } from "./entities/scheduled-note-creation.js";
|
||||
|
||||
export const Announcements = db.getRepository(Announcement);
|
||||
export const AnnouncementReads = db.getRepository(AnnouncementRead);
|
||||
|
@ -135,3 +136,4 @@ export const RegistryItems = db.getRepository(RegistryItem);
|
|||
export const Webhooks = db.getRepository(Webhook);
|
||||
export const Ads = db.getRepository(Ad);
|
||||
export const PasswordResetRequests = db.getRepository(PasswordResetRequest);
|
||||
export const ScheduledNoteCreations = db.getRepository(ScheduledNoteCreation);
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Polls,
|
||||
Channels,
|
||||
Notes,
|
||||
ScheduledNoteCreations,
|
||||
} from "../index.js";
|
||||
import type { Packed } from "@/misc/schema.js";
|
||||
import { countReactions, decodeReaction, nyaify } from "backend-rs";
|
||||
|
@ -198,6 +199,15 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
host,
|
||||
);
|
||||
|
||||
let scheduledAt: string | undefined;
|
||||
if (note.visibility === "specified" && note.visibleUserIds.length === 0) {
|
||||
scheduledAt = (
|
||||
await ScheduledNoteCreations.findOneBy({
|
||||
noteId: note.id,
|
||||
})
|
||||
)?.scheduledAt?.toISOString();
|
||||
}
|
||||
|
||||
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
|
||||
const packed: Packed<"Note"> = await awaitAll({
|
||||
id: note.id,
|
||||
|
@ -231,6 +241,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
},
|
||||
})
|
||||
: undefined,
|
||||
scheduledAt,
|
||||
reactions: countReactions(note.reactions),
|
||||
reactionEmojis: reactionEmoji,
|
||||
emojis: noteEmoji,
|
||||
|
|
|
@ -512,6 +512,7 @@ export const UserRepository = db.getRepository(User).extend({
|
|||
description: profile!.description,
|
||||
location: profile!.location,
|
||||
birthday: profile!.birthday,
|
||||
lang: profile!.lang,
|
||||
fields: profile!.fields,
|
||||
followersCount: followersCount ?? null,
|
||||
followingCount: followingCount ?? null,
|
||||
|
|
|
@ -204,6 +204,12 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
optional: false,
|
||||
example: "2018-03-12",
|
||||
},
|
||||
lang: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
optional: false,
|
||||
example: "ja-JP",
|
||||
},
|
||||
fields: {
|
||||
type: "array",
|
||||
nullable: false,
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
endedPollNotificationQueue,
|
||||
webhookDeliverQueue,
|
||||
} from "./queues.js";
|
||||
import type { ThinUser } from "./types.js";
|
||||
import type { DbUserScheduledCreateNoteData, ThinUser } from "./types.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
|
||||
function renderError(e: Error): any {
|
||||
|
@ -455,6 +455,17 @@ export function createDeleteAccountJob(
|
|||
);
|
||||
}
|
||||
|
||||
export function createScheduledCreateNoteJob(
|
||||
options: DbUserScheduledCreateNoteData,
|
||||
delay: number,
|
||||
) {
|
||||
return dbQueue.add("scheduledCreateNote", options, {
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDeleteObjectStorageFileJob(key: string) {
|
||||
return objectStorageQueue.add(
|
||||
"deleteFile",
|
||||
|
|
|
@ -16,6 +16,7 @@ import { importMastoPost } from "./import-masto-post.js";
|
|||
import { importCkPost } from "./import-firefish-post.js";
|
||||
import { importBlocking } from "./import-blocking.js";
|
||||
import { importCustomEmojis } from "./import-custom-emojis.js";
|
||||
import { scheduledCreateNote } from "./scheduled-create-note.js";
|
||||
|
||||
const jobs = {
|
||||
deleteDriveFiles,
|
||||
|
@ -34,6 +35,7 @@ const jobs = {
|
|||
importCkPost,
|
||||
importCustomEmojis,
|
||||
deleteAccount,
|
||||
scheduledCreateNote,
|
||||
} as Record<
|
||||
string,
|
||||
| Bull.ProcessCallbackFunction<DbJobData>
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { Users, Notes, ScheduledNoteCreations } from "@/models/index.js";
|
||||
import type { DbUserScheduledCreateNoteData } from "@/queue/types.js";
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import type Bull from "bull";
|
||||
import deleteNote from "@/services/note/delete.js";
|
||||
import createNote from "@/services/note/create.js";
|
||||
import { In } from "typeorm";
|
||||
|
||||
const logger = queueLogger.createSubLogger("scheduled-post");
|
||||
|
||||
export async function scheduledCreateNote(
|
||||
job: Bull.Job<DbUserScheduledCreateNoteData>,
|
||||
done: () => void,
|
||||
): Promise<void> {
|
||||
logger.info("Scheduled creating note...");
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await Notes.findOneBy({ id: job.data.noteId });
|
||||
if (note == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
deleteNote(user, note);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
await ScheduledNoteCreations.delete({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const visibleUsers = job.data.option.visibleUserIds
|
||||
? await Users.findBy({
|
||||
id: In(job.data.option.visibleUserIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
await createNote(user, {
|
||||
createdAt: new Date(),
|
||||
files: note.files,
|
||||
poll: job.data.option.poll,
|
||||
text: note.text || undefined,
|
||||
lang: note.lang,
|
||||
reply: note.reply,
|
||||
renote: note.renote,
|
||||
cw: note.cw,
|
||||
localOnly: note.localOnly,
|
||||
visibility: job.data.option.visibility,
|
||||
visibleUsers,
|
||||
channel: note.channel,
|
||||
});
|
||||
|
||||
await deleteNote(user, note);
|
||||
|
||||
logger.info("Success");
|
||||
|
||||
done();
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||
import type { Note } from "@/models/entities/note";
|
||||
import type { IPoll } from "@/models/entities/poll";
|
||||
import type { User } from "@/models/entities/user.js";
|
||||
import type { Webhook } from "@/models/entities/webhook";
|
||||
import type { IActivity } from "@/remote/activitypub/type.js";
|
||||
|
@ -24,7 +25,8 @@ export type DbJobData =
|
|||
| DbUserImportPostsJobData
|
||||
| DbUserImportJobData
|
||||
| DbUserDeleteJobData
|
||||
| DbUserImportMastoPostJobData;
|
||||
| DbUserImportMastoPostJobData
|
||||
| DbUserScheduledCreateNoteData;
|
||||
|
||||
export type DbUserJobData = {
|
||||
user: ThinUser;
|
||||
|
@ -55,6 +57,16 @@ export type DbUserImportMastoPostJobData = {
|
|||
parent: Note | null;
|
||||
};
|
||||
|
||||
export type DbUserScheduledCreateNoteData = {
|
||||
user: ThinUser;
|
||||
option: {
|
||||
visibility: string;
|
||||
visibleUserIds?: string[] | null;
|
||||
poll?: IPoll;
|
||||
};
|
||||
noteId: Note["id"];
|
||||
};
|
||||
|
||||
export type ObjectStorageJobData =
|
||||
| ObjectStorageFileJobData
|
||||
| Record<string, unknown>;
|
||||
|
|
|
@ -5,8 +5,8 @@ import { StatusError, getResponse } from "@/misc/fetch.js";
|
|||
import { createSignedPost, createSignedGet } from "./ap-request.js";
|
||||
import type { Response } from "node-fetch";
|
||||
import type { IObject } from "./type.js";
|
||||
import { isValidUrl } from "@/misc/is-valid-url.js";
|
||||
import { apLogger } from "@/remote/activitypub/logger.js";
|
||||
import { isSafeUrl } from "backend-rs";
|
||||
|
||||
export default async (user: { id: User["id"] }, url: string, object: any) => {
|
||||
const body = JSON.stringify(object);
|
||||
|
@ -44,7 +44,7 @@ export async function apGet(
|
|||
user?: ILocalUser,
|
||||
redirects: boolean = true,
|
||||
): Promise<{ finalUrl: string; content: IObject }> {
|
||||
if (!isValidUrl(url)) {
|
||||
if (!isSafeUrl(url)) {
|
||||
throw new StatusError("Invalid URL", 400);
|
||||
}
|
||||
|
||||
|
|
|
@ -87,6 +87,7 @@ export const paramDef = {
|
|||
description: { ...Users.descriptionSchema, nullable: true },
|
||||
location: { ...Users.locationSchema, nullable: true },
|
||||
birthday: { ...Users.birthdaySchema, nullable: true },
|
||||
lang: { type: "string", nullable: true },
|
||||
avatarId: { type: "string", format: "misskey:id", nullable: true },
|
||||
bannerId: { type: "string", format: "misskey:id", nullable: true },
|
||||
fields: {
|
||||
|
@ -154,6 +155,7 @@ export default define(meta, paramDef, async (ps, _user, token) => {
|
|||
|
||||
if (ps.name !== undefined) updates.name = ps.name;
|
||||
if (ps.description !== undefined) profileUpdates.description = ps.description;
|
||||
if (typeof ps.lang === "string") profileUpdates.lang = ps.lang;
|
||||
if (ps.location !== undefined) profileUpdates.location = ps.location;
|
||||
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
|
||||
if (ps.ffVisibility !== undefined)
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
Notes,
|
||||
Channels,
|
||||
Blockings,
|
||||
ScheduledNoteCreations,
|
||||
} from "@/models/index.js";
|
||||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
|
@ -15,9 +16,10 @@ import { config } from "@/config.js";
|
|||
import { noteVisibilities } from "@/types.js";
|
||||
import { ApiError } from "@/server/api/error.js";
|
||||
import define from "@/server/api/define.js";
|
||||
import { HOUR } from "backend-rs";
|
||||
import { HOUR, genId } from "backend-rs";
|
||||
import { getNote } from "@/server/api/common/getters.js";
|
||||
import { langmap } from "@/misc/langmap.js";
|
||||
import { createScheduledCreateNoteJob } from "@/queue";
|
||||
|
||||
export const meta = {
|
||||
tags: ["notes"],
|
||||
|
@ -156,6 +158,7 @@ export const paramDef = {
|
|||
},
|
||||
required: ["choices"],
|
||||
},
|
||||
scheduledAt: { type: "integer", nullable: true },
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
|
@ -274,8 +277,20 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
if (ps.poll.expiresAt < Date.now()) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
if (
|
||||
ps.poll.expiresAt &&
|
||||
ps.scheduledAt &&
|
||||
ps.poll.expiresAt < Number(new Date(ps.scheduledAt))
|
||||
) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
} else if (typeof ps.poll.expiredAfter === "number") {
|
||||
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
|
||||
if (ps.scheduledAt) {
|
||||
ps.poll.expiresAt =
|
||||
Number(new Date(ps.scheduledAt)) + ps.poll.expiredAfter;
|
||||
} else {
|
||||
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -288,31 +303,80 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
}
|
||||
|
||||
let delay: number | null = null;
|
||||
if (ps.scheduledAt) {
|
||||
delay = Number(ps.scheduledAt) - Number(new Date());
|
||||
if (delay < 0) {
|
||||
delay = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a post
|
||||
const note = await create(user, {
|
||||
createdAt: new Date(),
|
||||
files: files,
|
||||
poll: ps.poll
|
||||
? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
const note = await create(
|
||||
user,
|
||||
{
|
||||
createdAt: new Date(),
|
||||
files: files,
|
||||
poll: ps.poll
|
||||
? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
}
|
||||
: undefined,
|
||||
text: ps.text || undefined,
|
||||
lang: ps.lang,
|
||||
reply,
|
||||
renote,
|
||||
cw: ps.cw,
|
||||
localOnly: ps.localOnly,
|
||||
...(delay != null
|
||||
? {
|
||||
visibility: "specified",
|
||||
visibleUsers: [],
|
||||
}
|
||||
: {
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
}),
|
||||
channel,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
},
|
||||
false,
|
||||
delay
|
||||
? async (note) => {
|
||||
await ScheduledNoteCreations.insert({
|
||||
id: genId(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
scheduledAt: new Date(ps.scheduledAt as number),
|
||||
});
|
||||
|
||||
createScheduledCreateNoteJob(
|
||||
{
|
||||
user: { id: user.id },
|
||||
noteId: note.id,
|
||||
option: {
|
||||
poll: ps.poll
|
||||
? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple,
|
||||
expiresAt: ps.poll.expiresAt
|
||||
? new Date(ps.poll.expiresAt)
|
||||
: null,
|
||||
}
|
||||
: undefined,
|
||||
visibility: ps.visibility,
|
||||
visibleUserIds: ps.visibleUserIds,
|
||||
},
|
||||
},
|
||||
delay,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
text: ps.text || undefined,
|
||||
lang: ps.lang,
|
||||
reply,
|
||||
renote,
|
||||
cw: ps.cw,
|
||||
localOnly: ps.localOnly,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
});
|
||||
|
||||
);
|
||||
return {
|
||||
createdNote: await Notes.pack(note, user),
|
||||
};
|
||||
|
|
|
@ -175,6 +175,7 @@ export default async (
|
|||
},
|
||||
data: Option,
|
||||
silent = false,
|
||||
waitToPublish?: (note: Note) => Promise<void>,
|
||||
) =>
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
|
||||
new Promise<Note>(async (res, rej) => {
|
||||
|
@ -356,6 +357,8 @@ export default async (
|
|||
|
||||
res(note);
|
||||
|
||||
if (waitToPublish) await waitToPublish(note);
|
||||
|
||||
// Register host
|
||||
if (Users.isRemoteUser(user)) {
|
||||
registerOrFetchInstanceDoc(user.host).then((i) => {
|
||||
|
|
|
@ -132,6 +132,9 @@ export async function signIn(token: Account["token"], redirect?: string) {
|
|||
if (_DEV_) console.log("logging as token ", token);
|
||||
const newAccount = await fetchAccount(token);
|
||||
localStorage.setItem("account", JSON.stringify(newAccount));
|
||||
if (newAccount.lang) {
|
||||
localStorage.setItem("lang", newAccount.lang);
|
||||
}
|
||||
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
|
||||
await addAccount(newAccount.id, token);
|
||||
|
||||
|
|
|
@ -65,7 +65,8 @@
|
|||
v-if="isMyRenote"
|
||||
:class="icon('ph-dots-three-outline dropdownIcon')"
|
||||
></i>
|
||||
<MkTime :time="note.createdAt" />
|
||||
<MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/>
|
||||
<MkTime v-else :time="note.createdAt" />
|
||||
</button>
|
||||
<MkVisibility :note="note" />
|
||||
</div>
|
||||
|
@ -147,7 +148,8 @@
|
|||
class="created-at"
|
||||
:to="notePage(appearNote)"
|
||||
>
|
||||
<MkTime :time="appearNote.createdAt" mode="absolute" />
|
||||
<MkTime v-if="appearNote.scheduledAt != null" :time="appearNote.scheduledAt"/>
|
||||
<MkTime v-else :time="appearNote.createdAt" mode="absolute" />
|
||||
</MkA>
|
||||
<MkA
|
||||
v-if="appearNote.channel && !inChannel"
|
||||
|
@ -173,6 +175,7 @@
|
|||
v-tooltip.noDelay.bottom="i18n.ts.reply"
|
||||
class="button _button"
|
||||
@click.stop="reply()"
|
||||
:disabled="note.scheduledAt != null"
|
||||
>
|
||||
<i :class="icon('ph-arrow-u-up-left')"></i>
|
||||
<template
|
||||
|
@ -187,6 +190,7 @@
|
|||
:note="appearNote"
|
||||
:count="appearNote.renoteCount"
|
||||
:detailed-view="detailedView"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<XStarButtonNoEmoji
|
||||
v-if="!enableEmojiReactions"
|
||||
|
@ -194,6 +198,7 @@
|
|||
:note="appearNote"
|
||||
:count="reactionCount"
|
||||
:reacted="appearNote.myReaction != null"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<XStarButton
|
||||
v-if="
|
||||
|
@ -203,6 +208,7 @@
|
|||
ref="starButton"
|
||||
class="button"
|
||||
:note="appearNote"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<button
|
||||
v-if="
|
||||
|
@ -213,6 +219,7 @@
|
|||
v-tooltip.noDelay.bottom="i18n.ts.reaction"
|
||||
class="button _button"
|
||||
@click.stop="react()"
|
||||
:disabled="note.scheduledAt != null"
|
||||
>
|
||||
<i :class="icon('ph-smiley')"></i>
|
||||
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
|
||||
|
@ -226,11 +233,12 @@
|
|||
v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
|
||||
class="button _button reacted"
|
||||
@click.stop="undoReact(appearNote)"
|
||||
:disabled="note.scheduledAt != null"
|
||||
>
|
||||
<i :class="icon('ph-minus')"></i>
|
||||
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
|
||||
</button>
|
||||
<XQuoteButton class="button" :note="appearNote" />
|
||||
<XQuoteButton class="button" :note="appearNote" :disabled="note.scheduledAt != null"/>
|
||||
<button
|
||||
v-if="
|
||||
isSignedIn(me) &&
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
<div>
|
||||
<div class="info">
|
||||
<MkA class="created-at" :to="notePage(note)">
|
||||
<MkTime :time="note.createdAt" />
|
||||
<MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/>
|
||||
<MkTime v-else :time="note.createdAt" />
|
||||
<i
|
||||
v-if="note.updatedAt"
|
||||
v-tooltip.noDelay="
|
||||
|
|
|
@ -54,6 +54,15 @@
|
|||
><i :class="icon('ph-eye-slash')"></i
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
v-if="editId == null"
|
||||
v-tooltip="i18n.ts.scheduledPost"
|
||||
class="_button schedule"
|
||||
:class="{ active: scheduledAt }"
|
||||
@click="setScheduledAt"
|
||||
>
|
||||
<i :class="icon('ph-clock')"></i>
|
||||
</button>
|
||||
<button
|
||||
ref="languageButton"
|
||||
v-tooltip="i18n.ts.language"
|
||||
|
@ -432,6 +441,7 @@ const recentHashtags = ref(
|
|||
JSON.parse(localStorage.getItem("hashtags") || "[]"),
|
||||
);
|
||||
const imeText = ref("");
|
||||
const scheduledAt = ref<number | null>(null);
|
||||
|
||||
const typing = throttle(3000, () => {
|
||||
if (props.channel) {
|
||||
|
@ -772,6 +782,38 @@ function setVisibility() {
|
|||
);
|
||||
}
|
||||
|
||||
async function setScheduledAt() {
|
||||
function getDateStr(type: "date" | "time", value: number) {
|
||||
const tmp = document.createElement("input");
|
||||
tmp.type = type;
|
||||
tmp.valueAsNumber = value - new Date().getTimezoneOffset() * 60000;
|
||||
return tmp.value;
|
||||
}
|
||||
|
||||
const at = scheduledAt.value ?? Date.now();
|
||||
|
||||
const result = await os.form(i18n.ts.scheduledPost, {
|
||||
at_date: {
|
||||
type: "date",
|
||||
label: i18n.ts.scheduledDate,
|
||||
default: getDateStr("date", at),
|
||||
},
|
||||
at_time: {
|
||||
type: "time",
|
||||
label: i18n.ts._poll.deadlineTime,
|
||||
default: getDateStr("time", at),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.canceled && result.result) {
|
||||
scheduledAt.value = Number(
|
||||
new Date(`${result.result.at_date}T${result.result.at_time}`),
|
||||
);
|
||||
} else {
|
||||
scheduledAt.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const language = ref<string | null>(
|
||||
props.initialLanguage ??
|
||||
defaultStore.state.recentlyUsedPostLanguages[0] ??
|
||||
|
@ -1176,6 +1218,7 @@ async function post() {
|
|||
: visibility.value === "specified"
|
||||
? visibleUsers.value.map((u) => u.id)
|
||||
: undefined,
|
||||
scheduledAt: scheduledAt.value,
|
||||
};
|
||||
|
||||
if (withHashtags.value && hashtags.value && hashtags.value.trim() !== "") {
|
||||
|
@ -1224,6 +1267,7 @@ async function post() {
|
|||
}
|
||||
posting.value = false;
|
||||
postAccount.value = null;
|
||||
scheduledAt.value = null;
|
||||
nextTick(() => autosize.update(textareaEl.value!));
|
||||
});
|
||||
})
|
||||
|
@ -1434,6 +1478,14 @@ onMounted(() => {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .schedule {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
> .text-count {
|
||||
opacity: 0.7;
|
||||
line-height: 66px;
|
||||
|
|
|
@ -10,6 +10,12 @@
|
|||
v-tooltip="i18n.ts._visibility.followers"
|
||||
:class="icon('ph-lock')"
|
||||
></i>
|
||||
<i
|
||||
v-else-if="note.visibility === 'specified' && note.scheduledAt"
|
||||
ref="specified"
|
||||
v-tooltip="`scheduled at ${note.scheduledAt}`"
|
||||
:class="icon('ph-clock')"
|
||||
></i>
|
||||
<i
|
||||
v-else-if="
|
||||
note.visibility === 'specified' &&
|
||||
|
@ -41,13 +47,10 @@ import * as os from "@/os";
|
|||
import { useTooltip } from "@/scripts/use-tooltip";
|
||||
import { i18n } from "@/i18n";
|
||||
import icon from "@/scripts/icon";
|
||||
import type { entities } from "firefish-js";
|
||||
|
||||
const props = defineProps<{
|
||||
note: {
|
||||
visibility: string;
|
||||
localOnly?: boolean;
|
||||
visibleUserIds?: string[];
|
||||
};
|
||||
note: entities.Note;
|
||||
}>();
|
||||
|
||||
const specified = ref<HTMLElement>();
|
||||
|
|
|
@ -42,36 +42,42 @@ const relative = computed<string>(() => {
|
|||
if (props.mode === "absolute") return ""; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
||||
const ago = (now.value - _time) / 1000; /* ms */
|
||||
let ago = (now.value - _time) / 1000; /* ms */
|
||||
|
||||
const agoType = ago > 0 ? "_ago" : "_later";
|
||||
ago = Math.abs(ago);
|
||||
|
||||
return ago >= 31536000
|
||||
? i18n.t("_ago.yearsAgo", { n: Math.floor(ago / 31536000).toString() })
|
||||
? i18n.t(`${agoType}.yearsAgo`, {
|
||||
n: Math.floor(ago / 31536000).toString(),
|
||||
})
|
||||
: ago >= 2592000
|
||||
? i18n.t("_ago.monthsAgo", {
|
||||
? i18n.t(`${agoType}.monthsAgo`, {
|
||||
n: Math.floor(ago / 2592000).toString(),
|
||||
})
|
||||
: ago >= 604800
|
||||
? i18n.t("_ago.weeksAgo", {
|
||||
? i18n.t(`${agoType}.weeksAgo`, {
|
||||
n: Math.floor(ago / 604800).toString(),
|
||||
})
|
||||
: ago >= 86400
|
||||
? i18n.t("_ago.daysAgo", {
|
||||
? i18n.t(`${agoType}.daysAgo`, {
|
||||
n: Math.floor(ago / 86400).toString(),
|
||||
})
|
||||
: ago >= 3600
|
||||
? i18n.t("_ago.hoursAgo", {
|
||||
? i18n.t(`${agoType}.hoursAgo`, {
|
||||
n: Math.floor(ago / 3600).toString(),
|
||||
})
|
||||
: ago >= 60
|
||||
? i18n.t("_ago.minutesAgo", {
|
||||
? i18n.t(`${agoType}.minutesAgo`, {
|
||||
n: (~~(ago / 60)).toString(),
|
||||
})
|
||||
: ago >= 10
|
||||
? i18n.t("_ago.secondsAgo", {
|
||||
? i18n.t(`${agoType}.secondsAgo`, {
|
||||
n: (~~(ago % 60)).toString(),
|
||||
})
|
||||
: ago >= -1
|
||||
? i18n.ts._ago.justNow
|
||||
: i18n.ts._ago.future;
|
||||
? i18n.ts[agoType].justNow
|
||||
: i18n.ts[agoType].future;
|
||||
});
|
||||
|
||||
let tickId: number;
|
||||
|
|
|
@ -14,6 +14,12 @@
|
|||
>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n :src="i18n.ts.i18nServerInfo" v-if="serverLang" tag="span">
|
||||
<template #language><strong>{{ langs.find(a => a[0] === serverLang)?.[1] ?? serverLang }}</strong></template>
|
||||
</I18n>
|
||||
<button class="_textButton" @click="updateServerLang" v-if="lang && lang !== serverLang">
|
||||
{{i18n.t(serverLang ? "i18nServerChange" : "i18nServerSet", { language: langs.find(a => a[0] === lang)?.[1] ?? lang })}}
|
||||
</button>
|
||||
</template>
|
||||
</FormSelect>
|
||||
|
||||
|
@ -404,6 +410,7 @@ import { deviceKind } from "@/scripts/device-kind";
|
|||
import icon from "@/scripts/icon";
|
||||
|
||||
const lang = ref(localStorage.getItem("lang"));
|
||||
const serverLang = ref(me?.lang);
|
||||
const translateLang = ref(localStorage.getItem("translateLang"));
|
||||
const fontSize = ref(localStorage.getItem("fontSize"));
|
||||
const useSystemFont = ref(localStorage.getItem("useSystemFont") !== "f");
|
||||
|
@ -559,6 +566,14 @@ const foldNotification = computed(
|
|||
// });
|
||||
// }
|
||||
|
||||
function updateServerLang() {
|
||||
os.api("i/update", {
|
||||
lang: lang.value,
|
||||
}).then((i) => {
|
||||
serverLang.value = i.lang;
|
||||
});
|
||||
}
|
||||
|
||||
watch(swipeOnDesktop, () => {
|
||||
defaultStore.set("swipeOnMobile", true);
|
||||
});
|
||||
|
|
|
@ -38,11 +38,11 @@ export type FormItemUrl = BaseFormItem & {
|
|||
};
|
||||
export type FormItemDate = BaseFormItem & {
|
||||
type: "date";
|
||||
default?: Date | null;
|
||||
default?: string | Date | null;
|
||||
};
|
||||
export type FormItemTime = BaseFormItem & {
|
||||
type: "time";
|
||||
default?: number | Date | null;
|
||||
default?: string | Date | null;
|
||||
};
|
||||
export type FormItemSearch = BaseFormItem & {
|
||||
type: "search";
|
||||
|
|
|
@ -69,6 +69,7 @@ export type NoteSubmitReq = {
|
|||
expiredAfter: number | null;
|
||||
};
|
||||
lang?: string;
|
||||
scheduledAt?: number | null;
|
||||
};
|
||||
|
||||
export type Endpoints = {
|
||||
|
|
|
@ -193,6 +193,7 @@ export type Note = {
|
|||
url?: string;
|
||||
updatedAt?: DateString;
|
||||
isHidden?: boolean;
|
||||
scheduledAt?: DateString;
|
||||
/** if the note is a history */
|
||||
historyId?: ID;
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import path, { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execa } from "execa";
|
||||
import fs from "node:fs";
|
||||
|
||||
(async () => {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
@ -32,4 +33,18 @@ import { execa } from "execa";
|
|||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
|
||||
if (!fs.existsSync(join(__dirname, "/../packages/backend-rs/built/index.js"))) {
|
||||
fs.copyFileSync(
|
||||
join(__dirname, "/../packages/backend-rs/index.js"),
|
||||
join(__dirname, "/../packages/backend-rs/built/index.js"),
|
||||
);
|
||||
console.warn("backend-rs/built/index.js has not been updated (https://github.com/napi-rs/napi-rs/issues/1768)");
|
||||
}
|
||||
if (!fs.existsSync(join(__dirname, "/../packages/backend-rs/built/index.d.ts"))) {
|
||||
fs.copyFileSync(
|
||||
join(__dirname, "/../packages/backend-rs/index.d.ts"),
|
||||
join(__dirname, "/../packages/backend-rs/built/index.d.ts"),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
|
Loading…
Reference in New Issue