Merge branch 'main' into account-featured_tags

This commit is contained in:
tobi 2024-12-04 13:37:30 +01:00 committed by GitHub
commit cf925b2039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
278 changed files with 10824 additions and 4223 deletions
.drone.yml
.github/ISSUE_TEMPLATE
.goreleaser.ymlCONTRIBUTING.mdDockerfileREADME.md
docs
admin
api
faq.md
getting_started/reverse_proxy
locales/zh
api
configuration
faq.md
getting_started/reverse_proxy
repo
user_guide
overrides/public
user_guide
go.modgo.sum
internal
ap
api
db
gtsmodel
processing
typeutils
validate
web
test

View file

@ -12,7 +12,7 @@ steps:
# We use golangci-lint for linting.
# See: https://golangci-lint.run/
- name: lint
image: golangci/golangci-lint:v1.57.2
image: golangci/golangci-lint:v1.62.0
volumes:
- name: go-build-cache
path: /root/.cache/go-build
@ -28,7 +28,7 @@ steps:
- pull_request
- name: test
image: golang:1.22-alpine
image: golang:1.23-alpine
volumes:
- name: go-build-cache
path: /root/.cache/go-build
@ -94,7 +94,7 @@ steps:
- pull_request
- name: snapshot
image: superseriousbusiness/gotosocial-drone-build:0.6.2 # https://github.com/superseriousbusiness/gotosocial-drone-build
image: superseriousbusiness/gotosocial-drone-build:0.7.0 # https://github.com/superseriousbusiness/gotosocial-drone-build
volumes:
- name: go-build-cache
path: /root/.cache/go-build
@ -141,7 +141,7 @@ steps:
- main
- name: release
image: superseriousbusiness/gotosocial-drone-build:0.6.2 # https://github.com/superseriousbusiness/gotosocial-drone-build
image: superseriousbusiness/gotosocial-drone-build:0.7.0 # https://github.com/superseriousbusiness/gotosocial-drone-build
volumes:
- name: go-build-cache
path: /root/.cache/go-build
@ -210,7 +210,7 @@ clone:
steps:
- name: mirror
image: superseriousbusiness/gotosocial-drone-build:0.6.2
image: superseriousbusiness/gotosocial-drone-build:0.7.0
environment:
ORIGIN_REPO: https://github.com/superseriousbusiness/gotosocial
TARGET_REPO: https://codeberg.org/superseriousbusiness/gotosocial
@ -223,6 +223,6 @@ steps:
---
kind: signature
hmac: 1b89e3a538fbca72eb9a0b398cd82f09a774ba3649013e19d36012eda327e83f
hmac: c79f1c3b16db8da7e3b01b960021a583ec81069aff8afd4425f049dd140f0620
...

View file

@ -1,6 +1,6 @@
name: Frontend Bug Report
description: Report an issue related to the web frontend
title: "[bug] Issue Title"
title: "[bug/frontend] Issue Title"
labels: ["bug", "frontend"]
assignees: []

View file

@ -1,12 +1,12 @@
# https://goreleaser.com
project_name: gotosocial
# Version 2 of GoReleaser: https://goreleaser.com/errors/version/
version: 2
project_name: gotosocial
# https://goreleaser.com/customization/hooks/
before:
hooks:
# generate the swagger.yaml file using go-swagger and bundle it into the assets directory
- swagger generate spec --scan-models --exclude-deps -o web/assets/swagger.yaml
- go run ./vendor/github.com/go-swagger/go-swagger/cmd/swagger generate spec --scan-models --exclude-deps -o web/assets/swagger.yaml
- sed -i "s/REPLACE_ME/{{ incpatch .Version }}/" web/assets/swagger.yaml
# Install web deps + bundle web assets
- yarn --cwd ./web/source install

View file

