Page MenuHomePhorge

No OneTemporary

Size
42 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index ef6b0fce7e..794ac8b0dd 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,307 +1,322 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
+import stringUtils from '../../services/string_utils/string_utils.js'
import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import PollForm from '../poll/poll_form.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { reject, map, uniqBy } from 'lodash'
import suggestor from '../emoji-input/suggestor.js'
const buildMentionsString = ({ user, attentions }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
let mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject'
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector
},
mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
this.$refs.textarea.focus()
}
},
data () {
const preset = this.$route.query.message
let statusText = preset || ''
const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined'
? this.$store.state.instance.scopeCopy
: this.$store.state.config.scopeCopy
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const contentType = typeof this.$store.state.config.postContentType === 'undefined'
? this.$store.state.instance.postContentType
: this.$store.state.config.postContentType
return {
dropFiles: [],
submitDisabled: false,
error: null,
posting: false,
highlighted: 0,
newStatus: {
spoilerText: this.subject || '',
status: statusText,
nsfw: false,
files: [],
poll: {},
visibility: scope,
contentType
},
caret: 0,
- pollFormVisible: false
+ pollFormVisible: false,
+ idempotencyKey: ''
}
},
computed: {
users () {
return this.$store.state.users.users
},
userDefaultScope () {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes () {
const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined'
? this.$store.state.instance.minimalScopesMode
: this.$store.state.config.minimalScopesMode
return !minimalScopesMode
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users
})
},
emojiSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
]
})
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
},
statusLength () {
return this.newStatus.status.length
},
spoilerTextLength () {
return this.newStatus.spoilerText.length
},
statusLengthLimit () {
return this.$store.state.instance.textlimit
},
hasStatusLengthLimit () {
return this.statusLengthLimit > 0
},
charactersLeft () {
return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
},
isOverLengthLimit () {
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
},
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
alwaysShowSubject () {
if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.config.alwaysShowSubjectInput
} else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.instance.alwaysShowSubjectInput
} else {
return true
}
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
safeDMEnabled () {
return this.$store.state.instance.safeDM
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2
},
hideScopeNotice () {
return this.$store.state.config.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
}
},
methods: {
+ createIdempotencyKey () {
+ const idempotencyKey = stringUtils.randomString(20)
+ return idempotencyKey
+ },
postStatus (newStatus) {
if (this.posting) { return }
if (this.submitDisabled) { return }
if (this.newStatus.status === '') {
if (this.newStatus.files.length > 0) {
this.newStatus.status = '\u200b' // hack
} else {
this.error = 'Cannot post an empty status with no files'
return
}
}
const poll = this.pollFormVisible ? this.newStatus.poll : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
+ // Idempotency key changes if status is unique, or if there is a response from the server.
+ const keyHash = stringUtils.hashString(newStatus.status)
+ if (this.idempotencyKey.indexOf(keyHash) === -1) {
+ this.idempotencyKey = this.createIdempotencyKey() + keyHash
+ }
+
this.posting = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
- poll
+ poll,
+ idempotencyKey: this.idempotencyKey
}).then((data) => {
+ // Reset idempotency key on response
+ this.idempotencyKey = ''
if (!data.error) {
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {}
}
this.pollFormVisible = false
this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
} else {
this.error = data.error
}
this.posting = false
})
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
},
disableSubmit () {
this.submitDisabled = true
},
enableSubmit () {
this.submitDisabled = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
}
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
// Remove "px" at the end of the values
const vertPadding = Number(topPaddingStr.substr(0, topPaddingStr.length - 2)) +
Number(bottomPaddingStr.substr(0, bottomPaddingStr.length - 2))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') {
target.style.height = null
}
},
clearError () {
this.error = null
},
changeVis (visibility) {
this.newStatus.visibility = visibility
},
togglePollForm () {
this.pollFormVisible = !this.pollFormVisible
},
setPoll (poll) {
this.newStatus.poll = poll
},
clearPollForm () {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
}
}
}
export default PostStatusForm
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 005eda3eeb..896623faa3 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,968 +1,911 @@
import { each, map, concat, last } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch'
import { StatusCodeError } from '../errors/errors'
/* eslint-env browser */
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const FOLLOW_REQUESTS_URL = '/api/pleroma/friend_requests'
const APPROVE_USER_URL = '/api/pleroma/friendships/approve'
const DENY_USER_URL = '/api/pleroma/friendships/deny'
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}`
const ACTIVATION_STATUS_URL = screenName => `/api/pleroma/admin/users/${screenName}/activation_status`
const ADMIN_USERS_URL = '/api/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const MFA_SETTINGS_URL = '/api/pleroma/profile/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/profile/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/profile/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/profile/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/profile/mfa/totp'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = id => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = id => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = id => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = id => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOWING_URL = id => `/api/v1/accounts/${id}/following`
const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = '/api/v1/timelines/direct'
const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
const MASTODON_POLL_URL = id => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by`
const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin`
const oldfetch = window.fetch
let fetch = (url, options) => {
options = options || {}
const baseUrl = ''
const fullUrl = baseUrl + url
options.credentials = 'same-origin'
return oldfetch(fullUrl, options)
}
const promisedRequest = ({ method, url, payload, credentials, headers = {} }) => {
const options = {
method,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...headers
}
}
if (payload) {
options.body = JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials)
}
}
return fetch(url, options)
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
return resolve(json)
}))
})
}
const updateNotificationSettings = ({ credentials, settings }) => {
const form = new FormData()
each(settings, (value, key) => {
form.append(key, value)
})
return fetch(NOTIFICATION_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'PUT',
body: form
}).then((data) => data.json())
}
const updateAvatar = ({ credentials, avatar }) => {
const form = new FormData()
form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBg = ({ credentials, background }) => {
const form = new FormData()
form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBanner = ({ credentials, banner }) => {
const form = new FormData()
form.append('header', banner)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
method: 'PATCH',
payload: params,
credentials
}).then((data) => parseUser(data))
}
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return fetch(MASTODON_REGISTRATION_URL, {
method: 'POST',
headers: {
...authHeaders(credentials),
'Content-Type': 'application/json'
},
body: JSON.stringify({
nickname,
locale: 'en_US',
agreement: true,
...rest
})
})
.then((response) => [response.ok, response])
.then(([ok, response]) => {
if (ok) {
return response.json()
} else {
return response.json().then((error) => { throw new Error(error) })
}
})
}
const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json())
const authHeaders = (accessToken) => {
if (accessToken) {
return { 'Authorization': `Bearer ${accessToken}` }
} else {
return { }
}
}
const externalProfile = ({ profileUrl, credentials }) => {
let url = `${EXTERNAL_PROFILE_URL}?profileurl=${profileUrl}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const followUser = ({ id, credentials }) => {
let url = MASTODON_FOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const unfollowUser = ({ id, credentials }) => {
let url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const pinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const unpinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNPIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
}
const blockUser = ({ id, credentials }) => {
return fetch(MASTODON_BLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const unblockUser = ({ id, credentials }) => {
return fetch(MASTODON_UNBLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const approveUser = ({ id, credentials }) => {
let url = `${APPROVE_USER_URL}?user_id=${id}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const denyUser = ({ id, credentials }) => {
let url = `${DENY_USER_URL}?user_id=${id}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const fetchUser = ({ id, credentials }) => {
let url = `${MASTODON_USER_URL}/${id}`
return promisedRequest({ url, credentials })
.then((data) => parseUser(data))
}
const fetchUserRelationship = ({ id, credentials }) => {
let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) })
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url }, response))
}
return resolve(json)
}))
})
}
const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWING_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const exportFriends = ({ id, credentials }) => {
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({id, maxId, credentials})
friends = concat(friends, users)
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWERS_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url += args ? '?' + args : ''
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchFollowRequests = ({ credentials }) => {
const url = FOLLOW_REQUESTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const fetchConversation = ({ id, credentials }) => {
let urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then(({ ancestors, descendants }) => ({
ancestors: ancestors.map(parseStatus),
descendants: descendants.map(parseStatus)
}))
}
const fetchStatus = ({ id, credentials }) => {
let url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then((data) => parseStatus(data))
}
const tagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const form = {
nicknames: [screenName],
tags: [tag]
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'PUT',
headers: headers,
body: JSON.stringify(form)
})
}
const untagUser = ({ tag, credentials, ...options }) => {
const screenName = options.screen_name
const body = {
nicknames: [screenName],
tags: [tag]
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'DELETE',
headers: headers,
body: JSON.stringify(body)
})
}
const addRight = ({ right, credentials, ...user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'POST',
headers: authHeaders(credentials),
body: {}
})
}
const deleteRight = ({ right, credentials, ...user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'DELETE',
headers: authHeaders(credentials),
body: {}
})
}
const setActivationStatus = ({ status, credentials, ...user }) => {
const screenName = user.screen_name
const body = {
status: status
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(ACTIVATION_STATUS_URL(screenName), {
method: 'PUT',
headers: headers,
body: JSON.stringify(body)
})
}
const deleteUser = ({ credentials, ...user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, {
method: 'DELETE',
headers: headers
})
}
const fetchTimeline = ({
timeline,
credentials,
since = false,
until = false,
userId = false,
tag = false,
withMuted = false
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
friends: MASTODON_USER_HOME_TIMELINE_URL,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
'publicAndExternal': MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL
}
const isNotifications = timeline === 'notifications'
const params = []
let url = timelineUrls[timeline]
if (timeline === 'user' || timeline === 'media') {
url = url(userId)
}
if (since) {
params.push(['since_id', since])
}
if (until) {
params.push(['max_id', until])
}
if (tag) {
url = url(tag)
}
if (timeline === 'media') {
params.push(['only_media', 1])
}
if (timeline === 'public') {
params.push(['local', true])
}
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
params.push(['count', 20])
params.push(['with_muted', withMuted])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then((data) => data.map(isNotifications ? parseNotification : parseStatus))
}
const fetchPinnedStatuses = ({ id, credentials }) => {
const url = MASTODON_USER_TIMELINE_URL(id) + '?pinned=true'
return promisedRequest({ url, credentials })
.then((data) => data.map(parseStatus))
}
const verifyCredentials = (user) => {
return fetch(MASTODON_LOGIN_URL, {
headers: authHeaders(user)
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response
}
}
})
.then((data) => data.error ? data : parseUser(data))
}
const favorite = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_FAVORITE_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const unfavorite = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNFAVORITE_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const retweet = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
const unretweet = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNRETWEET_URL(id), method: 'POST', credentials })
.then((data) => parseStatus(data))
}
-const hashString = (string) => {
- let hash = 0
- let char = ''
- if (string.length === 0) {
- return hash
- }
- for (let i = 0; i < string.length; i++) {
- char = string.charCodeAt(i)
- hash = ((hash << 5) - hash) + char
- hash |= 0
- }
- return hash
-}
-
-/*
- Generates idempotency key
- A user cannot post an identical status within the range in minutes.
- Range can be set to 'year', 'month', 'day' to block duplicate posts in those periods
-*/
-const createIdempotencyKey = (credentials, status, range = 2) => {
- const d = new Date()
- const year = d.getFullYear()
- const month = (d.getMonth() + 1)
- const day = d.getDate()
- const hour = d.getHours()
- const minute = d.getMinutes()
-
- const ranges = ['year', 'month', 'day']
-
- // Keep range within 60 minute bounds
- if (ranges.indexOf(range) === -1) {
- if (isNaN(range)) range = 2
- range = range % 60
- }
-
- // Hash for smaller size and obfuscation
- let idempotencyKey = ''
- switch (range) {
- case 'year':
- idempotencyKey = credentials + '&' + hashString(status) +
- '&' + year
- break
- case 'month':
- idempotencyKey = credentials + '&' + hashString(status) +
- '&' + year + '-' + month
- break
- case 'day':
- idempotencyKey = credentials + '&' + hashString(status) +
- '&' + year + '-' + month + '-' + day
- break
- default:
- let idempotencyTime = year + '-' + month + '-' + day + '-h' + hour
- if (range !== 0) idempotencyTime += ':' + Math.ceil(minute / range) * range
-
- idempotencyKey = credentials + '&' + hashString(status) + '&' + idempotencyTime
- }
-
- return idempotencyKey
-}
-
const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
- contentType
+ contentType,
+ idempotencyKey
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach(val => {
form.append('media_ids[]', val)
})
if (pollOptions.some(option => option !== '')) {
const normalizedPoll = {
expires_in: poll.expiresIn,
multiple: poll.multiple
}
Object.keys(normalizedPoll).forEach(key => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach(option => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
let postHeaders = authHeaders(credentials)
- postHeaders['idempotency-key'] = createIdempotencyKey(credentials, status)
+ if (idempotencyKey) {
+ postHeaders['idempotency-key'] = idempotencyKey
+ }
return fetch(MASTODON_POST_STATUS_URL, {
body: form,
method: 'POST',
headers: postHeaders
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response
}
}
})
.then((data) => data.error ? data : parseStatus(data))
}
const deleteStatus = ({ id, credentials }) => {
return fetch(MASTODON_DELETE_URL(id), {
headers: authHeaders(credentials),
method: 'DELETE'
})
}
const uploadMedia = ({ formData, credentials }) => {
return fetch(MASTODON_MEDIA_UPLOAD_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((data) => data.json())
.then((data) => parseAttachment(data))
}
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(BLOCKS_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(FOLLOW_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.ok)
}
const deleteAccount = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(DELETE_ACCOUNT_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return fetch(CHANGE_PASSWORD_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const settingsMFA = ({ credentials }) => {
return fetch(MFA_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(MFA_DISABLE_OTP_URL, {
body: form,
method: 'DELETE',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return fetch(MFA_CONFIRM_OTP_URL, {
body: form,
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const mfaSetupOTP = ({ credentials }) => {
return fetch(MFA_SETUP_OTP_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const generateMfaBackupCodes = ({ credentials }) => {
return fetch(MFA_BACKUP_CODES_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const fetchMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
.then((users) => users.map(parseUser))
}
const muteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' })
}
const unmuteUser = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
}
const fetchBlocks = ({ credentials }) => {
return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials })
.then((users) => users.map(parseUser))
}
const fetchOAuthTokens = ({ credentials }) => {
const url = '/api/oauth_tokens.json'
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => {
if (data.ok) {
return data.json()
}
throw new Error('Error fetching auth tokens', data)
})
}
const revokeOAuthToken = ({ id, credentials }) => {
const url = `/api/oauth_tokens/${id}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'DELETE'
})
}
const suggestions = ({ credentials }) => {
return fetch(SUGGESTIONS_URL, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const markNotificationsAsSeen = ({ id, credentials }) => {
const body = new FormData()
body.append('latest_id', id)
return fetch(QVITTER_USER_NOTIFICATIONS_READ_URL, {
body,
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const vote = ({ pollId, choices, credentials }) => {
const form = new FormData()
form.append('choices', choices)
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices: choices
}
})
}
const fetchPoll = ({ pollId, credentials }) => {
return promisedRequest(
{
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials
}
)
}
const fetchFavoritedByUsers = ({ id }) => {
return promisedRequest({ url: MASTODON_STATUS_FAVORITEDBY_URL(id) }).then((users) => users.map(parseUser))
}
const fetchRebloggedByUsers = ({ id }) => {
return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser))
}
const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
return promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
'account_id': userId,
'status_ids': statusIds,
comment,
forward
},
credentials
})
}
const apiService = {
verifyCredentials,
fetchTimeline,
fetchPinnedStatuses,
fetchConversation,
fetchStatus,
fetchFriends,
exportFriends,
fetchFollowers,
followUser,
unfollowUser,
pinOwnStatus,
unpinOwnStatus,
blockUser,
unblockUser,
fetchUser,
fetchUserRelationship,
favorite,
unfavorite,
retweet,
unretweet,
postStatus,
deleteStatus,
uploadMedia,
fetchMutes,
muteUser,
unmuteUser,
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
tagUser,
untagUser,
deleteUser,
addRight,
deleteRight,
setActivationStatus,
register,
getCaptcha,
updateAvatar,
updateBg,
updateProfile,
updateBanner,
externalProfile,
importBlocks,
importFollows,
deleteAccount,
changePassword,
settingsMFA,
mfaDisableOTP,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
fetchFollowRequests,
approveUser,
denyUser,
suggestions,
markNotificationsAsSeen,
vote,
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
reportUser,
updateNotificationSettings
}
export default apiService
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index 1efecf907a..e8a06e31d1 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -1,46 +1,48 @@
import { map } from 'lodash'
import apiService from '../api/api.service.js'
-const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
+const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, idempotencyKey = '', media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
const mediaIds = map(media, 'id')
return apiService.postStatus({
credentials: store.state.users.currentUser.credentials,
status,
spoilerText,
visibility,
sensitive,
mediaIds,
inReplyToStatusId,
contentType,
- poll})
+ poll,
+ idempotencyKey
+ })
.then((data) => {
if (!data.error) {
store.dispatch('addNewStatuses', {
statuses: [data],
timeline: 'friends',
showImmediately: true,
noIdUpdate: true // To prevent missing notices on next pull.
})
}
return data
})
.catch((err) => {
return {
error: err.message
}
})
}
const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData })
}
const statusPosterService = {
postStatus,
uploadMedia
}
export default statusPosterService
diff --git a/src/services/string_utils/string_utils.js b/src/services/string_utils/string_utils.js
new file mode 100644
index 0000000000..42dff9609d
--- /dev/null
+++ b/src/services/string_utils/string_utils.js
@@ -0,0 +1,30 @@
+export const hashString = (string) => {
+ let hash = 0
+ let char = ''
+ if (string.length === 0) {
+ return hash
+ }
+ for (let i = 0; i < string.length; i++) {
+ char = string.charCodeAt(i)
+ hash = ((hash << 5) - hash) + char
+ hash |= 0
+ }
+ return hash.toString()
+}
+
+export const randomString = (length) => {
+ var result = ''
+ var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ var charsLength = chars.length
+ for (var i = 0; i < length; i++) {
+ result += chars.charAt(Math.floor(Math.random() * charsLength))
+ }
+ return result
+}
+
+const stringUtils = {
+ hashString,
+ randomString
+}
+
+export default stringUtils
diff --git a/test/unit/specs/services/string_utils/string_utils.spec.js b/test/unit/specs/services/string_utils/string_utils.spec.js
new file mode 100644
index 0000000000..38aad395db
--- /dev/null
+++ b/test/unit/specs/services/string_utils/string_utils.spec.js
@@ -0,0 +1,50 @@
+import stringUtils from '../../../../../src/services/string_utils/string_utils.js'
+
+describe('hashString', () => {
+ it('hashed strings return the same results', () => {
+ // Test pre-determined
+ const test1 = 'thisisaTestString'
+ const expected1 = '316328252'
+ const res1 = stringUtils.hashString(test1)
+
+ const test2 = 'This is a long sentence, with many words and 123 numbers! check?A::LL'
+ const expected2 = '-1156337474'
+ const res2 = stringUtils.hashString(test2)
+
+ // Test that hashes match internally
+ const expected3 = stringUtils.hashString(test2)
+ const res3 = stringUtils.hashString(test2)
+ const expected4 = stringUtils.hashString(test1)
+ const res4 = stringUtils.hashString(test1)
+
+ // Test hashes of randomly generated strings of different lengths.
+ const random1 = stringUtils.randomString(50)
+ const random2 = stringUtils.randomString(150)
+ const expected5 = stringUtils.hashString(random1)
+ const res5 = stringUtils.hashString(random1)
+ const expected6 = stringUtils.hashString(random2)
+ const res6 = stringUtils.hashString(random2)
+
+ expect(res1).to.eql(expected1)
+ expect(res2).to.eql(expected2)
+ expect(res3).to.eql(expected3)
+ expect(res4).to.eql(expected4)
+ expect(res5).to.eql(expected5)
+ expect(res6).to.eql(expected6)
+ })
+})
+
+describe('randomString', () => {
+ it('randomly generated strings do not match', () => {
+ const randomTen = stringUtils.randomString(10)
+ const randomTen2 = stringUtils.randomString(10)
+ const randomTwenty = stringUtils.randomString(20)
+ const randomTwenty2 = stringUtils.randomString(20)
+ const randomFifty = stringUtils.randomString(50)
+ const randomFifty2 = stringUtils.randomString(50)
+
+ expect(randomTen).to.not.eql(randomTen2)
+ expect(randomTwenty).to.not.eql(randomTwenty2)
+ expect(randomFifty).to.not.eql(randomFifty2)
+ })
+})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 9, 10:26 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724812
Default Alt Text
(42 KB)

Event Timeline