@ -24,7 +24,7 @@ These contribution guidelines were adapted from / inspired by those of Gitea (ht
- [Finding your way around the code](#finding-your-way-around-the-code)
- [Style / Linting / Formatting](#style--linting--formatting)
- [Testing](#testing)
- [Standalone Testrig with Semaphore](#standalone-testrig-with-semaphore)
- [Standalone Testrig with Pinafore](#standalone-testrig-with-pinafore)
- [Running automated tests](#running-automated-tests)
- [SQLite](#sqlite)
- [Postgres](#postgres)
@ -401,9 +401,9 @@ GoToSocial provides a [testrig](https://github.com/superseriousbusiness/gotosoci
One thing that *isn't* mocked is the Database interface because it's just easier to use an in-memory SQLite database than to mock everything out.
#### Standalone Testrig with Semaphore
#### Standalone Testrig with Pinafore
You can launch a testrig as a standalone server running at localhost, which you can connect to using something like [Semaphore](https://github.com/NickColley/semaphore/).
You can launch a testrig as a standalone server running at localhost, which you can connect to using something like [Pinafore](https://github.com/nolanlawson/pinafore/).
To do this, first build the gotosocial binary with `DEBUG=1 ./scripts/build.sh`.
@ -413,14 +413,14 @@ Then, launch the testrig with the `DEBUG` environment variable set by invoking t
DEBUG=1 ./gotosocial testrig start
```
To run Semaphore locally in dev mode, first clone the [Semaphore](https://github.com/NickColley/semaphore/) repository, and then run the following commands in the cloned directory:
To run Pinafore locally in dev mode, first clone the [Pinafore](https://github.com/nolanlawson/pinafore/) repository, and then run the following commands in the cloned directory:
```bash
yarn # install dependencies
yarn run dev
```
The Semaphore instance will start running on `localhost:4002`.
The Pinafore instance will start running on `localhost:4002`.
To connect to the testrig, navigate to `http://localhost:4002` and enter your instance name as `localhost:8080`.

View file

@ -2,7 +2,7 @@
# Dockerfile reference: https://docs.docker.com/engine/reference/builder/
# stage 1: generate up-to-date swagger.yaml to put in the final container
FROM --platform=${BUILDPLATFORM} golang:1.22-alpine AS swagger
FROM --platform=${BUILDPLATFORM} golang:1.23-alpine AS swagger
RUN \
### Installs goswagger for building swagger definitions inside this container
@ -28,7 +28,7 @@ RUN yarn --cwd ./web/source install && \
rm -rf ./web/source
# stage 3: build the executor container
FROM --platform=${TARGETPLATFORM} alpine:3.19.1 as executor
FROM --platform=${TARGETPLATFORM} alpine:3.20 as executor
# switch to non-root user:group for GtS
USER 1000:1000

View file

@ -113,7 +113,7 @@ The Mastodon API has become the de facto standard for client communication with
Though most apps that implement the Mastodon API should work, GoToSocial is tested and works reliably with beautiful apps like:
* [Tusky](https://tusky.app/) for Android
* [Semaphore](https://semaphore.social/) in the browser
* [Pinafore](https://pinafore.social/) in the browser
* [Feditext](https://github.com/feditext/feditext) (beta) on iOS, iPadOS and macOS
If you've used Mastodon with a third-party app before, you'll find using GoToSocial a breeze.

View file

@ -167,3 +167,11 @@ Links to the set contact account and/or email address will appear on the footer
The selected **contact user** must be an active (not suspended) admin and/or moderator on the instance.
If you're on a single-user instance and you give admin privileges to your main account, you can just fill in your own username here; you don't need to make a separate admin account just for this.
### Instance Custom CSS
custom CSS allows you to further customize the way your instance looks when visited through a browser.
This custom CSS will be applied to all pages of your instance. Users themes and CSS still take precedence over this customization.
See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS for your instance.

View file

@ -1570,6 +1570,10 @@ definitions:
$ref: '#/definitions/instanceV1Configuration'
contact_account:
$ref: '#/definitions/account'
custom_css:
description: Custom CSS for the instance.
type: string
x-go-name: CustomCSS
debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean
@ -1750,6 +1754,10 @@ definitions:
$ref: '#/definitions/instanceV2Configuration'
contact:
$ref: '#/definitions/instanceV2Contact'
custom_css:
description: Instance Custom Css
type: string
x-go-name: CustomCSS
debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean

View file

@ -2,7 +2,7 @@
## Where's the user interface?
GoToSocial is just a bare server for the most part and is designed to be used thru external applications. [Semaphore](https://semaphore.social/) in the browser, [Tusky](https://tusky.app/) for Android and [Feditext](https://github.com/feditext/feditext) for iOS, iPadOS and macOS are the best-supported. Anything that supports the Mastodon API should work, other than the features GoToSocial doesn't yet have. Permalinks and profile pages are served directly through GoToSocial as well as the settings panel, but most interaction goes through the apps.
GoToSocial is just a bare server for the most part and is designed to be used thru external applications. [Pinafore](https://pinafore.social/) in the browser, [Tusky](https://tusky.app/) for Android and [Feditext](https://github.com/feditext/feditext) for iOS, iPadOS and macOS are the best-supported. Anything that supports the Mastodon API should work, other than the features GoToSocial doesn't yet have. Permalinks and profile pages are served directly through GoToSocial as well as the settings panel, but most interaction goes through the apps.
## Why aren't my posts showing up on my profile page?

View file

@ -1,6 +1,6 @@
# WebSocket
GoToSocial uses the secure [WebSocket protocol](https://en.wikipedia.org/wiki/WebSocket) (aka `wss`) to allow for streaming updates of statuses and notifications via client apps like Semaphore.
GoToSocial uses the secure [WebSocket protocol](https://en.wikipedia.org/wiki/WebSocket) (aka `wss`) to allow for streaming updates of statuses and notifications via client apps like Pinafore.
In order to use this functionality, you need to ensure that whatever proxy you've configured GoToSocial to run behind allows WebSocket connections through.

View file

@ -4980,7 +4980,7 @@ paths:
- description: 此表情的代码,将被实例居民用于选定对应表情。此代码在实例上必须是唯一的。
in: formData
name: shortcode
pattern: \w{2,30}
pattern: \w{1,30}
required: true
type: string
- description: 此表情的 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。
@ -5130,7 +5130,7 @@ paths:
- description: 用于表情的代码,将被实例居民用于选定表情。此代码在实例上必须是唯一的。仅适用于 `copy` 操作类型。
in: formData
name: shortcode
pattern: \w{2,30}
pattern: \w{1,30}
type: string
- description: 此表情的新 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。仅适用于 **本站** 表情。
in: formData
@ -5639,6 +5639,417 @@ paths:
summary: 吊销实例密钥
tags:
- admin
/api/v1/admin/domain_permission_drafts:
get:
description: |-
该端点将返回按时间倒序排序(最新优先),并带有连续 ID 的域名权限草案ID 值越大,草稿越新)。可以通过返回的 Link 标头解析下一页与上一页查询。
示例:
```
<https://example.org/api/v1/admin/domain_permission_drafts?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_drafts?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: domainPermissionDraftsGet
parameters:
- description: 仅显示给定订阅 ID 创建的草案。
in: query
name: subscription_id
type: string
- description: 仅显示针对特定域名的草案。
in: query
name: domain
type: string
- description: 筛选“屏蔽”与“放行”类型的草案。
in: query
name: permission_type
type: string
- description: 仅返回早于给定 max ID 的条目(用于向下分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: max_id
type: string
- description: 仅返回晚于给定 since ID 的条目(用于向上分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: since_id
type: string
- description: 仅返回相邻且晚于给定 min ID 的条目(用于向上分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: min_id
type: string
- default: 20
description: 要返回的条目数量。
in: query
maximum: 100
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: 域名权限草案。
headers:
Link:
description: 下一查询与上一查询的链接。
type: string
schema:
items:
$ref: '#/definitions/domainPermission'
type: array
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止
"404":
description: not found 未找到
"406":
description: not acceptable 不可接受
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 查看域名权限草案。
tags:
- admin
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftCreate
parameters:
- description: 该草案要针对的域名。
in: formData
name: domain
type: string
- description: 草案类型为“放行”或“屏蔽”。
in: formData
name: permission_type
type: string
- description: 对外公开展示时混淆具体域名。例如:`example.org` 将变为类似 `ex***e.org` 的字符串。
in: formData
name: obfuscate
type: boolean
- description: 对此域名权限的公开评注。若您选择分享此权限设定,此评注将与权限条目一起显示。
in: formData
name: public_comment
type: string
- description: 对此域名权限的私人评注。仅显示给其他管理员,因此这是一个可用于记录为什么某个域名最终被添加此权限设定的有用的内部手段。
in: formData
name: private_comment
type: string
produces:
- application/json
responses:
"200":
description: 新创建的域名权限草案。
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"406":
description: not acceptable 不可接受
"409":
description: conflict 冲突
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 使用给定参数创建一条域名权限草案。
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}:
get:
operationId: domainPermissionDraftGet
parameters:
- description: 域名权限草案的 ID。
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: 域名权限草案。
schema:
$ref: '#/definitions/domainPermission'
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"404":
description: not found 未找到
"406":
description: not acceptable 不可接受
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 获取具有给定 ID 的域名权限草案。
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}/accept:
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftAccept
parameters:
- description: 域名权限草案的 ID。
in: path
name: id
required: true
type: string
- default: false
description: 若已经存在一条具有相同域名与权限设定类型的草案,使用新草案的字段覆盖现有权限设定。
in: formData
name: overwrite
type: boolean
produces:
- application/json
responses:
"200":
description: 新创建的域名权限草案。
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"406":
description: not acceptable 不可接受
"409":
description: conflict 冲突
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 接受一条域名权限草案,将其转换为会得到强制执行的域名权限。
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}/remove:
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftRemove
parameters:
- description: 域名权限草案的 ID。
in: path
name: id
required: true
type: string
- default: false
description: 删除此域名权限草案时,为目标域名创建一个域名排除条目,以确保之后不会为此域名创建草案。
in: formData
name: exclude_target
type: boolean
produces:
- application/json
responses:
"200":
description: 被移除的域名权限草案。
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"406":
description: not acceptable 不可接受
"409":
description: conflict 冲突
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 移除一条域名权限草案,可选择忽略所有之后的针对给定域名的草案。
tags:
- admin
/api/v1/admin/domain_permission_excludes:
get:
description: |-
返回按时间倒序排序(新创建的条目优先),并带有连续 ID 的域名权限排除条目ID 值越大,排除条目越新)。可以通过返回的 Link 标头解析下一页与上一页查询。
示例:
```
<https://example.org/api/v1/admin/domain_permission_excludes?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_excludes?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
```
operationId: domainPermissionExcludesGet
parameters:
- description: 仅返回针对给定域名的排除条目。
in: query
name: domain
type: string
- description: 仅返回比给定 max ID 新的条目(用于向下分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: max_id
type: string
- description: 仅返回比给定 since ID 新的条目(用于向上分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: since_id
type: string
- description: 仅返回比给定 min ID 相邻且更新的条目(用于向上分页)。具有对应 ID 的条目不会包含在响应中。
in: query
name: min_id
type: string
- default: 20
description: 要返回的条目数量。
in: query
maximum: 100
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: 域名权限排除条目。
headers:
Link:
description: 下一查询与上一查询的链接。
type: string
schema:
items:
$ref: '#/definitions/domainPermission'
type: array
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"404":
description: not found 未找到
"406":
description: not acceptable 不可接受
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 查看域名权限排除条目。
tags:
- admin
post:
consumes:
- multipart/form-data
- application/json
description: |-
被排除的域名(及其子域名)在导入或订阅域名权限列表时不会被自动屏蔽或放行。
您仍然可以为被排除的域名手动创建域名屏蔽条目或域名放行条目,被排除之后,与该域名关联的任何的已有或新创建的域名屏蔽条目或域名放行条目都将被继续执行。
operationId: domainPermissionExcludeCreate
parameters:
- description: 要创建权限排除的域名。
in: formData
name: domain
type: string
- description: 对该域名排除条目的私密评论。
in: formData
name: private_comment
type: string
produces:
- application/json
responses:
"200":
description: 新创建的域名排除条目。
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"406":
description: not acceptable 不可接受
"409":
description: conflict 冲突
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 使用给定参数创建一个域名权限排除条目。
tags:
- admin
/api/v1/admin/domain_permission_excludes/{id}:
delete:
operationId: domainPermissionExcludeDelete
parameters:
- description: 该域名权限排除条目的 ID。
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: 被移除的域名权限排除条目。
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request 无效请求
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"406":
description: not acceptable 不可接受
"409":
description: conflict 冲突
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 移除一个域名权限排除条目。
tags:
- admin
get:
operationId: domainPermissionExcludeGet
parameters:
- description: 域名权限排除条目的 ID。
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: 域名权限排除条目。
schema:
$ref: '#/definitions/domainPermission'
"401":
description: unauthorized 未授权
"403":
description: forbidden 禁止访问
"404":
description: not found 未找到
"406":
description: not acceptable 不可接受
"500":
description: internal server error 服务器内部错误
security:
- OAuth2 Bearer:
- admin
summary: 获取具有给定 ID 的域名权限排除。
tags:
- admin
/api/v1/admin/email/test:
post:
consumes:

View file

@ -1,5 +1,9 @@
# 存储
When configuring an object storage backend, the `storage-s3-endpoint` **must not** include the bucket name. That's what `s3-bucket-name` is for. Using subfolders in a bucket isn't currently supported.
配置对象存储后端时,`storage-s3-endpoint` **不得** 包含存储桶名称。`s3-bucket-name`负责配置存储桶名称。目前不支持使用特定存储桶的子目录作为存储后端。
## 设置
```yaml

View file

@ -2,7 +2,7 @@
## 用户界面在哪?
GoToSocial 的大部分内容只是一个裸服务端,用于通过第三方应用程序进行使用。可通过 [Semaphore](https://semaphore.social/) 在浏览器使用,可通过 [Tusky](https://tusky.app/) 在 Android 使用,可通过 [Feditext](https://github.com/feditext/feditext) 在 iOS、iPadOS 和 macOS 使用。这些应用程序兼容性最好。任何由 Mastodon API 提供的实例功能都应该可以工作,除非它们是 GoToSocial 尚不具备的功能。永久链接和个账户页是通过 GoToSocial 直接提供的,设置面板也是如此,但大多数交互都是通过应用程序完成的。
GoToSocial 的大部分内容只是一个裸服务端,用于通过第三方应用程序进行使用。可通过 [Pinafore](https://pinafore.social/) 在浏览器使用,可通过 [Tusky](https://tusky.app/) 在 Android 使用,可通过 [Feditext](https://github.com/feditext/feditext) 在 iOS、iPadOS 和 macOS 使用。这些应用程序兼容性最好。任何由 Mastodon API 提供的实例功能都应该可以工作,除非它们是 GoToSocial 尚不具备的功能。永久链接和个账户页是通过 GoToSocial 直接提供的,设置面板也是如此,但大多数交互都是通过应用程序完成的。
## 为什么我的贴文没有显示在我的账户页面上?

View file

@ -1,6 +1,6 @@
# WebSocket
GoToSocial 使用安全的 [WebSocket 协议](https://en.wikipedia.org/wiki/WebSocket)(即 `wss`)来通过客户端应用程序(如 Semaphore实现贴文和通知的实时更新。
GoToSocial 使用安全的 [WebSocket 协议](https://en.wikipedia.org/wiki/WebSocket)(即 `wss`)来通过客户端应用程序(如 Pinafore实现贴文和通知的实时更新。
为了使用此功能,你需要确保配置 GoToSocial 所在的代理允许 WebSocket 连接通过。

View file

@ -24,7 +24,7 @@
- [浏览代码结构](#浏览代码结构)
- [风格/代码检查/格式化](#风格代码检查格式化)
- [测试](#测试)
- [独立测试环境与 Semaphore](#独立测试环境与-semaphore)
- [独立测试环境与 Pinafore](#独立测试环境与-pinafore)
- [运行自动化测试](#运行自动化测试)
- [SQLite](#sqlite)
- [Postgres](#postgres)
@ -400,9 +400,9 @@ GoToSocial 提供了一个 [testrig](https://github.com/superseriousbusiness/got
没有模拟的一个东西是数据库接口,因为使用内存中的 SQLite 数据库比模拟所有东西要简单得多。
#### 独立测试环境与 Semaphore
#### 独立测试环境与 Pinafore
你可以启动一个在本地主机运行的独立测试服务器 testrig可以通过 [Semaphore](https://github.com/NickColley/semaphore/) 连接。
你可以启动一个在本地主机运行的独立测试服务器 testrig可以通过 [Pinafore](https://github.com/NickColley/pinafore/) 连接。
要做到这一点,首先用 `DEBUG=1 ./scripts/build.sh` 构建 gotosocial 二进制文件。
@ -412,14 +412,14 @@ GoToSocial 提供了一个 [testrig](https://github.com/superseriousbusiness/got
DEBUG=1 ./gotosocial testrig start
```
要在本地开发模式下运行 Semaphore首先克隆 [Semaphore](https://github.com/NickColley/semaphore/) 存储库,然后在克隆的目录中运行以下命令:
要在本地开发模式下运行 Pinafore首先克隆 [Pinafore](https://github.com/nolanlawson/pinafore/) 存储库,然后在克隆的目录中运行以下命令:
```bash
yarn # 安装依赖
yarn run dev
```
Semaphore 实例将在 `localhost:4002` 上启动。
Pinafore 实例将在 `localhost:4002` 上启动。
要连接到 testrig导航至 `http://localhost:4002`,并将在实例域名栏输入 `localhost:8080`

View file

@ -113,7 +113,7 @@ Mastodon API 已成为客户端与联邦宇宙服务端通信的事实标准,
大多数实现 Mastodon API 的应用程序都应该可以使用 GoToSocial但以下这些优秀的应用程序已经过测试可与 GoToSocial 可靠地配合使用:
* [Tusky](https://tusky.app/) 适用于 Android
* [Semaphore](https://semaphore.social/) 适用于浏览器
* [Pinafore](https://pinafore.social/) 适用于浏览器
* [Feditext](https://github.com/feditext/feditext) (beta) 适用于 iOS, iPadOS 和 macOS
如果你之前通过第三方应用来使用 Mastodon使用 GoToSocial 将是轻而易举的。

View file

@ -36,6 +36,9 @@ GoToSocial 为贴文提供 Mastodon 风格的隐私设置。从最私密到最
### 互关可见
!!! warning
目前暂时无法将帖文可见性设为“互关可见”。
`互关可见` 的贴文只会显示给贴文作者和与作者*互相关注*的人。换句话说,只有在满足两个条件时,其他人才能看到:
1. 其他账户关注贴文作者。

Binary file not shown.

Before

(image error) Size: 200 KiB

After

(image error) Size: 229 KiB

View file

@ -36,6 +36,9 @@ Direct posts are **not** accessible via a web URL on your GoToSocial instance.
### Mutuals-only
!!! warning
Mutuals-only posts are not currently functional.
Posts with a visibility of `mutuals_only` will only appear to the post author, and to *mutual follows* of the post author. In other words, they can only be seen by others if two conditions are met:
1. The other account follows the post author.

36
go.mod
View file

@ -1,9 +1,12 @@
module github.com/superseriousbusiness/gotosocial
go 1.22.2
go 1.23
// Replace go-swagger with our version that fixes (ours particularly) use of Go1.23
replace github.com/go-swagger/go-swagger => github.com/superseriousbusiness/go-swagger v0.31.0-gts-go1.23-fix
// Replace modernc/sqlite with our version that fixes the concurrency INTERRUPT issue
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.34.2-concurrency-workaround
// Below pin otel libraries to v1.29.0 until we can figure out issues
replace go.opentelemetry.io/otel => go.opentelemetry.io/otel v1.29.0
@ -28,7 +31,7 @@ require (
codeberg.org/gruf/go-debug v1.3.0
codeberg.org/gruf/go-errors/v2 v2.3.2
codeberg.org/gruf/go-fastcopy v1.1.3
codeberg.org/gruf/go-ffmpreg v0.6.0
codeberg.org/gruf/go-ffmpreg v0.6.1
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf
codeberg.org/gruf/go-kv v1.6.5
codeberg.org/gruf/go-list v0.0.0-20240425093752-494db03d641f
@ -38,7 +41,7 @@ require (
codeberg.org/gruf/go-sched v1.2.4
codeberg.org/gruf/go-storage v0.2.0
codeberg.org/gruf/go-structr v0.8.11
codeberg.org/superseriousbusiness/exif-terminator v0.9.0
codeberg.org/superseriousbusiness/exif-terminator v0.9.1
github.com/DmitriyVTitov/size v1.5.0
github.com/KimMachineGun/automemlimit v0.6.1
github.com/buckket/go-blurhash v1.1.0
@ -52,31 +55,31 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0
github.com/gorilla/websocket v1.5.2
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/k3a/html2text v1.2.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/miekg/dns v1.1.62
github.com/minio/minio-go/v7 v7.0.80
github.com/minio/minio-go/v7 v7.0.81
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.20.2
github.com/ncruces/go-sqlite3 v0.20.3
github.com/oklog/ulid v1.3.1
github.com/prometheus/client_golang v1.20.5
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
github.com/superseriousbusiness/activity v1.9.0-gts
github.com/superseriousbusiness/httpsig v1.2.0-SSB
github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8
github.com/tdewolff/minify/v2 v2.21.1
github.com/tdewolff/minify/v2 v2.21.2
github.com/technologize/otel-go-contrib v1.1.1
github.com/tetratelabs/wazero v1.8.1
github.com/tetratelabs/wazero v1.8.2
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
github.com/ulule/limiter/v3 v3.11.2
github.com/uptrace/bun v1.2.5
github.com/uptrace/bun/dialect/pgdialect v1.2.5
github.com/uptrace/bun/dialect/sqlitedialect v1.2.5
github.com/uptrace/bun/extra/bunotel v1.2.5
github.com/uptrace/bun v1.2.6
github.com/uptrace/bun/dialect/pgdialect v1.2.6
github.com/uptrace/bun/dialect/sqlitedialect v1.2.6
github.com/uptrace/bun/extra/bunotel v1.2.6
github.com/wagslane/go-password-validator v0.3.0
github.com/yuin/goldmark v1.7.8
go.opentelemetry.io/otel v1.32.0
@ -108,7 +111,9 @@ require (
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@ -213,7 +218,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/superseriousbusiness/go-jpeg-image-structure/v2 v2.0.0-20220321154430-d89a106fdabe // indirect
github.com/superseriousbusiness/go-png-image-structure/v2 v2.0.1-SSB // indirect
github.com/tdewolff/parse/v2 v2.7.18 // indirect
github.com/tdewolff/parse/v2 v2.7.19 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@ -221,6 +226,7 @@ require (
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect

65
go.sum generated
View file

@ -46,8 +46,8 @@ codeberg.org/gruf/go-fastcopy v1.1.3 h1:Jo9VTQjI6KYimlw25PPc7YLA3Xm+XMQhaHwKnM7x
codeberg.org/gruf/go-fastcopy v1.1.3/go.mod h1:GDDYR0Cnb3U/AIfGM3983V/L+GN+vuwVMvrmVABo21s=
codeberg.org/gruf/go-fastpath/v2 v2.0.0 h1:iAS9GZahFhyWEH0KLhFEJR+txx1ZhMXxYzu2q5Qo9c0=
codeberg.org/gruf/go-fastpath/v2 v2.0.0/go.mod h1:3pPqu5nZjpbRrOqvLyAK7puS1OfEtQvjd6342Cwz56Q=
codeberg.org/gruf/go-ffmpreg v0.6.0 h1:/cfUJ9bFKEoXT9LDYZy3eZ0HF60YWcO+0nGciepJKMw=
codeberg.org/gruf/go-ffmpreg v0.6.0/go.mod h1:Ar5nbt3tB2Wr0uoaqV3wDBNwAx+H+AB/mV7Kw7NlZTI=
codeberg.org/gruf/go-ffmpreg v0.6.1 h1:HitxOKPhbwARV469h6jY9a3IlROiwAN6QTbTxLRBnC8=
codeberg.org/gruf/go-ffmpreg v0.6.1/go.mod h1:HQmEaBF83rHOt2Jo1yJv9D0JApoSLFtVR9Uzu7aVglk=
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf h1:84s/ii8N6lYlskZjHH+DG6jyia8w2mXMZlRwFn8Gs3A=
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf/go.mod h1:zZAICsp5rY7+hxnws2V0ePrWxE0Z2Z/KXcN3p/RQCfk=
codeberg.org/gruf/go-kv v1.6.5 h1:ttPf0NA8F79pDqBttSudPTVCZmGncumeNIxmeM9ztz0=
@ -72,8 +72,8 @@ codeberg.org/gruf/go-storage v0.2.0 h1:mKj3Lx6AavEkuXXtxqPhdq+akW9YwrnP16yQBF7K5
codeberg.org/gruf/go-storage v0.2.0/go.mod h1:o3GzMDE5QNUaRnm/daUzFqvuAaC4utlgXDXYO79sWKU=
codeberg.org/gruf/go-structr v0.8.11 h1:I3cQCHpK3fQSXWaaUfksAJRN4+efULiuF11Oi/m8c+o=
codeberg.org/gruf/go-structr v0.8.11/go.mod h1:zkoXVrAnKosh8VFAsbP/Hhs8FmLBjbVVy5w/Ngm8ApM=
codeberg.org/superseriousbusiness/exif-terminator v0.9.0 h1:/EfyGI6HIrbkhFwgXGSjZ9o1kr/+k8v4mKdfXTH02Go=
codeberg.org/superseriousbusiness/exif-terminator v0.9.0/go.mod h1:gCWKduudUWFzsnixoMzu0FYVdxHWG+AbXnZ50DqxsUE=
codeberg.org/superseriousbusiness/exif-terminator v0.9.1 h1:8Pss29AVuvljHAYLnZUyoqJp/8IN1cD3Jz30bJbxme8=
codeberg.org/superseriousbusiness/exif-terminator v0.9.1/go.mod h1:gCWKduudUWFzsnixoMzu0FYVdxHWG+AbXnZ50DqxsUE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@ -97,10 +97,14 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@ -238,8 +242,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/go-swagger/go-swagger v0.31.0 h1:H8eOYQnY2u7vNKWDNykv2xJP3pBhRG/R+SOCAmKrLlc=
github.com/go-swagger/go-swagger v0.31.0/go.mod h1:WSigRRWEig8zV6t6Sm8Y+EmUjlzA/HoaZJ5edupq7po=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b h1:khEcpUM4yFcxg4/FHQWkvVRmgijNXRfzkIDHh23ggEo=
@ -330,8 +332,8 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.2 h1:qoW6V1GT3aZxybsbC6oLnailWnB+qTMVwMreOso9XUw=
github.com/gorilla/websocket v1.5.2/go.mod h1:0n9H61RBAcf5/38py2MCYbxzPIY9rOkpvvMT24Rqs30=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -411,8 +413,8 @@ github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/minio/minio-go/v7 v7.0.81 h1:SzhMN0TQ6T/xSBu6Nvw3M5M8voM+Ht8RH3hE8S7zxaA=
github.com/minio/minio-go/v7 v7.0.81/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
@ -432,8 +434,8 @@ github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-sqlite3 v0.20.2 h1:cMLIwrLZQuCWVCEOowSqlIlpzgbag3jnYVW4NM5u01M=
github.com/ncruces/go-sqlite3 v0.20.2/go.mod h1:yL4ZNWGsr1/8pcLfpPW1RT1WFdvyeHonrgIwwi4rvkg=
github.com/ncruces/go-sqlite3 v0.20.3 h1:+4G4uEqOeusF0yRuQVUl9fuoEebUolwQSnBUjYBLYIw=
github.com/ncruces/go-sqlite3 v0.20.3/go.mod h1:ojLIAB243gtz68Eo283Ps+k9PyR3dvzS+9/RgId4+AA=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@ -525,8 +527,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/superseriousbusiness/activity v1.9.0-gts h1:qWMDeiGdnVi+XG7CfuM7ET87qe9adousU6utWItBX/o=
@ -535,21 +538,23 @@ github.com/superseriousbusiness/go-jpeg-image-structure/v2 v2.0.0-20220321154430
github.com/superseriousbusiness/go-jpeg-image-structure/v2 v2.0.0-20220321154430-d89a106fdabe/go.mod h1:gH4P6gN1V+wmIw5o97KGaa1RgXB/tVpC2UNzijhg3E4=
github.com/superseriousbusiness/go-png-image-structure/v2 v2.0.1-SSB h1:8psprYSK1KdOSH7yQ4PbJq0YYaGQY+gzdW/B0ExDb/8=
github.com/superseriousbusiness/go-png-image-structure/v2 v2.0.1-SSB/go.mod h1:ymKGfy9kg4dIdraeZRAdobMS/flzLk3VcRPLpEWOAXg=
github.com/superseriousbusiness/go-swagger v0.31.0-gts-go1.23-fix h1:CXcjArOyxBPFgsNAu4As+RK9BwOUEG1LL7ja4g7iax0=
github.com/superseriousbusiness/go-swagger v0.31.0-gts-go1.23-fix/go.mod h1:WSigRRWEig8zV6t6Sm8Y+EmUjlzA/HoaZJ5edupq7po=
github.com/superseriousbusiness/httpsig v1.2.0-SSB h1:BinBGKbf2LSuVT5+MuH0XynHN9f0XVshx2CTDtkaWj0=
github.com/superseriousbusiness/httpsig v1.2.0-SSB/go.mod h1:+rxfATjFaDoDIVaJOTSP0gj6UrbicaYPEptvCLC9F28=
github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8 h1:nTIhuP157oOFcscuoK1kCme1xTeGIzztSw70lX9NrDQ=
github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8/go.mod h1:uYC/W92oVRJ49Vh1GcvTqpeFqHi+Ovrl2sMllQWRAEo=
github.com/tdewolff/minify/v2 v2.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k=
github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg=
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/technologize/otel-go-contrib v1.1.1 h1:wZH9aSPNWZWIkEh3vfaKfMb15AJ80jJ1aVj/4GZdqIw=
github.com/technologize/otel-go-contrib v1.1.1/go.mod h1:dCN/wj2WyUO8aFZFdIN+6tfJHImjTML/8r2YVYAy3So=
github.com/tetratelabs/wazero v1.8.1 h1:NrcgVbWfkWvVc4UtT4LRLDf91PsOzDzefMdwhLfA550=
github.com/tetratelabs/wazero v1.8.1/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
@ -580,14 +585,14 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
github.com/uptrace/bun v1.2.5 h1:gSprL5xiBCp+tzcZHgENzJpXnmQwRM/A6s4HnBF85mc=
github.com/uptrace/bun v1.2.5/go.mod h1:vkQMS4NNs4VNZv92y53uBSHXRqYyJp4bGhMHgaNCQpY=
github.com/uptrace/bun/dialect/pgdialect v1.2.5 h1:dWLUxpjTdglzfBks2x+U2WIi+nRVjuh7Z3DLYVFswJk=
github.com/uptrace/bun/dialect/pgdialect v1.2.5/go.mod h1:stwnlE8/6x8cuQ2aXcZqwDK/d+6jxgO3iQewflJT6C4=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.5 h1:liDvMaIWrN8DrHcxVbviOde/VDss9uhcqpcTSL3eJjc=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.5/go.mod h1:Mw6IDL/jNUL5ozcREAezOJSZ9Jm4LJlfoaXxBEfNBlM=
github.com/uptrace/bun/extra/bunotel v1.2.5 h1:kkuuTbrG9d5leYZuSBKhq2gtq346lIrxf98Mig2y128=
github.com/uptrace/bun/extra/bunotel v1.2.5/go.mod h1:rCHLszRZwppWE9cGDodO2FCI1qCrLwDjONp38KD3bA8=
github.com/uptrace/bun v1.2.6 h1:lyGBQAhNiClchb97HA2cBnDeRxwTRLhSIgiFPXVisV8=
github.com/uptrace/bun v1.2.6/go.mod h1:xMgnVFf+/5xsrFBU34HjDJmzZnXbVuNEt/Ih56I8qBU=
github.com/uptrace/bun/dialect/pgdialect v1.2.6 h1:iNd1YLx619K+sZK+dRcWPzluurXYK1QwIkp9FEfNB/8=
github.com/uptrace/bun/dialect/pgdialect v1.2.6/go.mod h1:OL7d3qZLxKYP8kxNhMg3IheN1pDR3UScGjoUP+ivxJQ=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.6 h1:p8vA39kR9Ypw0so+gUhFhd8NOufx3MzvoxJeUpwieQU=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.6/go.mod h1:sdGy8eCv9WVGDrPhagE9i7FASeyj3BFkHzkRMF/qK3w=
github.com/uptrace/bun/extra/bunotel v1.2.6 h1:6m90acv9hsDuTYRo3oiKCWMatGPmi+feKAx8Y/GPj9A=
github.com/uptrace/bun/extra/bunotel v1.2.6/go.mod h1:QGqnFNJ2H88juh7DmgdPJZVN9bSTpj7UaGllSO9JDKk=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@ -602,6 +607,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41 h1:rnB8ZLMeAr3VcqjfRkAm27qb8y6zFKNfuHvy1Gfe7KI=
github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240816141633-0a40785b4f41/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
@ -623,8 +630,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround h1:pFMJnlc1PuH+jcVz4vz53vcpnoZG+NqFBr3qikDmEB4=
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.34.2-concurrency-workaround h1:Z/9vgdPNZm8ZDANnIJ7ZGeYKJ5biqPY1OQbN+DLCtec=
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.34.2-concurrency-workaround/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=

View file

@ -1027,7 +1027,7 @@ func ExtractVisibility(addressable Addressable, actorFollowersURI string) (gtsmo
)
if len(to) == 0 && len(cc) == 0 {
return "", gtserror.Newf("message wasn't TO or CC anyone")
return 0, gtserror.Newf("message wasn't TO or CC anyone")
}
// Assume most restrictive visibility,

View file

@ -28,7 +28,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/accounts"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
@ -99,7 +98,7 @@ func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() {
suite.Equal(2, apimodelAccount.FollowersCount)
suite.Equal(2, apimodelAccount.FollowingCount)
suite.Equal(8, apimodelAccount.StatusesCount)
suite.EqualValues(gtsmodel.VisibilityPublic, apimodelAccount.Source.Privacy)
suite.EqualValues(apimodel.VisibilityPublic, apimodelAccount.Source.Privacy)
suite.Equal(testAccount.Settings.Language, apimodelAccount.Source.Language)
suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note)
}

View file

@ -147,16 +147,12 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) {
return
}
permType := c.Query(apiutil.DomainPermissionPermTypeKey)
switch permType {
case "", "block", "allow":
// No problem.
default:
// Invalid.
permTypeStr := c.Query(apiutil.DomainPermissionPermTypeKey)
permType := gtsmodel.ParseDomainPermissionType(permTypeStr)
if permType == gtsmodel.DomainPermissionUnknown {
text := fmt.Sprintf(
"permission_type %s not recognized, valid values are empty string, block, or allow",
permType,
permTypeStr,
)
errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
@ -173,7 +169,7 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) {
c.Request.Context(),
c.Query(apiutil.DomainPermissionSubscriptionIDKey),
c.Query(apiutil.DomainPermissionDomainKey),
gtsmodel.NewDomainPermissionType(permType),
permType,
page,
)
if errWithCode != nil {

View file

@ -41,6 +41,7 @@ func (suite *FiltersTestSuite) postFilter(
irreversible *bool,
wholeWord *bool,
expiresIn *int,
expiresInStr *string,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
@ -75,6 +76,8 @@ func (suite *FiltersTestSuite) postFilter(
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
} else if expiresInStr != nil {
ctx.Request.Form["expires_in"] = []string{*expiresInStr}
}
}
@ -124,7 +127,7 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
irreversible := false
wholeWord := true
expiresIn := 86400
filter, err := suite.postFilter(&phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, http.StatusOK, "")
filter, err := suite.postFilter(&phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -155,7 +158,7 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
"whole_word": true,
"expires_in": 86400.1
}`
filter, err := suite.postFilter(nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -182,7 +185,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
phrase := "GNU/Linux"
context := []string{"home"}
filter, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusOK, "")
filter, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -203,7 +206,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
func (suite *FiltersTestSuite) TestPostFilterEmptyPhrase() {
phrase := ""
context := []string{"home"}
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -211,7 +214,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyPhrase() {
func (suite *FiltersTestSuite) TestPostFilterMissingPhrase() {
context := []string{"home"}
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -220,7 +223,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingPhrase() {
func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
phrase := "GNU/Linux"
context := []string{}
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&phrase, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -228,7 +231,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
phrase := "GNU/Linux"
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -237,8 +240,37 @@ func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
// There should be a filter with this phrase as its title in our test fixtures. Creating another should fail.
func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
phrase := "fnord"
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&phrase, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}
// postFilterWithExpiration creates a filter with optional expiration.
func (suite *FiltersTestSuite) postFilterWithExpiration(phrase *string, expiresIn *int, expiresInStr *string, requestJson *string) *apimodel.FilterV1 {
context := []string{"home"}
filter, err := suite.postFilter(phrase, &context, nil, nil, expiresIn, expiresInStr, requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
return filter
}
// Regression test for https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPostFilterWithEmptyStringExpiration() {
title := "Form Sins"
expiresInStr := ""
filter := suite.postFilterWithExpiration(&title, nil, &expiresInStr, nil)
suite.Nil(filter.ExpiresAt)
}
// Regression test related to https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPostFilterWithNullExpirationJSON() {
requestJson := `{
"phrase": "JSON Sins",
"context": ["home"],
"expires_in": null
}`
filter := suite.postFilterWithExpiration(nil, nil, nil, &requestJson)
suite.Nil(filter.ExpiresAt)
}

View file

@ -42,6 +42,7 @@ func (suite *FiltersTestSuite) putFilter(
irreversible *bool,
wholeWord *bool,
expiresIn *int,
expiresInStr *string,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
@ -76,6 +77,8 @@ func (suite *FiltersTestSuite) putFilter(
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
} else if expiresInStr != nil {
ctx.Request.Form["expires_in"] = []string{*expiresInStr}
}
}
@ -128,7 +131,7 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
irreversible := false
wholeWord := true
expiresIn := 86400
filter, err := suite.putFilter(id, &phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, http.StatusOK, "")
filter, err := suite.putFilter(id, &phrase, &context, &irreversible, &wholeWord, &expiresIn, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -160,7 +163,7 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
"whole_word": true,
"expires_in": 86400.1
}`
filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -188,7 +191,7 @@ func (suite *FiltersTestSuite) TestPutFilterMinimal() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
phrase := "GNU/Linux"
context := []string{"home"}
filter, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusOK, "")
filter, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -210,7 +213,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyPhrase() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
phrase := ""
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -219,7 +222,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyPhrase() {
func (suite *FiltersTestSuite) TestPutFilterMissingPhrase() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
context := []string{"home"}
_, err := suite.putFilter(id, nil, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.putFilter(id, nil, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -229,7 +232,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
phrase := "GNU/Linux"
context := []string{}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -238,7 +241,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
func (suite *FiltersTestSuite) TestPutFilterMissingContext() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
phrase := "GNU/Linux"
_, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -248,7 +251,7 @@ func (suite *FiltersTestSuite) TestPutFilterMissingContext() {
func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
id := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"].ID
phrase := "metasyntactic variables"
_, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.putFilter(id, &phrase, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -258,7 +261,7 @@ func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
id := suite.testFilterKeywords["local_account_2_filter_1_keyword_1"].ID
phrase := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
@ -268,8 +271,60 @@ func (suite *FiltersTestSuite) TestPutNonexistentFilter() {
id := "not_even_a_real_ULID"
phrase := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
// setFilterExpiration sets filter expiration.
func (suite *FiltersTestSuite) setFilterExpiration(id string, phrase *string, expiresIn *int, expiresInStr *string, requestJson *string) *apimodel.FilterV1 {
context := []string{"home"}
filter, err := suite.putFilter(id, phrase, &context, nil, nil, expiresIn, expiresInStr, requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
return filter
}
// Regression test for https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPutFilterUnsetExpirationDateEmptyString() {
filterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
id := filterKeyword.ID
phrase := filterKeyword.Keyword
// Setup: set an expiration date for the filter.
expiresIn := 86400
filter := suite.setFilterExpiration(id, &phrase, &expiresIn, nil, nil)
if !suite.NotNil(filter.ExpiresAt) {
suite.FailNow("Test precondition failed")
}
// Unset the filter's expiration date by setting it to an empty string.
expiresInStr := ""
filter = suite.setFilterExpiration(id, &phrase, nil, &expiresInStr, nil)
suite.Nil(filter.ExpiresAt)
}
// Regression test related to https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPutFilterUnsetExpirationDateNullJSON() {
filterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
id := filterKeyword.ID
phrase := filterKeyword.Keyword
// Setup: set an expiration date for the filter.
expiresIn := 86400
filter := suite.setFilterExpiration(id, &phrase, &expiresIn, nil, nil)
if !suite.NotNil(filter.ExpiresAt) {
suite.FailNow("Test precondition failed")
}
// Unset the filter's expiration date by setting it to a null literal.
requestJson := `{
"phrase": "fnord",
"context": ["home"],
"expires_in": null
}`
filter = suite.setFilterExpiration(id, nil, nil, nil, &requestJson)
suite.Nil(filter.ExpiresAt)
}

View file

@ -46,12 +46,11 @@ func validateNormalizeCreateUpdateFilter(form *apimodel.FilterCreateUpdateReques
return errors.New("irreversible aka server-side drop filters are not supported yet")
}
// Normalize filter expiry if necessary.
if form.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
// If `expires_in` was provided
// as JSON, then normalize it.
if form.ExpiresInI.IsSpecified() {
var err error
form.ExpiresIn, err = apiutil.ParseDuration(
form.ExpiresIn, err = apiutil.ParseNullableDuration(
form.ExpiresInI,
"expires_in",
)
@ -60,10 +59,5 @@ func validateNormalizeCreateUpdateFilter(form *apimodel.FilterCreateUpdateReques
}
}
// Interpret zero as indefinite duration.
if form.ExpiresIn != nil && *form.ExpiresIn == 0 {
form.ExpiresIn = nil
}
return nil
}

View file

@ -225,12 +225,11 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
// Apply defaults for missing fields.
form.FilterAction = util.Ptr(action)
// Normalize filter expiry if necessary.
if form.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
// If `expires_in` was provided
// as JSON, then normalize it.
if form.ExpiresInI.IsSpecified() {
var err error
form.ExpiresIn, err = apiutil.ParseDuration(
form.ExpiresIn, err = apiutil.ParseNullableDuration(
form.ExpiresInI,
"expires_in",
)
@ -239,11 +238,6 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
}
}
// Interpret zero as indefinite duration.
if form.ExpiresIn != nil && *form.ExpiresIn == 0 {
form.ExpiresIn = nil
}
// Normalize and validate new keywords and statuses.
for i, formKeyword := range form.Keywords {
if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {

View file

@ -36,7 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, expiresInStr *string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, requestJson *string, expectedHTTPStatus int, expectedBody string, keywordsAttributesKeyword *[]string) (*apimodel.FilterV2, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
@ -64,6 +64,8 @@ func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, acti
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
} else if expiresInStr != nil {
ctx.Request.Form["expires_in"] = []string{*expiresInStr}
}
if keywordsAttributesKeyword != nil {
ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword
@ -130,7 +132,7 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
keywordsAttributesWholeWord := []bool{true, false}
// Checked in lexical order by status ID, so keep this sorted.
statusAttributesStatusID := []string{"01HEN2QRFA8H3C6QPN7RD4KSR6", "01HEWV37MHV8BAC8ANFGVRRM5D"}
filter, err := suite.postFilter(&title, &context, &action, &expiresIn, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &statusAttributesStatusID, nil, http.StatusOK, "")
filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, &keywordsAttributesWholeWord, &statusAttributesStatusID, nil, http.StatusOK, "", &keywordsAttributesKeyword)
if err != nil {
suite.FailNow(err.Error())
}
@ -197,7 +199,7 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
}
]
}`
filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -245,7 +247,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
filter, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -267,7 +269,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
title := ""
context := []string{"home"}
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -275,7 +277,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
context := []string{"home"}
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -284,7 +286,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
title := "GNU/Linux"
context := []string{}
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -292,7 +294,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
title := "GNU/Linux"
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -301,8 +303,37 @@ func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
// Creating another filter with the same title should fail.
func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
title := suite.testFilters["local_account_1_filter_1"].Title
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
}
// postFilterWithExpiration creates a filter with optional expiration.
func (suite *FiltersTestSuite) postFilterWithExpiration(title *string, expiresIn *int, expiresInStr *string, requestJson *string) *apimodel.FilterV2 {
context := []string{"home"}
filter, err := suite.postFilter(title, &context, nil, expiresIn, expiresInStr, nil, nil, requestJson, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
return filter
}
// Regression test for https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPostFilterWithEmptyStringExpiration() {
title := "Form Crimes"
expiresInStr := ""
filter := suite.postFilterWithExpiration(&title, nil, &expiresInStr, nil)
suite.Nil(filter.ExpiresAt)
}
// Regression test related to https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPostFilterWithNullExpirationJSON() {
requestJson := `{
"title": "JSON Crimes",
"context": ["home"],
"expires_in": null
}`
filter := suite.postFilterWithExpiration(nil, nil, nil, &requestJson)
suite.Nil(filter.ExpiresAt)
}

View file

@ -269,12 +269,11 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
// Normalize filter expiry if necessary.
if form.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
// If `expires_in` was provided
// as JSON, then normalize it.
if form.ExpiresInI.IsSpecified() {
var err error
form.ExpiresIn, err = apiutil.ParseDuration(
form.ExpiresIn, err = apiutil.ParseNullableDuration(
form.ExpiresInI,
"expires_in",
)
@ -283,11 +282,6 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
// Interpret zero as indefinite duration.
if form.ExpiresIn != nil && *form.ExpiresIn == 0 {
form.ExpiresIn = nil
}
// Normalize and validate updates.
for i, formKeyword := range form.Keywords {
if formKeyword.Keyword != nil {

View file

@ -36,7 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesID *[]string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, expiresInStr *string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, requestJson *string, expectedHTTPStatus int, expectedBody string, keywordsAttributesID *[]string) (*apimodel.FilterV2, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
@ -64,6 +64,8 @@ func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context
}
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
} else if expiresInStr != nil {
ctx.Request.Form["expires_in"] = []string{*expiresInStr}
}
if keywordsAttributesID != nil {
ctx.Request.Form["keywords_attributes[][id]"] = *keywordsAttributesID
@ -159,7 +161,7 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
keywordsAttributesWholeWord := []bool{true, false, true}
keywordsAttributesDestroy := []bool{false, true}
statusesAttributesStatusID := []string{suite.testStatuses["remote_account_1_status_2"].ID}
filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, &keywordsAttributesID, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, nil, nil, http.StatusOK, "")
filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, nil, nil, http.StatusOK, "", &keywordsAttributesID)
if err != nil {
suite.FailNow(err.Error())
}
@ -231,7 +233,7 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
}
]
}`
filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -281,7 +283,7 @@ func (suite *FiltersTestSuite) TestPutFilterMinimal() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -302,7 +304,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyTitle() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := ""
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`, nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -312,7 +314,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`, nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -322,7 +324,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := suite.testFilters["local_account_1_filter_2"].Title
_, err := suite.putFilter(id, &title, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
_, err := suite.putFilter(id, &title, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`, nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -332,7 +334,7 @@ func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
id := suite.testFilters["local_account_2_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`, nil)
if err != nil {
suite.FailNow(err.Error())
}
@ -342,8 +344,70 @@ func (suite *FiltersTestSuite) TestPutNonexistentFilter() {
id := "not_even_a_real_ULID"
phrase := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`, nil)
if err != nil {
suite.FailNow(err.Error())
}
}
// setFilterExpiration sets filter expiration.
func (suite *FiltersTestSuite) setFilterExpiration(id string, expiresIn *int, expiresInStr *string, requestJson *string) *apimodel.FilterV2 {
filter, err := suite.putFilter(id, nil, nil, nil, expiresIn, expiresInStr, nil, nil, nil, nil, nil, nil, requestJson, http.StatusOK, "", nil)
if err != nil {
suite.FailNow(err.Error())
}
return filter
}
// Regression test for https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPutFilterUnsetExpirationDateEmptyString() {
id := suite.testFilters["local_account_1_filter_2"].ID
// Setup: set an expiration date for the filter.
expiresIn := 86400
filter := suite.setFilterExpiration(id, &expiresIn, nil, nil)
if !suite.NotNil(filter.ExpiresAt) {
suite.FailNow("Test precondition failed")
}
// Unset the filter's expiration date by setting it to an empty string.
expiresInStr := ""
filter = suite.setFilterExpiration(id, nil, &expiresInStr, nil)
suite.Nil(filter.ExpiresAt)
}
// Regression test related to https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPutFilterUnsetExpirationDateNullJSON() {
id := suite.testFilters["local_account_1_filter_3"].ID
// Setup: set an expiration date for the filter.
expiresIn := 86400
filter := suite.setFilterExpiration(id, &expiresIn, nil, nil)
if !suite.NotNil(filter.ExpiresAt) {
suite.FailNow("Test precondition failed")
}
// Unset the filter's expiration date by setting it to a null literal.
requestJson := `{
"expires_in": null
}`
filter = suite.setFilterExpiration(id, nil, nil, &requestJson)
suite.Nil(filter.ExpiresAt)
}
// Regression test related to https://github.com/superseriousbusiness/gotosocial/issues/3497
func (suite *FiltersTestSuite) TestPutFilterUnalteredExpirationDateJSON() {
id := suite.testFilters["local_account_1_filter_4"].ID
// Setup: set an expiration date for the filter.
expiresIn := 86400
filter := suite.setFilterExpiration(id, &expiresIn, nil, nil)
if !suite.NotNil(filter.ExpiresAt) {
suite.FailNow("Test precondition failed")
}
// Update nothing. There should still be an expiration date.
requestJson := `{}`
filter = suite.setFilterExpiration(id, nil, nil, &requestJson)
suite.NotNil(filter.ExpiresAt)
}

View file

@ -175,6 +175,7 @@ func validateInstanceUpdate(form *apimodel.InstanceSettingsUpdateRequest) error
form.ContactEmail == nil &&
form.ShortDescription == nil &&
form.Description == nil &&
form.CustomCSS == nil &&
form.Terms == nil &&
form.Avatar == nil &&
form.AvatarDescription == nil &&

View file

@ -18,14 +18,16 @@
package notifications
import (
"fmt"
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// NotificationsGETHandler swagger:operation GET /api/v1/notifications notifications
@ -152,27 +154,23 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) {
return
}
limit := 20
limitString := c.Query(LimitKey)
if limitString != "" {
i, err := strconv.ParseInt(limitString, 10, 32)
if err != nil {
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
limit = int(i)
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
80, // max limit
20, // no limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
ctx := c.Request.Context()
resp, errWithCode := m.processor.Timeline().NotificationsGet(
c.Request.Context(),
ctx,
authed,
c.Query(MaxIDKey),
c.Query(SinceIDKey),
c.Query(MinIDKey),
limit,
c.QueryArray(TypesKey),
c.QueryArray(ExcludeTypesKey),
page,
parseNotificationTypes(ctx, c.QueryArray(TypesKey)), // Include types.
parseNotificationTypes(ctx, c.QueryArray(ExcludeTypesKey)), // Exclude types.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
@ -185,3 +183,28 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) {
apiutil.JSON(c, http.StatusOK, resp.Items)
}
// parseNotificationTypes converts the given slice of string values
// to gtsmodel notification types, logging + skipping unknown types.
func parseNotificationTypes(
ctx context.Context,
values []string,
) []gtsmodel.NotificationType {
if len(values) == 0 {
return nil
}
ntypes := make([]gtsmodel.NotificationType, 0, len(values))
for _, value := range values {
ntype := gtsmodel.ParseNotificationType(value)
if ntype == gtsmodel.NotificationUnknown {
// Type we don't know about (yet), log and ignore it.
log.Warnf(ctx, "ignoring unknown type %s", value)
continue
}
ntypes = append(ntypes, ntype)
}
return ntypes
}

View file

@ -248,6 +248,45 @@ func (suite *NotificationsTestSuite) TestGetNotificationsIncludeOneType() {
}
}
// Test including an unknown notification type, it should be ignored.
func (suite *NotificationsTestSuite) TestGetNotificationsIncludeUnknownType() {
testAccount := suite.testAccounts["local_account_1"]
testToken := suite.testTokens["local_account_1"]
testUser := suite.testUsers["local_account_1"]
suite.addMoreNotifications(testAccount)
maxID := ""
minID := ""
limit := 10
types := []string{"favourite", "something.weird"}
excludeTypes := []string(nil)
expectedHTTPStatus := http.StatusOK
expectedBody := ""
notifications, _, err := suite.getNotifications(
testAccount,
testToken,
testUser,
maxID,
minID,
limit,
types,
excludeTypes,
expectedHTTPStatus,
expectedBody,
)
if err != nil {
suite.FailNow(err.Error())
}
// This should only include the fav notification.
suite.Len(notifications, 1)
for _, notification := range notifications {
suite.Equal("favourite", notification.Type)
}
}
func TestBookmarkTestSuite(t *testing.T) {
suite.Run(t, new(NotificationsTestSuite))
}

View file

@ -95,5 +95,5 @@ type FilterCreateUpdateRequestV1 struct {
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
ExpiresInI Nullable[any] `json:"expires_in"`
}

View file

@ -134,7 +134,7 @@ type FilterCreateRequestV2 struct {
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
ExpiresInI Nullable[any] `json:"expires_in"`
// Keywords to be added to the newly created filter.
Keywords []FilterKeywordCreateUpdateRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`
@ -199,7 +199,7 @@ type FilterUpdateRequestV2 struct {
// Number of seconds from now that the filter should expire. If omitted, filter never expires.
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
ExpiresInI Nullable[any] `json:"expires_in"`
// Keywords to be added to the filter, modified, or removed.
Keywords []FilterKeywordCreateUpdateDeleteRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`

View file

@ -33,6 +33,8 @@ type InstanceSettingsUpdateRequest struct {
ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"`
// Longer description of the instance, max 5,000 chars. HTML formatting accepted.
Description *string `form:"description" json:"description" xml:"description"`
// Custom CSS for the instance.
CustomCSS *string `form:"custom_css" json:"custom_css,omitempty" xml:"custom_css"`
// Terms and conditions of the instance, max 5,000 chars. HTML formatting accepted.
Terms *string `form:"terms" json:"terms" xml:"terms"`
// Image to use as the instance thumbnail.

View file

@ -38,6 +38,8 @@ type InstanceV1 struct {
//
// This should be displayed on the 'about' page for an instance.
Description string `json:"description"`
// Custom CSS for the instance.
CustomCSS string `json:"custom_css,omitempty"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance.

View file

@ -53,6 +53,8 @@ type InstanceV2 struct {
Description string `json:"description"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// Instance Custom Css
CustomCSS string `json:"custom_css,omitempty"`
// Basic anonymous usage data for this instance.
Usage InstanceV2Usage `json:"usage"`
// An image used to represent this instance.

View file

@ -0,0 +1,107 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package model
import (
"bytes"
"encoding/json"
"errors"
)
// Nullable is a generic type, which implements a field that can be one of three states:
//
// - field is not set in the request
// - field is explicitly set to `null` in the request
// - field is explicitly set to a valid value in the request
//
// Nullable is intended to be used with JSON unmarshalling.
//
// Adapted from https://github.com/oapi-codegen/nullable/blob/main/nullable.go
type Nullable[T any] struct {
state nullableState
value T
}
type nullableState uint8
const (
nullableStateUnspecified nullableState = 0
nullableStateNull nullableState = 1
nullableStateSet nullableState = 2
)
// Get retrieves the underlying value, if present,
// and returns an error if the value was not present.
func (t Nullable[T]) Get() (T, error) {
var empty T
if t.IsNull() {
return empty, errors.New("value is null")
}
if !t.IsSpecified() {
return empty, errors.New("value is not specified")
}
return t.value, nil
}
// IsNull indicates whether the field
// was sent, and had a value of `null`
func (t Nullable[T]) IsNull() bool {
return t.state == nullableStateNull
}
// IsSpecified indicates whether the field
// was sent either as a value or as `null`.
func (t Nullable[T]) IsSpecified() bool {
return t.state != nullableStateUnspecified
}
// If field is unspecified,
// UnmarshalJSON won't be called.
func (t *Nullable[T]) UnmarshalJSON(data []byte) error {
// If field is specified as `null`.
if bytes.Equal(data, []byte("null")) {
t.setNull()
return nil
}
// Otherwise, we have an
// actual value, so parse it.
var v T
if err := json.Unmarshal(data, &v); err != nil {
return err
}
t.set(v)
return nil
}
// setNull indicates that the field
// was sent, and had a value of `null`
func (t *Nullable[T]) setNull() {
*t = Nullable[T]{state: nullableStateNull}
}
// set the underlying value to given value.
func (t *Nullable[T]) set(value T) {
*t = Nullable[T]{
state: nullableStateSet,
value: value,
}
}

View file

@ -20,12 +20,13 @@ package util
import (
"fmt"
"strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// ParseDuration parses the given raw interface belonging to
// ParseDuration parses the given raw interface belonging
// the given fieldName as an integer duration.
//
// Will return nil, nil if rawI is the zero value of its type.
func ParseDuration(rawI any, fieldName string) (*int, error) {
var (
asInteger int
@ -60,11 +61,28 @@ func ParseDuration(rawI any, fieldName string) (*int, error) {
return nil, err
}
// Someone submitted 0,
// don't point to this.
if asInteger == 0 {
return nil, nil
}
return &asInteger, nil
}
// ParseNullableDuration is like ParseDuration, but
// for JSON values that may have been sent as `null`.
//
// IsSpecified should be checked and "true" on the
// given nullable before calling this function.
func ParseNullableDuration(
nullable apimodel.Nullable[any],
fieldName string,
) (*int, error) {
if nullable.IsNull() {
// Was specified as `null`,
// return pointer to zero value.
return util.Ptr(0), nil
}
rawI, err := nullable.Get()
if err != nil {
return nil, err
}
return ParseDuration(rawI, fieldName)
}

View file

@ -106,7 +106,7 @@ func (suite *AccountTestSuite) populateTestStatus(testAccountKey string, status
status.URI = fmt.Sprintf("http://localhost:8080/users/%s/statuses/%s", testAccount.Username, status.ID)
status.Local = util.Ptr(true)
if status.Visibility == "" {
if status.Visibility == 0 {
status.Visibility = gtsmodel.VisibilityDefault
}
if status.ActivityStreamsType == "" {

View file

@ -49,6 +49,7 @@ import (
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
)
// DBService satisfies the DB interface
@ -131,18 +132,18 @@ func doMigration(ctx context.Context, db *bun.DB) error {
// NewBunDBService returns a bunDB derived from the provided config, which implements the go-fed DB interface.
// Under the hood, it uses https://github.com/uptrace/bun to create and maintain a database connection.
func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
var db *bun.DB
var sqldb *sql.DB
var dialect func() schema.Dialect
var err error
t := strings.ToLower(config.GetDbType())
switch t {
switch t := strings.ToLower(config.GetDbType()); t {
case "postgres":
db, err = pgConn(ctx)
sqldb, dialect, err = pgConn(ctx)
if err != nil {
return nil, err
}
case "sqlite":
db, err = sqliteConn(ctx)
sqldb, dialect, err = sqliteConn(ctx)
if err != nil {
return nil, err
}
@ -150,34 +151,20 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
return nil, fmt.Errorf("database type %s not supported for bundb", t)
}
// Add database query hooks.
db.AddQueryHook(queryHook{})
if config.GetTracingEnabled() {
db.AddQueryHook(tracing.InstrumentBun())
}
if config.GetMetricsEnabled() {
db.AddQueryHook(metrics.InstrumentBun())
}
// table registration is needed for many-to-many, see:
// https://bun.uptrace.dev/orm/many-to-many-relation/
for _, t := range []interface{}{
&gtsmodel.AccountToEmoji{},
&gtsmodel.ConversationToStatus{},
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusToTag{},
&gtsmodel.ThreadToStatus{},
} {
db.RegisterModel(t)
}
// perform any pending database migrations: this includes
// the very first 'migration' on startup which just creates
// necessary tables
if err := doMigration(ctx, db); err != nil {
// perform any pending database migrations: this includes the first
// 'migration' on startup which just creates necessary db tables.
//
// Note this uses its own instance of bun.DB as bun will automatically
// store in-memory reflect type schema of any Go models passed to it,
// and we still maintain lots of old model versions in the migrations.
if err := doMigration(ctx, bunDB(sqldb, dialect)); err != nil {
return nil, fmt.Errorf("db migration error: %s", err)
}
// Wrap sql.DB as bun.DB type,
// adding any connection hooks.
db := bunDB(sqldb, dialect)
ps := &DBService{
Account: &accountDB{
db: db,
@ -319,17 +306,47 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
return ps, nil
}
func pgConn(ctx context.Context) (*bun.DB, error) {
// bunDB returns a new bun.DB for given sql.DB connection pool and dialect
// function. This can be used to apply any necessary opts / hooks as we
// initialize a bun.DB object both before and after performing migrations.
func bunDB(sqldb *sql.DB, dialect func() schema.Dialect) *bun.DB {
db := bun.NewDB(sqldb, dialect())
// Add our SQL connection hooks.
db.AddQueryHook(queryHook{})
if config.GetTracingEnabled() {
db.AddQueryHook(tracing.InstrumentBun())
}
if config.GetMetricsEnabled() {
db.AddQueryHook(metrics.InstrumentBun())
}
// table registration is needed for many-to-many, see:
// https://bun.uptrace.dev/orm/many-to-many-relation/
for _, t := range []interface{}{
&gtsmodel.AccountToEmoji{},
&gtsmodel.ConversationToStatus{},
&gtsmodel.StatusToEmoji{},
&gtsmodel.StatusToTag{},
&gtsmodel.ThreadToStatus{},
} {
db.RegisterModel(t)
}
return db
}
func pgConn(ctx context.Context) (*sql.DB, func() schema.Dialect, error) {
opts, err := deriveBunDBPGOptions() //nolint:contextcheck
if err != nil {
return nil, fmt.Errorf("could not create bundb postgres options: %w", err)
return nil, nil, fmt.Errorf("could not create bundb postgres options: %w", err)
}
cfg := stdlib.RegisterConnConfig(opts)
sqldb, err := sql.Open("pgx-gts", cfg)
if err != nil {
return nil, fmt.Errorf("could not open postgres db: %w", err)
return nil, nil, fmt.Errorf("could not open postgres db: %w", err)
}
// Tune db connections for postgres, see:
@ -339,22 +356,20 @@ func pgConn(ctx context.Context) (*bun.DB, error) {
sqldb.SetMaxIdleConns(2) // assume default 2; if max idle is less than max open, it will be automatically adjusted
sqldb.SetConnMaxLifetime(5 * time.Minute) // fine to kill old connections
db := bun.NewDB(sqldb, pgdialect.New())
// ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("postgres ping: %w", err)
if err := sqldb.PingContext(ctx); err != nil {
return nil, nil, fmt.Errorf("postgres ping: %w", err)
}
log.Info(ctx, "connected to POSTGRES database")
return db, nil
return sqldb, func() schema.Dialect { return pgdialect.New() }, nil
}
func sqliteConn(ctx context.Context) (*bun.DB, error) {
func sqliteConn(ctx context.Context) (*sql.DB, func() schema.Dialect, error) {
// validate db address has actually been set
address := config.GetDbAddress()
if address == "" {
return nil, fmt.Errorf("'%s' was not set when attempting to start sqlite", config.DbAddressFlag())
return nil, nil, fmt.Errorf("'%s' was not set when attempting to start sqlite", config.DbAddressFlag())
}
// Build SQLite connection address with prefs.
@ -363,7 +378,7 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
// Open new DB instance
sqldb, err := sql.Open("sqlite-gts", address)
if err != nil {
return nil, fmt.Errorf("could not open sqlite db with address %s: %w", address, err)
return nil, nil, fmt.Errorf("could not open sqlite db with address %s: %w", address, err)
}
// Tune db connections for sqlite, see:
@ -379,16 +394,14 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
sqldb.SetConnMaxLifetime(5 * time.Minute)
}
db := bun.NewDB(sqldb, sqlitedialect.New())
// ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("sqlite ping: %w", err)
if err := sqldb.PingContext(ctx); err != nil {
return nil, nil, fmt.Errorf("sqlite ping: %w", err)
}
log.Infof(ctx, "connected to SQLITE database with address %s", address)
return db, nil
return sqldb, func() schema.Dialect { return sqlitedialect.New() }, nil
}
/*
@ -420,13 +433,12 @@ func maxOpenConns() int {
// deriveBunDBPGOptions takes an application config and returns either a ready-to-use set of options
// with sensible defaults, or an error if it's not satisfied by the provided config.
func deriveBunDBPGOptions() (*pgx.ConnConfig, error) {
url := config.GetDbPostgresConnectionString()
// if database URL is defined, ignore other DB related configuration fields
if url != "" {
cfg, err := pgx.ParseConfig(url)
return cfg, err
// If database URL is defined, ignore
// other DB-related configuration fields.
if url := config.GetDbPostgresConnectionString(); url != "" {
return pgx.ParseConfig(url)
}
// these are all optional, the db adapter figures out defaults
address := config.GetDbAddress()
@ -518,15 +530,12 @@ func buildSQLiteAddress(addr string) (string, bool) {
//
// - SQLite by itself supports setting a subset of its configuration options
// via URI query arguments in the connection. Namely `mode` and `cache`.
// This is the same situation for the directly transpiled C->Go code in
// modernc.org/sqlite, i.e. modernc.org/sqlite/lib, NOT the Go SQL driver.
// This is the same situation for our supported SQLite implementations.
//
// - `modernc.org/sqlite` has a "shim" around it to allow the directly
// transpiled C code to be usable with a more native Go API. This is in
// the form of a `database/sql/driver.Driver{}` implementation that calls
// through to the transpiled C code.
// - Both implementations have a "shim" around them in the form of a
// `database/sql/driver.Driver{}` implementation.
//
// - The SQLite shim we interface with adds support for setting ANY of the
// - The SQLite shims we interface with add support for setting ANY of the
// configuration options via query arguments, through using a special `_pragma`
// query key that specifies SQLite PRAGMAs to set upon opening each connection.
// As such you will see below that most config is set with the `_pragma` key.
@ -552,12 +561,6 @@ func buildSQLiteAddress(addr string) (string, bool) {
// reached. And for whatever reason (:shrug:) SQLite is very particular about
// setting this BEFORE the `journal_mode` is set, otherwise you can end up
// running into more of these `SQLITE_BUSY` return codes than you might expect.
//
// - One final thing (I promise!): `SQLITE_BUSY` is only handled by the internal
// `busy_timeout` handler in the case that a data race occurs contending for
// table locks. THERE ARE STILL OTHER SITUATIONS IN WHICH THIS MAY BE RETURNED!
// As such, we use our wrapping DB{} and Tx{} types (in "db.go") which make use
// of our own retry-busy handler.
// Drop anything fancy from DB address
addr = strings.Split(addr, "?")[0] // drop any provided query strings

View file

@ -104,7 +104,7 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (
q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true)
// Ignore statuses that are direct messages.
q = q.Where("NOT ? = ?", bun.Ident("status.visibility"), "direct")
q = q.Where("NOT ? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityDirect)
count, err := q.Count(ctx)
if err != nil {

View file

@ -77,7 +77,7 @@ func init() {
UpdatedAt: oldAction.UpdatedAt,
TargetCategory: gtsmodel.AdminActionCategoryAccount,
TargetID: oldAction.TargetAccountID,
Type: gtsmodel.NewAdminActionType(string(oldAction.Type)),
Type: gtsmodel.ParseAdminActionType(string(oldAction.Type)),
AccountID: oldAction.AccountID,
Text: oldAction.Text,
SendEmail: util.Ptr(oldAction.SendEmail),

View file

@ -21,7 +21,8 @@ import (
"context"
"strings"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20231002153327_add_status_polls"
"github.com/uptrace/bun"
)

View file

@ -0,0 +1,48 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
)
// Poll represents an attached (to) Status poll, i.e. a questionaire. Can be remote / local.
type Poll struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // Unique identity string.
Multiple *bool `bun:",nullzero,notnull,default:false"` // Is this a multiple choice poll? i.e. can you vote on multiple options.
HideCounts *bool `bun:",nullzero,notnull,default:false"` // Hides vote counts until poll ends.
Options []string `bun:",nullzero,notnull"` // The available options for this poll.
Votes []int `bun:",nullzero,notnull"` // Vote counts per choice.
Voters *int `bun:",nullzero,notnull"` // Total no. voters count.
StatusID string `bun:"type:CHAR(26),nullzero,notnull,unique"` // Status ID of which this Poll is attached to.
ExpiresAt time.Time `bun:"type:timestamptz,nullzero,notnull"` // The expiry date of this Poll.
ClosedAt time.Time `bun:"type:timestamptz,nullzero"` // The closure date of this poll, will be zerotime until set.
Closing bool `bun:"-"` // An ephemeral field only set on Polls in the middle of closing.
// no creation date, use attached Status.CreatedAt.
}
// PollVote represents a single instance of vote(s) in a Poll by an account.
// If the Poll is single-choice, len(.Choices) = 1, if multiple-choice then
// len(.Choices) >= 1. Can be remote or local.
type PollVote struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // Unique identity string.
Choices []int `bun:",nullzero,notnull"` // The Poll's option indices of which these are votes for.
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique:in_poll_by_account"` // Account ID from which this vote originated.
PollID string `bun:"type:CHAR(26),nullzero,notnull,unique:in_poll_by_account"` // Poll ID of which this is a vote in.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation date of this PollVote.
}

View file

@ -0,0 +1,75 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
PollID string `bun:"type:CHAR(26),nullzero"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
Boostable *bool `bun:",notnull"` // This status can be boosted/reblogged
Replyable *bool `bun:",notnull"` // This status can be replied to
Likeable *bool `bun:",notnull"` // This status can be liked/faved
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -161,11 +161,14 @@ func init() {
return err
}
// Get the mapping of old enum string values to new integer values.
visibilityMapping := visibilityEnumMapping[oldmodel.Visibility]()
// For each status found in this way, update
// to new version of interaction policy.
for _, oldStatus := range oldStatuses {
// Start with default policy for this visibility.
v := gtsmodel.Visibility(oldStatus.Visibility)
v := visibilityMapping[oldStatus.Visibility]
policy := gtsmodel.DefaultInteractionPolicyFor(v)
if !*oldStatus.Likeable {

View file

@ -22,40 +22,61 @@ import (
)
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
FetchedAt time.Time `bun:"type:timestamptz,nullzero"`
PinnedAt time.Time `bun:"type:timestamptz,nullzero"`
URI string `bun:",unique,nullzero,notnull"`
URL string `bun:",nullzero"`
Content string `bun:""`
AttachmentIDs []string `bun:"attachments,array"`
TagIDs []string `bun:"tags,array"`
MentionIDs []string `bun:"mentions,array"`
EmojiIDs []string `bun:"emojis,array"`
Local *bool `bun:",nullzero,notnull,default:false"`
AccountID string `bun:"type:CHAR(26),nullzero,notnull"`
AccountURI string `bun:",nullzero,notnull"`
InReplyToID string `bun:"type:CHAR(26),nullzero"`
InReplyToURI string `bun:",nullzero"`
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"`
InReplyTo *Status `bun:"-"`
BoostOfID string `bun:"type:CHAR(26),nullzero"`
BoostOfURI string `bun:"-"`
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"`
BoostOf *Status `bun:"-"`
ThreadID string `bun:"type:CHAR(26),nullzero"`
PollID string `bun:"type:CHAR(26),nullzero"`
ContentWarning string `bun:",nullzero"`
Visibility string `bun:",nullzero,notnull"`
Sensitive *bool `bun:",nullzero,notnull,default:false"`
Language string `bun:",nullzero"`
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"`
ActivityStreamsType string `bun:",nullzero,notnull"`
Text string `bun:""`
Federated *bool `bun:",notnull"`
Boostable *bool `bun:",notnull"`
Replyable *bool `bun:",notnull"`
Likeable *bool `bun:",notnull"`
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
FetchedAt time.Time `bun:"type:timestamptz,nullzero"`
PinnedAt time.Time `bun:"type:timestamptz,nullzero"`
URI string `bun:",unique,nullzero,notnull"`
URL string `bun:",nullzero"`
Content string `bun:""`
AttachmentIDs []string `bun:"attachments,array"`
TagIDs []string `bun:"tags,array"`
MentionIDs []string `bun:"mentions,array"`
EmojiIDs []string `bun:"emojis,array"`
Local *bool `bun:",nullzero,notnull,default:false"`
AccountID string `bun:"type:CHAR(26),nullzero,notnull"`
AccountURI string `bun:",nullzero,notnull"`
InReplyToID string `bun:"type:CHAR(26),nullzero"`
InReplyToURI string `bun:",nullzero"`
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"`
InReplyTo *Status `bun:"-"`
BoostOfID string `bun:"type:CHAR(26),nullzero"`
BoostOfURI string `bun:"-"`
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"`
BoostOf *Status `bun:"-"`
ThreadID string `bun:"type:CHAR(26),nullzero"`
PollID string `bun:"type:CHAR(26),nullzero"`
ContentWarning string `bun:",nullzero"`
Visibility Visibility `bun:",nullzero,notnull"`
Sensitive *bool `bun:",nullzero,notnull,default:false"`
Language string `bun:",nullzero"`
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"`
ActivityStreamsType string `bun:",nullzero,notnull"`
Text string `bun:""`
Federated *bool `bun:",notnull"`
Boostable *bool `bun:",notnull"`
Replyable *bool `bun:",notnull"`
Likeable *bool `bun:",notnull"`
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -20,7 +20,7 @@ package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction"
"github.com/uptrace/bun"
)

View file

@ -0,0 +1,66 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// SinBinStatus represents a status that's been rejected and/or reported + quarantined.
//
// Automatically rejected statuses are not put in the sin bin, only statuses that were
// stored on the instance and which someone (local or remote) has subsequently rejected.
type SinBinStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Creation time of this item.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Last-updated time of this item.
URI string `bun:",unique,nullzero,notnull"` // ActivityPub URI/ID of this status.
URL string `bun:",nullzero"` // Web url for viewing this status.
Domain string `bun:",nullzero"` // Domain of the status, will be null if this is a local status, otherwise something like `example.org`.
AccountURI string `bun:",nullzero,notnull"` // ActivityPub uri of the author of this status.
InReplyToURI string `bun:",nullzero"` // ActivityPub uri of the status this status is a reply to.
Content string `bun:",nullzero"` // Content of this status.
AttachmentLinks []string `bun:",nullzero,array"` // Links to attachments of this status.
MentionTargetURIs []string `bun:",nullzero,array"` // URIs of mentioned accounts.
EmojiLinks []string `bun:",nullzero,array"` // Links to any emoji images used in this status.
PollOptions []string `bun:",nullzero,array"` // String values of any poll options used in this status.
ContentWarning string `bun:",nullzero"` // CW / subject string for this status.
Visibility Visibility `bun:",nullzero,notnull"` // Visibility level of this status.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Mark the status as sensitive.
Language string `bun:",nullzero"` // Language code for this status.
ActivityStreamsType string `bun:",nullzero,notnull"` // ActivityStreams type of this status.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -0,0 +1,44 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TEXT", bun.Ident("instances"), bun.Ident("custom_css"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("instances"), bun.Ident("custom_css"))
return err
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,249 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"errors"
old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Tables with visibility types.
var visTables = []struct {
Table string
Column string
Default *new_gtsmodel.Visibility
}{
{Table: "statuses", Column: "visibility"},
{Table: "sin_bin_statuses", Column: "visibility"},
{Table: "account_settings", Column: "privacy", Default: util.Ptr(new_gtsmodel.VisibilityDefault)},
{Table: "account_settings", Column: "web_visibility", Default: util.Ptr(new_gtsmodel.VisibilityDefault)},
}
// Visibility type indices.
var visIndices = []struct {
name string
cols []string
order string
}{
{
name: "statuses_visibility_idx",
cols: []string{"visibility"},
order: "",
},
{
name: "statuses_profile_web_view_idx",
cols: []string{"account_id", "visibility"},
order: "id DESC",
},
{
name: "statuses_public_timeline_idx",
cols: []string{"visibility"},
order: "id DESC",
},
}
// Before making changes to the visibility col
// we must drop all indices that rely on it.
for _, index := range visIndices {
if _, err := tx.NewDropIndex().
Index(index.name).
Exec(ctx); err != nil {
return err
}
}
// Get the mapping of old enum string values to new integer values.
visibilityMapping := visibilityEnumMapping[old_gtsmodel.Visibility]()
// Convert all visibility tables.
for _, table := range visTables {
if err := convertEnums(ctx, tx, table.Table, table.Column,
visibilityMapping, table.Default); err != nil {
return err
}
}
// Recreate the visibility indices.
for _, index := range visIndices {
q := tx.NewCreateIndex().
Table("statuses").
Index(index.name).
Column(index.cols...)
if index.order != "" {
q = q.ColumnExpr(index.order)
}
if _, err := q.Exec(ctx); err != nil {
return err
}
}
// Get the mapping of old enum string values to the new integer value types.
notificationMapping := notificationEnumMapping[old_gtsmodel.NotificationType]()
// Migrate over old notifications table column over to new column type.
if err := convertEnums(ctx, tx, "notifications", "notification_type", //nolint:revive
notificationMapping, nil); err != nil {
return err
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}
// convertEnums performs a transaction that converts
// a table's column of our old-style enums (strings) to
// more performant and space-saving integer types.
func convertEnums[OldType ~string, NewType ~int16](
ctx context.Context,
tx bun.Tx,
table string,
column string,
mapping map[OldType]NewType,
defaultValue *NewType,
) error {
if len(mapping) == 0 {
return errors.New("empty mapping")
}
// Generate new column name.
newColumn := column + "_new"
log.Infof(ctx, "converting %s.%s enums; "+
"this may take a while, please don't interrupt!",
table, column,
)
// Ensure a default value.
if defaultValue == nil {
var zero NewType
defaultValue = &zero
}
// Add new column to database.
if _, err := tx.NewAddColumn().
Table(table).
ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
bun.Ident(newColumn),
*defaultValue).
Exec(ctx); err != nil {
return gtserror.Newf("error adding new column: %w", err)
}
// Get a count of all in table.
total, err := tx.NewSelect().
Table(table).
Count(ctx)
if err != nil {
return gtserror.Newf("error selecting total count: %w", err)
}
var updated int
for old, new := range mapping {
// Update old to new values.
res, err := tx.NewUpdate().
Table(table).
Where("? = ?", bun.Ident(column), old).
Set("? = ?", bun.Ident(newColumn), new).
Exec(ctx)
if err != nil {
return gtserror.Newf("error updating old column values: %w", err)
}
// Count number items updated.
n, _ := res.RowsAffected()
updated += int(n)
}
// Check total updated.
if total != updated {
log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
}
// Drop the old column from table.
if _, err := tx.NewDropColumn().
Table(table).
ColumnExpr("?", bun.Ident(column)).
Exec(ctx); err != nil {
return gtserror.Newf("error dropping old column: %w", err)
}
// Rename new to old name.
if _, err := tx.NewRaw(
"ALTER TABLE ? RENAME COLUMN ? TO ?",
bun.Ident(table),
bun.Ident(newColumn),
bun.Ident(column),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming new column: %w", err)
}
return nil
}
// visibilityEnumMapping maps old Visibility enum values to their newer integer type.
func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility {
return map[T]new_gtsmodel.Visibility{
T(old_gtsmodel.VisibilityNone): new_gtsmodel.VisibilityNone,
T(old_gtsmodel.VisibilityPublic): new_gtsmodel.VisibilityPublic,
T(old_gtsmodel.VisibilityUnlocked): new_gtsmodel.VisibilityUnlocked,
T(old_gtsmodel.VisibilityFollowersOnly): new_gtsmodel.VisibilityFollowersOnly,
T(old_gtsmodel.VisibilityMutualsOnly): new_gtsmodel.VisibilityMutualsOnly,
T(old_gtsmodel.VisibilityDirect): new_gtsmodel.VisibilityDirect,
}
}
// notificationEnumMapping maps old NotificationType enum values to their newer integer type.
func notificationEnumMapping[T ~string]() map[T]new_gtsmodel.NotificationType {
return map[T]new_gtsmodel.NotificationType{
T(old_gtsmodel.NotificationFollow): new_gtsmodel.NotificationFollow,
T(old_gtsmodel.NotificationFollowRequest): new_gtsmodel.NotificationFollowRequest,
T(old_gtsmodel.NotificationMention): new_gtsmodel.NotificationMention,
T(old_gtsmodel.NotificationReblog): new_gtsmodel.NotificationReblog,
T(old_gtsmodel.NotificationFave): new_gtsmodel.NotificationFave,
T(old_gtsmodel.NotificationPoll): new_gtsmodel.NotificationPoll,
T(old_gtsmodel.NotificationStatus): new_gtsmodel.NotificationStatus,
T(old_gtsmodel.NotificationSignup): new_gtsmodel.NotificationSignup,
T(old_gtsmodel.NotificationPendingFave): new_gtsmodel.NotificationPendingFave,
T(old_gtsmodel.NotificationPendingReply): new_gtsmodel.NotificationPendingReply,
T(old_gtsmodel.NotificationPendingReblog): new_gtsmodel.NotificationPendingReblog,
}
}

View file

@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// AccountSettings models settings / preferences for a local, non-instance account.
type AccountSettings struct {
AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
Privacy Visibility `bun:",nullzero,default:3"` // Default post privacy for this account
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
WebVisibility Visibility `bun:",nullzero,notnull,default:3"` // Visibility level of statuses that visitors can view via the web profile.
InteractionPolicyDirect *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy.
InteractionPolicyMutualsOnly *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new mutuals only visibility statuses. If null, assume default policy.
InteractionPolicyFollowersOnly *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new followers only visibility statuses. If null, assume default policy.
InteractionPolicyUnlocked *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new unlocked visibility statuses. If null, assume default policy.
InteractionPolicyPublic *gtsmodel.InteractionPolicy `bun:""` // Interaction policy to use for new public visibility statuses. If null, assume default policy.
}

View file

@ -0,0 +1,57 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc.
type Notification struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
NotificationType NotificationType `bun:",nullzero,notnull"` // Type of this notification
TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account targeted by the notification (ie., who will receive the notification?)
TargetAccount *gtsmodel.Account `bun:"-"` // Account corresponding to TargetAccountID. Can be nil, always check first + select using ID if necessary.
OriginAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the account that performed the action that created the notification.
OriginAccount *gtsmodel.Account `bun:"-"` // Account corresponding to OriginAccountID. Can be nil, always check first + select using ID if necessary.
StatusID string `bun:"type:CHAR(26),nullzero"` // If the notification pertains to a status, what is the database ID of that status?
Status *Status `bun:"-"` // Status corresponding to StatusID. Can be nil, always check first + select using ID if necessary.
Read *bool `bun:",nullzero,notnull,default:false"` // Notification has been seen/read
}
// NotificationType describes the reason/type of this notification.
type NotificationType string
// Notification Types
const (
NotificationFollow NotificationType = "follow" // NotificationFollow -- someone followed you
NotificationFollowRequest NotificationType = "follow_request" // NotificationFollowRequest -- someone requested to follow you
NotificationMention NotificationType = "mention" // NotificationMention -- someone mentioned you in their status
NotificationReblog NotificationType = "reblog" // NotificationReblog -- someone boosted one of your statuses
NotificationFave NotificationType = "favourite" // NotificationFave -- someone faved/liked one of your statuses
NotificationPoll NotificationType = "poll" // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = "status" // NotificationStatus -- someone you enabled notifications for has posted a status.
NotificationSignup NotificationType = "admin.sign_up" // NotificationSignup -- someone has submitted a new account sign-up to the instance.
NotificationPendingFave NotificationType = "pending.favourite" // Someone has faved a status of yours, which requires approval by you.
NotificationPendingReply NotificationType = "pending.reply" // Someone has replied to a status of yours, which requires approval by you.
NotificationPendingReblog NotificationType = "pending.reblog" // Someone has boosted a status of yours, which requires approval by you.
)

View file

@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// SinBinStatus represents a status that's been rejected and/or reported + quarantined.
//
// Automatically rejected statuses are not put in the sin bin, only statuses that were
// stored on the instance and which someone (local or remote) has subsequently rejected.
type SinBinStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Creation time of this item.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Last-updated time of this item.
URI string `bun:",unique,nullzero,notnull"` // ActivityPub URI/ID of this status.
URL string `bun:",nullzero"` // Web url for viewing this status.
Domain string `bun:",nullzero"` // Domain of the status, will be null if this is a local status, otherwise something like `example.org`.
AccountURI string `bun:",nullzero,notnull"` // ActivityPub uri of the author of this status.
InReplyToURI string `bun:",nullzero"` // ActivityPub uri of the status this status is a reply to.
Content string `bun:",nullzero"` // Content of this status.
AttachmentLinks []string `bun:",nullzero,array"` // Links to attachments of this status.
MentionTargetURIs []string `bun:",nullzero,array"` // URIs of mentioned accounts.
EmojiLinks []string `bun:",nullzero,array"` // Links to any emoji images used in this status.
PollOptions []string `bun:",nullzero,array"` // String values of any poll options used in this status.
ContentWarning string `bun:",nullzero"` // CW / subject string for this status.
Visibility Visibility `bun:",nullzero,notnull"` // Visibility level of this status.
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Mark the status as sensitive.
Language string `bun:",nullzero"` // Language code for this status.
ActivityStreamsType string `bun:",nullzero,notnull"` // ActivityStreams type of this status.
}

View file

@ -0,0 +1,95 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
type Status struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
URL string `bun:",nullzero"` // web url for viewing this status
Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *gtsmodel.Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
Language string `bun:",nullzero"` // what language is this status written in?
CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
Text string `bun:""` // Original text of the status without formatting
Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
}
// Visibility represents the visibility granularity of a status.
type Visibility string
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)

View file

@ -26,8 +26,8 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
@ -192,22 +192,19 @@ func (n *notificationDB) PopulateNotification(ctx context.Context, notif *gtsmod
func (n *notificationDB) GetAccountNotifications(
ctx context.Context,
accountID string,
maxID string,
sinceID string,
minID string,
limit int,
types []string,
excludeTypes []string,
page *paging.Page,
types []gtsmodel.NotificationType,
excludeTypes []gtsmodel.NotificationType,
) ([]*gtsmodel.Notification, error) {
// Ensure reasonable
if limit < 0 {
limit = 0
}
// Make educated guess for slice size
var (
notifIDs = make([]string, 0, limit)
frontToBack = true
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
notifIDs = make([]string, 0, limit)
)
q := n.db.
@ -215,23 +212,14 @@ func (n *notificationDB) GetAccountNotifications(
TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")).
Column("notification.id")
if maxID == "" {
maxID = id.Highest
}
// Return only notifs LOWER (ie., older) than maxID.
q = q.Where("? < ?", bun.Ident("notification.id"), maxID)
if sinceID != "" {
// Return only notifs HIGHER (ie., newer) than sinceID.
q = q.Where("? > ?", bun.Ident("notification.id"), sinceID)
if maxID != "" {
// Return only notifs LOWER (ie., older) than maxID.
q = q.Where("? < ?", bun.Ident("notification.id"), maxID)
}
if minID != "" {
// Return only notifs HIGHER (ie., newer) than minID.
q = q.Where("? > ?", bun.Ident("notification.id"), minID)
frontToBack = false // page up
}
if len(types) > 0 {
@ -251,12 +239,12 @@ func (n *notificationDB) GetAccountNotifications(
q = q.Limit(limit)
}
if frontToBack {
// Page down.
q = q.Order("notification.id DESC")
} else {
if order == paging.OrderAscending {
// Page up.
q = q.Order("notification.id ASC")
} else {
// Page down.
q = q.Order("notification.id DESC")
}
if err := q.Scan(ctx, &notifIDs); err != nil {
@ -269,11 +257,8 @@ func (n *notificationDB) GetAccountNotifications(
// If we're paging up, we still want notifications
// to be sorted by ID desc, so reverse ids slice.
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
if !frontToBack {
for l, r := 0, len(notifIDs)-1; l < r; l, r = l+1, r-1 {
notifIDs[l], notifIDs[r] = notifIDs[r], notifIDs[l]
}
if order == paging.OrderAscending {
slices.Reverse(notifIDs)
}
// Fetch notification models by their IDs.
@ -303,7 +288,7 @@ func (n *notificationDB) DeleteNotificationByID(ctx context.Context, id string)
return nil
}
func (n *notificationDB) DeleteNotifications(ctx context.Context, types []string, targetAccountID string, originAccountID string) error {
func (n *notificationDB) DeleteNotifications(ctx context.Context, types []gtsmodel.NotificationType, targetAccountID string, originAccountID string) error {
if targetAccountID == "" && originAccountID == "" {
return gtserror.New("one of targetAccountID or originAccountID must be set")
}

View file

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -92,10 +93,11 @@ func (suite *NotificationTestSuite) TestGetAccountNotificationsWithSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -115,10 +117,11 @@ func (suite *NotificationTestSuite) TestGetAccountNotificationsWithoutSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -140,10 +143,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -161,10 +165,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() {
notifications, err = suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)
@ -183,10 +188,11 @@ func (suite *NotificationTestSuite) TestDeleteNotificationsWithTwoAccounts() {
notifications, err := suite.db.GetAccountNotifications(
gtscontext.SetBarebones(context.Background()),
testAccount.ID,
id.Highest,
id.Lowest,
"",
20,
&paging.Page{
Min: paging.EitherMinID("", id.Lowest),
Max: paging.MaxID(id.Highest),
Limit: 20,
},
nil,
nil,
)

View file

@ -88,12 +88,15 @@ func (p *pollDB) getPoll(ctx context.Context, lookup string, dbQuery func(*gtsmo
func (p *pollDB) GetOpenPolls(ctx context.Context) ([]*gtsmodel.Poll, error) {
var pollIDs []string
// Select all polls with unset `closed_at` time.
// Select all polls with:
// - UNSET `closed_at`
// - SET `expires_at`
if err := p.db.NewSelect().
Table("polls").
Column("polls.id").
Join("JOIN ? ON ? = ?", bun.Ident("statuses"), bun.Ident("polls.id"), bun.Ident("statuses.poll_id")).
Where("? = true", bun.Ident("statuses.local")).
Where("? IS NOT NULL", bun.Ident("polls.expires_at")).
Where("? IS NULL", bun.Ident("polls.closed_at")).
Scan(ctx, &pollIDs); err != nil {
return nil, err

View file

@ -265,8 +265,8 @@ func (r *relationshipDB) AcceptFollowRequest(ctx context.Context, sourceAccountI
}
// Delete original follow request notification
if err := r.state.DB.DeleteNotifications(ctx, []string{
string(gtsmodel.NotificationFollowRequest),
if err := r.state.DB.DeleteNotifications(ctx, []gtsmodel.NotificationType{
gtsmodel.NotificationFollowRequest,
}, targetAccountID, sourceAccountID); err != nil {
return nil, err
}
@ -281,8 +281,8 @@ func (r *relationshipDB) RejectFollowRequest(ctx context.Context, sourceAccountI
}
// Delete follow request notification
return r.state.DB.DeleteNotifications(ctx, []string{
string(gtsmodel.NotificationFollowRequest),
return r.state.DB.DeleteNotifications(ctx, []gtsmodel.NotificationType{
gtsmodel.NotificationFollowRequest,
}, targetAccountID, sourceAccountID)
}

View file

@ -21,6 +21,7 @@ import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// Notification contains functions for creating and getting notifications.
@ -29,7 +30,7 @@ type Notification interface {
//
// Returned notifications will be ordered ID descending (ie., highest/newest to lowest/oldest).
// If types is empty, *all* notification types will be included.
GetAccountNotifications(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, types []string, excludeTypes []string) ([]*gtsmodel.Notification, error)
GetAccountNotifications(ctx context.Context, accountID string, page *paging.Page, types []gtsmodel.NotificationType, excludeTypes []gtsmodel.NotificationType) ([]*gtsmodel.Notification, error)
// GetNotificationByID returns one notification according to its id.
GetNotificationByID(ctx context.Context, id string) (*gtsmodel.Notification, error)
@ -64,7 +65,7 @@ type Notification interface {
// originate from originAccountID will be deleted.
//
// At least one parameter must not be an empty string.
DeleteNotifications(ctx context.Context, types []string, targetAccountID string, originAccountID string) error
DeleteNotifications(ctx context.Context, types []gtsmodel.NotificationType, targetAccountID string, originAccountID string) error
// DeleteNotificationsForStatus deletes all notifications that relate to
// the given statusID. This function is useful when a status has been deleted,

View file

@ -26,7 +26,7 @@ type AccountSettings struct {
AccountID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // AccountID that owns this settings.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item was last updated.
Privacy Visibility `bun:",nullzero"` // Default post privacy for this account
Privacy Visibility `bun:",nullzero,default:3"` // Default post privacy for this account
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
@ -34,7 +34,7 @@ type AccountSettings struct {
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.
WebVisibility Visibility `bun:",nullzero,notnull,default:public"` // Visibility level of statuses that visitors can view via the web profile.
WebVisibility Visibility `bun:",nullzero,notnull,default:3"` // Visibility level of statuses that visitors can view via the web profile.
InteractionPolicyDirect *InteractionPolicy `bun:""` // Interaction policy to use for new direct visibility statuses by this account. If null, assume default policy.
InteractionPolicyMutualsOnly *InteractionPolicy `bun:""` // Interaction policy to use for new mutuals only visibility statuses. If null, assume default policy.
InteractionPolicyFollowersOnly *InteractionPolicy `bun:""` // Interaction policy to use for new followers only visibility statuses. If null, assume default policy.

View file

@ -19,6 +19,7 @@ package gtsmodel
import (
"path"
"strings"
"time"
)
@ -46,8 +47,8 @@ func (c AdminActionCategory) String() string {
}
}
func NewAdminActionCategory(in string) AdminActionCategory {
switch in {
func ParseAdminActionCategory(in string) AdminActionCategory {
switch strings.ToLower(in) {
case "account":
return AdminActionCategoryAccount
case "domain":
@ -96,8 +97,8 @@ func (t AdminActionType) String() string {
}
}
func NewAdminActionType(in string) AdminActionType {
switch in {
func ParseAdminActionType(in string) AdminActionType {
switch strings.ToLower(in) {
case "disable":
return AdminActionDisable
case "reenable":

View file

@ -0,0 +1,24 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
// enumType is the type we (at least, should) use
// for database enum types. it is the largest size
// supported by a PostgreSQL SMALLINT, since an
// SQLite SMALLINT is actually variable in size.
type enumType int16

View file

@ -17,7 +17,10 @@
package gtsmodel
import "time"
import (
"strings"
"time"
)
// DomainPermission models a domain permission
// entry -- block / allow / draft / exclude.
@ -62,8 +65,8 @@ func (p DomainPermissionType) String() string {
}
}
func NewDomainPermissionType(in string) DomainPermissionType {
switch in {
func ParseDomainPermissionType(in string) DomainPermissionType {
switch strings.ToLower(in) {
case "block":
return DomainPermissionBlock
case "allow":

View file

@ -34,6 +34,7 @@ type Instance struct {
ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing).
CustomCSS string `bun:",nullzero"` // Custom CSS for the instance.
Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance

View file

@ -224,7 +224,7 @@ func DefaultInteractionPolicyFor(v Visibility) *InteractionPolicy {
case VisibilityDirect:
return DefaultInteractionPolicyDirect()
default:
panic("visibility " + v + " not recognized")
panic("invalid visibility")
}
}

View file

@ -17,7 +17,10 @@
package gtsmodel
import "time"
import (
"strings"
"time"
)
// Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc.
type Notification struct {
@ -34,20 +37,82 @@ type Notification struct {
Read *bool `bun:",nullzero,notnull,default:false"` // Notification has been seen/read
}
// NotificationType describes the reason/type of this notification.
type NotificationType string
// NotificationType describes the
// reason/type of this notification.
type NotificationType enumType
// Notification Types
const (
NotificationFollow NotificationType = "follow" // NotificationFollow -- someone followed you
NotificationFollowRequest NotificationType = "follow_request" // NotificationFollowRequest -- someone requested to follow you
NotificationMention NotificationType = "mention" // NotificationMention -- someone mentioned you in their status
NotificationReblog NotificationType = "reblog" // NotificationReblog -- someone boosted one of your statuses
NotificationFave NotificationType = "favourite" // NotificationFave -- someone faved/liked one of your statuses
NotificationPoll NotificationType = "poll" // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = "status" // NotificationStatus -- someone you enabled notifications for has posted a status.
NotificationSignup NotificationType = "admin.sign_up" // NotificationSignup -- someone has submitted a new account sign-up to the instance.
NotificationPendingFave NotificationType = "pending.favourite" // Someone has faved a status of yours, which requires approval by you.
NotificationPendingReply NotificationType = "pending.reply" // Someone has replied to a status of yours, which requires approval by you.
NotificationPendingReblog NotificationType = "pending.reblog" // Someone has boosted a status of yours, which requires approval by you.
// Notification Types
NotificationUnknown NotificationType = 0 // NotificationUnknown -- unknown notification type, error if this occurs
NotificationFollow NotificationType = 1 // NotificationFollow -- someone followed you
NotificationFollowRequest NotificationType = 2 // NotificationFollowRequest -- someone requested to follow you
NotificationMention NotificationType = 3 // NotificationMention -- someone mentioned you in their status
NotificationReblog NotificationType = 4 // NotificationReblog -- someone boosted one of your statuses
NotificationFave NotificationType = 5 // NotificationFave -- someone faved/liked one of your statuses
NotificationPoll NotificationType = 6 // NotificationPoll -- a poll you voted in or created has ended
NotificationStatus NotificationType = 7 // NotificationStatus -- someone you enabled notifications for has posted a status.
NotificationSignup NotificationType = 8 // NotificationSignup -- someone has submitted a new account sign-up to the instance.
NotificationPendingFave NotificationType = 9 // Someone has faved a status of yours, which requires approval by you.
NotificationPendingReply NotificationType = 10 // Someone has replied to a status of yours, which requires approval by you.
NotificationPendingReblog NotificationType = 11 // Someone has boosted a status of yours, which requires approval by you.
)
// String returns a stringified, frontend API compatible form of NotificationType.
func (t NotificationType) String() string {
switch t {
case NotificationFollow:
return "follow"
case NotificationFollowRequest:
return "follow_request"
case NotificationMention:
return "mention"
case NotificationReblog:
return "reblog"
case NotificationFave:
return "favourite"
case NotificationPoll:
return "poll"
case NotificationStatus:
return "status"
case NotificationSignup:
return "admin.sign_up"
case NotificationPendingFave:
return "pending.favourite"
case NotificationPendingReply:
return "pending.reply"
case NotificationPendingReblog:
return "pending.reblog"
default:
panic("invalid notification type")
}
}
// ParseNotificationType returns a notification type from the given value.
func ParseNotificationType(in string) NotificationType {
switch strings.ToLower(in) {
case "follow":
return NotificationFollow
case "follow_request":
return NotificationFollowRequest
case "mention":
return NotificationMention
case "reblog":
return NotificationReblog
case "favourite":
return NotificationFave
case "poll":
return NotificationPoll
case "status":
return NotificationStatus
case "admin.sign_up":
return NotificationSignup
case "pending.favourite":
return NotificationPendingFave
case "pending.reply":
return NotificationPendingReply
case "pending.reblog":
return NotificationPendingReblog
default:
return NotificationUnknown
}
}

View file

@ -263,27 +263,58 @@ type StatusToEmoji struct {
Emoji *Emoji `bun:"rel:belongs-to"`
}
// Visibility represents the visibility granularity of a status.
type Visibility string
// Visibility represents the
// visibility granularity of a status.
type Visibility enumType
const (
// VisibilityNone means nobody can see this.
// It's only used for web status visibility.
VisibilityNone Visibility = "none"
// VisibilityPublic means this status will be visible to everyone on all timelines.
VisibilityPublic Visibility = "public"
// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = "unlocked"
VisibilityNone Visibility = 1
// VisibilityPublic means this status will
// be visible to everyone on all timelines.
VisibilityPublic Visibility = 2
// VisibilityUnlocked means this status will be visible to everyone,
// but will only show on home timeline to followers, and in lists.
VisibilityUnlocked Visibility = 3
// VisibilityFollowersOnly means this status is viewable to followers only.
VisibilityFollowersOnly Visibility = "followers_only"
// VisibilityMutualsOnly means this status is visible to mutual followers only.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect means this status is visible only to mentioned recipients.
VisibilityDirect Visibility = "direct"
VisibilityFollowersOnly Visibility = 4
// VisibilityMutualsOnly means this status
// is visible to mutual followers only.
VisibilityMutualsOnly Visibility = 5
// VisibilityDirect means this status is
// visible only to mentioned recipients.
VisibilityDirect Visibility = 6
// VisibilityDefault is used when no other setting can be found.
VisibilityDefault Visibility = VisibilityUnlocked
)
// String returns a stringified, frontend API compatible form of Visibility.
func (v Visibility) String() string {
switch v {
case VisibilityNone:
return "none"
case VisibilityPublic:
return "public"
case VisibilityUnlocked:
return "unlocked"
case VisibilityFollowersOnly:
return "followers_only"
case VisibilityMutualsOnly:
return "mutuals_only"
case VisibilityDirect:
return "direct"
default:
panic("invalid visibility")
}
}
// Content models the simple string content
// of a status along with its ContentMap,
// which contains content entries keyed by

View file

@ -40,7 +40,7 @@ func (p *Processor) AccountAction(
return "", gtserror.NewErrorInternalError(err)
}
switch gtsmodel.NewAdminActionType(request.Type) {
switch gtsmodel.ParseAdminActionType(request.Type) {
case gtsmodel.AdminActionSuspend:
return p.accountActionSuspend(ctx, adminAcct, targetAcct, request.Text)

View file

@ -43,7 +43,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
if *form.Irreversible {
filter.Action = gtsmodel.FilterActionHide
}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
for _, context := range form.Context {

View file

@ -67,7 +67,7 @@ func (p *Processor) Update(
action = gtsmodel.FilterActionHide
}
expiresAt := time.Time{}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
expiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
contextHome := false

View file

@ -41,7 +41,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
Title: form.Title,
Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction),
}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
for _, context := range form.Context {

View file

@ -21,8 +21,6 @@ import (
"context"
"errors"
"fmt"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -30,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"time"
)
// Update an existing filter for the given account, using the provided parameters.
@ -68,10 +67,16 @@ func (p *Processor) Update(
filterColumns = append(filterColumns, "action")
filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction)
}
// TODO: (Vyr) is it possible to unset a filter expiration with this API?
if form.ExpiresIn != nil {
expiresIn := *form.ExpiresIn
filterColumns = append(filterColumns, "expires_at")
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
if expiresIn == 0 {
// Unset the expiration date.
filter.ExpiresAt = time.Time{}
} else {
// Update the expiration date.
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(expiresIn))
}
}
if form.Context != nil {
filterColumns = append(filterColumns,

View file

@ -227,6 +227,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...)
}
// validate & update site custom css if it's set on the form
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.InstanceCustomCSS(customCSS); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
instance.CustomCSS = text.SanitizeToPlaintext(customCSS)
columns = append(columns, []string{"custom_css"}...)
}
// Validate & update site
// terms if set on the form.
if form.Terms != nil {

View file

@ -46,7 +46,7 @@ func (p *Processor) PreferencesGet(ctx context.Context, accountID string) (*apim
func mastoPrefVisibility(vis gtsmodel.Visibility) string {
switch vis {
case gtsmodel.VisibilityPublic, gtsmodel.VisibilityDirect:
return string(vis)
return vis.String()
case gtsmodel.VisibilityUnlocked:
return "unlisted"
default:

View file

@ -85,25 +85,8 @@ func (p *Processor) Create(
PendingApproval: util.Ptr(false),
}
if form.Poll != nil {
// Update the status AS type to "Question".
status.ActivityStreamsType = ap.ActivityQuestion
// Create new poll for status from form.
secs := time.Duration(form.Poll.ExpiresIn)
status.Poll = &gtsmodel.Poll{
ID: id.NewULID(),
Multiple: &form.Poll.Multiple,
HideCounts: &form.Poll.HideTotals,
Options: form.Poll.Options,
StatusID: statusID,
Status: status,
ExpiresAt: now.Add(secs * time.Second),
}
// Set poll ID on the status.
status.PollID = status.Poll.ID
}
// Process any attached poll.
p.processPoll(status, form.Poll)
// Check + attach in-reply-to status.
if errWithCode := p.processInReplyTo(ctx,
@ -153,6 +136,14 @@ func (p *Processor) Create(
return nil, gtserror.NewErrorInternalError(err)
}
if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
// Now that the status is inserted, and side effects queued,
// attempt to schedule an expiry handler for the status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// send it back to the client API worker for async side-effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
@ -161,14 +152,6 @@ func (p *Processor) Create(
Origin: requester,
})
if status.Poll != nil {
// Now that the status is inserted, and side effects queued,
// attempt to schedule an expiry handler for the status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// If the new status replies to a status that
// replies to us, use our reply as an implicit
// accept of any pending interaction.
@ -189,6 +172,43 @@ func (p *Processor) Create(
return p.c.GetAPIStatus(ctx, requester, status)
}
func (p *Processor) processPoll(status *gtsmodel.Status, poll *apimodel.PollRequest) {
if poll == nil {
// No poll set.
// Nothing to do.
return
}
var expiresAt time.Time
// Now will have been set
// as the status creation.
now := status.CreatedAt
// Update the status AS type to "Question".
status.ActivityStreamsType = ap.ActivityQuestion
// Set an expiry time if one given.
if in := poll.ExpiresIn; in > 0 {
expiresIn := time.Duration(in)
expiresAt = now.Add(expiresIn * time.Second)
}
// Create new poll for status.
status.Poll = &gtsmodel.Poll{
ID: id.NewULID(),
Multiple: &poll.Multiple,
HideCounts: &poll.HideTotals,
Options: poll.Options,
StatusID: status.ID,
Status: status,
ExpiresAt: expiresAt,
}
// Set poll ID on the status.
status.PollID = status.Poll.ID
}
func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status, inReplyToID string) gtserror.WithCode {
if inReplyToID == "" {
// Not a reply.
@ -372,7 +392,7 @@ func (p *Processor) processVisibility(
// Fall back to account default, set
// this back on the form for later use.
case accountDefaultVis != "":
case accountDefaultVis != 0:
status.Visibility = accountDefaultVis
form.Visibility = p.converter.VisToAPIVis(ctx, accountDefaultVis)

View file

@ -21,6 +21,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -31,26 +32,21 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) NotificationsGet(
ctx context.Context,
authed *oauth.Auth,
maxID string,
sinceID string,
minID string,
limit int,
types []string,
excludeTypes []string,
page *paging.Page,
types []gtsmodel.NotificationType,
excludeTypes []gtsmodel.NotificationType,
) (*apimodel.PageableResponse, gtserror.WithCode) {
notifs, err := p.state.DB.GetAccountNotifications(
ctx,
authed.Account.ID,
maxID,
sinceID,
minID,
limit,
page,
types,
excludeTypes,
)
@ -78,22 +74,15 @@ func (p *Processor) NotificationsGet(
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
var (
items = make([]interface{}, 0, count)
nextMaxIDValue string
prevMinIDValue string
items = make([]interface{}, 0, count)
// Get the lowest and highest
// ID values, used for paging.
lo = notifs[count-1].ID
hi = notifs[0].ID
)
for i, n := range notifs {
// Set next + prev values before filtering and API
// converting, so caller can still page properly.
if i == count-1 {
nextMaxIDValue = n.ID
}
if i == 0 {
prevMinIDValue = n.ID
}
for _, n := range notifs {
visible, err := p.notifVisible(ctx, n, authed.Account)
if err != nil {
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err)
@ -115,13 +104,22 @@ func (p *Processor) NotificationsGet(
items = append(items, item)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "api/v1/notifications",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
// Build type query string.
query := make(url.Values)
for _, typ := range types {
query.Add("types[]", typ.String())
}
for _, typ := range excludeTypes {
query.Add("exclude_types[]", typ.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/notifications",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Account, targetNotifID string) (*apimodel.Notification, gtserror.WithCode) {

View file

@ -217,18 +217,23 @@ func (f *federate) CreatePollVote(ctx context.Context, poll *gtsmodel.Poll, vote
return err
}
// Convert vote to AS Create with vote choices as Objects.
create, err := f.converter.PollVoteToASCreate(ctx, vote)
// Convert vote to AS Creates with vote choices as Objects.
creates, err := f.converter.PollVoteToASCreates(ctx, vote)
if err != nil {
return gtserror.Newf("error converting to notes: %w", err)
}
// Send the Create via the Actor's outbox.
if _, err := f.FederatingActor().Send(ctx, outboxIRI, create); err != nil {
return gtserror.Newf("error sending Create activity via outbox %s: %w", outboxIRI, err)
var errs gtserror.MultiError
// Send each create activity.
actor := f.FederatingActor()
for _, create := range creates {
if _, err := actor.Send(ctx, outboxIRI, create); err != nil {
errs.Appendf("error sending Create activity via outbox %s: %w", outboxIRI, err)
}
}
return nil
return errs.Combine()
}
func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) error {

View file

@ -542,7 +542,7 @@ func getNotifyLockURI(
) string {
builder := strings.Builder{}
builder.WriteString("notification:?")
builder.WriteString("type=" + string(notificationType))
builder.WriteString("type=" + notificationType.String())
builder.WriteString("&target=" + targetAccount.URI)
builder.WriteString("&origin=" + originAccount.URI)
if statusID != "" {

View file

@ -89,7 +89,7 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() {
notifs, err := testStructs.State.DB.GetAccountNotifications(
gtscontext.SetBarebones(ctx),
targetAccount.ID,
"", "", "", 0, nil, nil,
nil, nil, nil,
)
if err != nil {
suite.FailNow(err.Error())

View file

@ -41,7 +41,7 @@ func APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility {
case apimodel.VisibilityNone:
return gtsmodel.VisibilityNone
}
return ""
return 0
}
func APIMarkerNameToMarkerName(m apimodel.MarkerName) gtsmodel.MarkerName {

View file

@ -444,7 +444,7 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat
poll := streams.NewActivityStreamsQuestion()
// Add required status poll data to AS Question.
if err := c.addPollToAS(ctx, s.Poll, poll); err != nil {
if err := c.addPollToAS(s.Poll, poll); err != nil {
return nil, gtserror.Newf("error converting poll: %w", err)
}
@ -708,7 +708,7 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat
return status, nil
}
func (c *Converter) addPollToAS(ctx context.Context, poll *gtsmodel.Poll, dst ap.Pollable) error {
func (c *Converter) addPollToAS(poll *gtsmodel.Poll, dst ap.Pollable) error {
var optionsProp interface {
// the minimum interface for appending AS Notes
// to an AS type options property of some kind.
@ -1701,10 +1701,14 @@ func (c *Converter) ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (voc
// PollVoteToASCreate converts a vote on a poll into a Create
// activity, suitable for federation, with each choice in the
// vote appended as a Note to the Create's Object field.
func (c *Converter) PollVoteToASCreate(
//
// TODO: as soon as other AP server implementations support
// the use of multiple objects in a single create, update this
// to return just the one create event again.
func (c *Converter) PollVoteToASCreates(
ctx context.Context,
vote *gtsmodel.PollVote,
) (vocab.ActivityStreamsCreate, error) {
) ([]vocab.ActivityStreamsCreate, error) {
if len(vote.Choices) == 0 {
panic("no vote.Choices")
}
@ -1743,22 +1747,25 @@ func (c *Converter) PollVoteToASCreate(
return nil, gtserror.Newf("invalid account uri: %w", err)
}
// Allocate Create activity and address 'To' poll author.
create := streams.NewActivityStreamsCreate()
ap.AppendTo(create, pollAuthorIRI)
// Parse each choice to a Note and add it to the list of Creates.
creates := make([]vocab.ActivityStreamsCreate, len(vote.Choices))
for i, choice := range vote.Choices {
// Create ID formatted as: {$voterIRI}/activity#vote/{$statusIRI}.
id := author.URI + "/activity#vote/" + poll.Status.URI
ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(create), id)
// Allocate Create activity and address 'To' poll author.
create := streams.NewActivityStreamsCreate()
ap.AppendTo(create, pollAuthorIRI)
// Set Create actor appropriately.
ap.AppendActorIRIs(create, authorIRI)
// Create ID formatted as: {$voterIRI}/activity#vote{$index}/{$statusIRI}.
createID := fmt.Sprintf("%s/activity#vote%d/%s", author.URI, i, poll.Status.URI)
ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(create), createID)
// Set publish time for activity.
ap.SetPublished(create, vote.CreatedAt)
// Set Create actor appropriately.
ap.AppendActorIRIs(create, authorIRI)
// Parse each choice to a Note and add it to the Create.
for _, choice := range vote.Choices {
// Set publish time for activity.
ap.SetPublished(create, vote.CreatedAt)
// Allocate new note to hold the vote.
note := streams.NewActivityStreamsNote()
// For AP IRI generate from author URI + poll ID + vote choice.
@ -1775,11 +1782,14 @@ func (c *Converter) PollVoteToASCreate(
ap.AppendInReplyTo(note, statusIRI)
ap.AppendTo(note, pollAuthorIRI)
// Append this note as Create Object.
// Append this note to the Create Object.
appendStatusableToActivity(create, note, false)
// Set create in slice.
creates[i] = create
}
return create, nil
return creates, nil
}
// populateValuesForProp appends the given PolicyValues

View file

@ -1104,43 +1104,55 @@ func (suite *InternalToASTestSuite) TestPinnedStatusesToASOneItem() {
func (suite *InternalToASTestSuite) TestPollVoteToASCreate() {
vote := suite.testPollVotes["remote_account_1_status_2_poll_vote_local_account_1"]
create, err := suite.typeconverter.PollVoteToASCreate(context.Background(), vote)
if err != nil {
suite.FailNow(err.Error())
}
creates, err := suite.typeconverter.PollVoteToASCreates(context.Background(), vote)
suite.NoError(err)
suite.Len(creates, 2)
createI, err := ap.Serialize(create)
createI0, err := ap.Serialize(creates[0])
suite.NoError(err)
bytes, err := json.MarshalIndent(createI, "", " ")
createI1, err := ap.Serialize(creates[1])
suite.NoError(err)
bytes0, err := json.MarshalIndent(createI0, "", " ")
suite.NoError(err)
bytes1, err := json.MarshalIndent(createI1, "", " ")
suite.NoError(err)
suite.Equal(`{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork/activity#vote/http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"object": [
{
"attributedTo": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork#01HEN2R65468ZG657C4ZPHJ4EX/votes/1",
"inReplyTo": "http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"name": "tissues",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Note"
},
{
"attributedTo": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork#01HEN2R65468ZG657C4ZPHJ4EX/votes/2",
"inReplyTo": "http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"name": "financial times",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Note"
}
],
"id": "http://localhost:8080/users/the_mighty_zork/activity#vote0/http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"object": {
"attributedTo": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork#01HEN2R65468ZG657C4ZPHJ4EX/votes/1",
"inReplyTo": "http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"name": "tissues",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Note"
},
"published": "2021-09-11T11:45:37+02:00",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Create"
}`, string(bytes))
}`, string(bytes0))
suite.Equal(`{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork/activity#vote1/http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"object": {
"attributedTo": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork#01HEN2R65468ZG657C4ZPHJ4EX/votes/2",
"inReplyTo": "http://fossbros-anonymous.io/users/foss_satan/statuses/01HEN2QRFA8H3C6QPN7RD4KSR6",
"name": "financial times",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Note"
},
"published": "2021-09-11T11:45:37+02:00",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Create"
}`, string(bytes1))
}
func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {

View file

@ -1534,6 +1534,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Title: i.Title,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
ShortDescription: i.ShortDescription,
ShortDescriptionText: i.ShortDescriptionText,
Email: i.ContactEmail,
@ -1674,6 +1675,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
SourceURL: instanceSourceURL,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(),
Rules: c.InstanceRulesToAPIRules(i.Rules),
@ -1862,7 +1864,7 @@ func (c *Converter) NotificationToAPINotification(
return &apimodel.Notification{
ID: n.ID,
Type: string(n.NotificationType),
Type: n.NotificationType.String(),
CreatedAt: util.FormatISO8601(n.CreatedAt),
Account: apiAccount,
Status: apiStatus,

View file

@ -189,6 +189,16 @@ func CustomCSS(customCSS string) error {
return nil
}
func InstanceCustomCSS(customCSS string) error {
maximumCustomCSSLength := config.GetAccountsCustomCSSLength()
if length := len([]rune(customCSS)); length > maximumCustomCSSLength {
return fmt.Errorf("custom_css must be less than %d characters, but submitted custom_css was %d characters", maximumCustomCSSLength, length)
}
return nil
}
// EmojiShortcode just runs the given shortcode through the regular expression
// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 1-30 characters,
// a-zA-Z, numbers, and underscores.

View file

@ -54,7 +54,7 @@ func (m *Module) aboutGETHandler(c *gin.Context) {
Template: "about.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout},
Stylesheets: []string{cssAbout, instanceCustomCSSPath},
Extra: map[string]any{
"showStrap": true,
"blocklistExposed": config.GetInstanceExposeSuspendedWeb(),

View file

@ -127,8 +127,9 @@ func (m *Module) confirmEmailPOSTHandler(c *gin.Context) {
// Serve page informing user that their
// email address is now confirmed.
page := apiutil.WebPage{
Template: "confirmed_email.tmpl",
Instance: instance,
Template: "confirmed_email.tmpl",
Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
Extra: map[string]any{
"email": user.Email,
"username": user.Account.Username,

View file

@ -55,3 +55,22 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS))
}
func (m *Module) instanceCustomCSSGETHandler(c *gin.Context) {
if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
instanceV1, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
instanceCustomCSS := instanceV1.CustomCSS
c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(instanceCustomCSS))
}

View file

@ -67,7 +67,7 @@ func (m *Module) domainBlockListGETHandler(c *gin.Context) {
Template: "domain-blocklist.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA},
Stylesheets: []string{cssFA, instanceCustomCSSPath},
Javascript: []string{jsFrontend},
Extra: map[string]any{"blocklist": domainBlocks},
}

View file

@ -59,7 +59,7 @@ func (m *Module) indexHandler(c *gin.Context) {
Template: "index.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout, cssIndex},
Stylesheets: []string{cssAbout, cssIndex, instanceCustomCSSPath},
Extra: map[string]any{"showStrap": true},
}

View file

@ -132,7 +132,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
}
// Prepare stylesheets for profile.
stylesheets := make([]string, 0, 6)
stylesheets := make([]string, 0, 7)
// Basic profile stylesheets.
stylesheets = append(
@ -142,6 +142,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
cssStatus,
cssThread,
cssProfile,
instanceCustomCSSPath,
}...,
)

View file

@ -53,6 +53,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) {
cssProfile, // Used for rendering stub/fake profiles.
cssStatus, // Used for rendering stub/fake statuses.
cssSettings,
instanceCustomCSSPath,
},
Javascript: []string{jsSettings},
}

View file

@ -126,9 +126,10 @@ func (m *Module) signupPOSTHandler(c *gin.Context) {
// Serve a page informing the
// user that they've signed up.
page := apiutil.WebPage{
Template: "signed-up.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Template: "signed-up.tmpl",
Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{
"email": user.UnconfirmedEmail,
"username": user.Account.Username,

View file

@ -59,7 +59,7 @@ func (m *Module) tagGETHandler(c *gin.Context) {
Template: "tag.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA, cssThread, cssTag},
Stylesheets: []string{cssFA, cssThread, cssTag, instanceCustomCSSPath},
Extra: map[string]any{"tagName": tagName},
}

View file

@ -115,7 +115,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
}
// Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5)
stylesheets := make([]string, 0, 6)
// Basic thread stylesheets.
stylesheets = append(
@ -124,6 +124,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
cssFA,
cssStatus,
cssThread,
instanceCustomCSSPath,
}...,
)

View file

@ -36,20 +36,21 @@ import (
)
const (
confirmEmailPath = "/" + uris.ConfirmEmailPath
profileGroupPath = "/@:username"
statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group
tagsPath = "/tags/:" + apiutil.TagNameKey
customCSSPath = profileGroupPath + "/custom.css"
rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist"
themesPathPrefix = assetsPathPrefix + "/themes"
settingsPathPrefix = "/settings"
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin"
signupPath = "/signup"
confirmEmailPath = "/" + uris.ConfirmEmailPath
profileGroupPath = "/@:username"
statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group
tagsPath = "/tags/:" + apiutil.TagNameKey
customCSSPath = profileGroupPath + "/custom.css"
instanceCustomCSSPath = "/custom.css"
rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist"
themesPathPrefix = assetsPathPrefix + "/themes"
settingsPathPrefix = "/settings"
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin"
signupPath = "/signup"
cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives
@ -114,6 +115,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)
r.AttachHandler(http.MethodGet, instanceCustomCSSPath, m.instanceCustomCSSGETHandler)
r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler)
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler)

View file

@ -5,7 +5,7 @@
set -eu
swagger_cmd() {
go run github.com/go-swagger/go-swagger/cmd/swagger "$@"
go run ./vendor/github.com/go-swagger/go-swagger/cmd/swagger "$@"
}
swagger_spec='docs/api/swagger.yaml'

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