Merge branch 'main' into domain_permission_subscriptions

This commit is contained in:
tobi 2024-12-02 12:25:31 +01:00
commit d11aee04b6
58 changed files with 1437 additions and 1795 deletions

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) - [Finding your way around the code](#finding-your-way-around-the-code)
- [Style / Linting / Formatting](#style--linting--formatting) - [Style / Linting / Formatting](#style--linting--formatting)
- [Testing](#testing) - [Testing](#testing)
- [Standalone Testrig with Semaphore](#standalone-testrig-with-semaphore) - [Standalone Testrig with Pinafore](#standalone-testrig-with-pinafore)
- [Running automated tests](#running-automated-tests) - [Running automated tests](#running-automated-tests)
- [SQLite](#sqlite) - [SQLite](#sqlite)
- [Postgres](#postgres) - [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. 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`. 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 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 ```bash
yarn # install dependencies yarn # install dependencies
yarn run dev 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`. To connect to the testrig, navigate to `http://localhost:4002` and enter your instance name as `localhost:8080`.

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: 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 * [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 * [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. 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. 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. 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

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

View file

@ -2,7 +2,7 @@
## Where's the user interface? ## 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? ## Why aren't my posts showing up on my profile page?

View file

@ -1,6 +1,6 @@
# WebSocket # 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. 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: 此表情的代码,将被实例居民用于选定对应表情。此代码在实例上必须是唯一的。 - description: 此表情的代码,将被实例居民用于选定对应表情。此代码在实例上必须是唯一的。
in: formData in: formData
name: shortcode name: shortcode
pattern: \w{2,30} pattern: \w{1,30}
required: true required: true
type: string type: string
- description: 此表情的 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。 - description: 此表情的 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。
@ -5130,7 +5130,7 @@ paths:
- description: 用于表情的代码,将被实例居民用于选定表情。此代码在实例上必须是唯一的。仅适用于 `copy` 操作类型。 - description: 用于表情的代码,将被实例居民用于选定表情。此代码在实例上必须是唯一的。仅适用于 `copy` 操作类型。
in: formData in: formData
name: shortcode name: shortcode
pattern: \w{2,30} pattern: \w{1,30}
type: string type: string
- description: 此表情的新 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。仅适用于 **本站** 表情。 - description: 此表情的新 png 或 gif 图像。动画 png 也可以!为了确保与其他 fedi 实现的兼容性,默认情况下表情大小限制为 50kb。仅适用于 **本站** 表情。
in: formData in: formData
@ -5639,6 +5639,417 @@ paths:
summary: 吊销实例密钥 summary: 吊销实例密钥
tags: tags:
- admin - 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: /api/v1/admin/email/test:
post: post:
consumes: 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 ```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 # WebSocket
GoToSocial 使用安全的 [WebSocket 协议](https://en.wikipedia.org/wiki/WebSocket)(即 `wss`)来通过客户端应用程序(如 Semaphore实现贴文和通知的实时更新。 GoToSocial 使用安全的 [WebSocket 协议](https://en.wikipedia.org/wiki/WebSocket)(即 `wss`)来通过客户端应用程序(如 Pinafore实现贴文和通知的实时更新。
为了使用此功能,你需要确保配置 GoToSocial 所在的代理允许 WebSocket 连接通过。 为了使用此功能,你需要确保配置 GoToSocial 所在的代理允许 WebSocket 连接通过。

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 229 KiB

4
go.mod
View file

@ -41,7 +41,7 @@ require (
codeberg.org/gruf/go-sched v1.2.4 codeberg.org/gruf/go-sched v1.2.4
codeberg.org/gruf/go-storage v0.2.0 codeberg.org/gruf/go-storage v0.2.0
codeberg.org/gruf/go-structr v0.8.11 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/DmitriyVTitov/size v1.5.0
github.com/KimMachineGun/automemlimit v0.6.1 github.com/KimMachineGun/automemlimit v0.6.1
github.com/buckket/go-blurhash v1.1.0 github.com/buckket/go-blurhash v1.1.0
@ -60,7 +60,7 @@ require (
github.com/k3a/html2text v1.2.1 github.com/k3a/html2text v1.2.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/miekg/dns v1.1.62 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/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.20.3 github.com/ncruces/go-sqlite3 v0.20.3
github.com/oklog/ulid v1.3.1 github.com/oklog/ulid v1.3.1

8
go.sum generated
View file

@ -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-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 h1:I3cQCHpK3fQSXWaaUfksAJRN4+efULiuF11Oi/m8c+o=
codeberg.org/gruf/go-structr v0.8.11/go.mod h1:zkoXVrAnKosh8VFAsbP/Hhs8FmLBjbVVy5w/Ngm8ApM= 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.1 h1:8Pss29AVuvljHAYLnZUyoqJp/8IN1cD3Jz30bJbxme8=
codeberg.org/superseriousbusiness/exif-terminator v0.9.0/go.mod h1:gCWKduudUWFzsnixoMzu0FYVdxHWG+AbXnZ50DqxsUE= 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= 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/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@ -413,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/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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 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.81 h1:SzhMN0TQ6T/xSBu6Nvw3M5M8voM+Ht8RH3hE8S7zxaA=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= 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.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 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= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=

View file

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

View file

@ -33,6 +33,8 @@ type InstanceSettingsUpdateRequest struct {
ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"` ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"`
// Longer description of the instance, max 5,000 chars. HTML formatting accepted. // Longer description of the instance, max 5,000 chars. HTML formatting accepted.
Description *string `form:"description" json:"description" xml:"description"` 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 and conditions of the instance, max 5,000 chars. HTML formatting accepted.
Terms *string `form:"terms" json:"terms" xml:"terms"` Terms *string `form:"terms" json:"terms" xml:"terms"`
// Image to use as the instance thumbnail. // 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. // This should be displayed on the 'about' page for an instance.
Description string `json:"description"` Description string `json:"description"`
// Custom CSS for the instance.
CustomCSS string `json:"custom_css,omitempty"`
// Raw (unparsed) version of description. // Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"` DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance. // A shorter description of the instance.

View file

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

View file

@ -49,6 +49,7 @@
"github.com/uptrace/bun/dialect/pgdialect" "github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/dialect/sqlitedialect" "github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/migrate" "github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
) )
// DBService satisfies the DB interface // 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. // 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. // 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) { 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 var err error
t := strings.ToLower(config.GetDbType())
switch t { switch t := strings.ToLower(config.GetDbType()); t {
case "postgres": case "postgres":
db, err = pgConn(ctx) sqldb, dialect, err = pgConn(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
case "sqlite": case "sqlite":
db, err = sqliteConn(ctx) sqldb, dialect, err = sqliteConn(ctx)
if err != nil { if err != nil {
return nil, err 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) return nil, fmt.Errorf("database type %s not supported for bundb", t)
} }
// Add database query hooks. // perform any pending database migrations: this includes the first
db.AddQueryHook(queryHook{}) // 'migration' on startup which just creates necessary db tables.
if config.GetTracingEnabled() { //
db.AddQueryHook(tracing.InstrumentBun()) // 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,
if config.GetMetricsEnabled() { // and we still maintain lots of old model versions in the migrations.
db.AddQueryHook(metrics.InstrumentBun()) if err := doMigration(ctx, bunDB(sqldb, dialect)); err != nil {
}
// 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 {
return nil, fmt.Errorf("db migration error: %s", err) 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{ ps := &DBService{
Account: &accountDB{ Account: &accountDB{
db: db, db: db,
@ -319,17 +306,47 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
return ps, nil 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 opts, err := deriveBunDBPGOptions() //nolint:contextcheck
if err != nil { 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) cfg := stdlib.RegisterConnConfig(opts)
sqldb, err := sql.Open("pgx-gts", cfg) sqldb, err := sql.Open("pgx-gts", cfg)
if err != nil { 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: // 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.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 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 // ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil { if err := sqldb.PingContext(ctx); err != nil {
return nil, fmt.Errorf("postgres ping: %w", err) return nil, nil, fmt.Errorf("postgres ping: %w", err)
} }
log.Info(ctx, "connected to POSTGRES database") 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 // validate db address has actually been set
address := config.GetDbAddress() address := config.GetDbAddress()
if address == "" { 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. // Build SQLite connection address with prefs.
@ -363,7 +378,7 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
// Open new DB instance // Open new DB instance
sqldb, err := sql.Open("sqlite-gts", address) sqldb, err := sql.Open("sqlite-gts", address)
if err != nil { 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: // Tune db connections for sqlite, see:
@ -379,16 +394,14 @@ func sqliteConn(ctx context.Context) (*bun.DB, error) {
sqldb.SetConnMaxLifetime(5 * time.Minute) sqldb.SetConnMaxLifetime(5 * time.Minute)
} }
db := bun.NewDB(sqldb, sqlitedialect.New())
// ping to check the db is there and listening // ping to check the db is there and listening
if err := db.PingContext(ctx); err != nil { if err := sqldb.PingContext(ctx); err != nil {
return nil, fmt.Errorf("sqlite ping: %w", err) return nil, nil, fmt.Errorf("sqlite ping: %w", err)
} }
log.Infof(ctx, "connected to SQLITE database with address %s", address) log.Infof(ctx, "connected to SQLITE database with address %s", address)
return db, nil return sqldb, func() schema.Dialect { return sqlitedialect.New() }, nil
} }
/* /*
@ -517,15 +530,12 @@ func buildSQLiteAddress(addr string) (string, bool) {
// //
// - SQLite by itself supports setting a subset of its configuration options // - SQLite by itself supports setting a subset of its configuration options
// via URI query arguments in the connection. Namely `mode` and `cache`. // via URI query arguments in the connection. Namely `mode` and `cache`.
// This is the same situation for the directly transpiled C->Go code in // This is the same situation for our supported SQLite implementations.
// modernc.org/sqlite, i.e. modernc.org/sqlite/lib, NOT the Go SQL driver.
// //
// - `modernc.org/sqlite` has a "shim" around it to allow the directly // - Both implementations have a "shim" around them in the form of a
// transpiled C code to be usable with a more native Go API. This is in // `database/sql/driver.Driver{}` implementation.
// the form of a `database/sql/driver.Driver{}` implementation that calls
// through to the transpiled C code.
// //
// - 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` // configuration options via query arguments, through using a special `_pragma`
// query key that specifies SQLite PRAGMAs to set upon opening each connection. // 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. // As such you will see below that most config is set with the `_pragma` key.
@ -551,12 +561,6 @@ func buildSQLiteAddress(addr string) (string, bool) {
// reached. And for whatever reason (:shrug:) SQLite is very particular about // reached. And for whatever reason (:shrug:) SQLite is very particular about
// setting this BEFORE the `journal_mode` is set, otherwise you can end up // 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. // 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 // Drop anything fancy from DB address
addr = strings.Split(addr, "?")[0] // drop any provided query strings addr = strings.Split(addr, "?")[0] // drop any provided query strings

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

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

View file

@ -227,6 +227,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...) 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 // Validate & update site
// terms if set on the form. // terms if set on the form.
if form.Terms != nil { if form.Terms != nil {

View file

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

View file

@ -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 // PollVoteToASCreate converts a vote on a poll into a Create
// activity, suitable for federation, with each choice in the // activity, suitable for federation, with each choice in the
// vote appended as a Note to the Create's Object field. // 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, ctx context.Context,
vote *gtsmodel.PollVote, vote *gtsmodel.PollVote,
) (vocab.ActivityStreamsCreate, error) { ) ([]vocab.ActivityStreamsCreate, error) {
if len(vote.Choices) == 0 { if len(vote.Choices) == 0 {
panic("no vote.Choices") panic("no vote.Choices")
} }
@ -1743,13 +1747,17 @@ func (c *Converter) PollVoteToASCreate(
return nil, gtserror.Newf("invalid account uri: %w", err) return nil, gtserror.Newf("invalid account uri: %w", err)
} }
// 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 {
// Allocate Create activity and address 'To' poll author. // Allocate Create activity and address 'To' poll author.
create := streams.NewActivityStreamsCreate() create := streams.NewActivityStreamsCreate()
ap.AppendTo(create, pollAuthorIRI) ap.AppendTo(create, pollAuthorIRI)
// Create ID formatted as: {$voterIRI}/activity#vote/{$statusIRI}. // Create ID formatted as: {$voterIRI}/activity#vote{$index}/{$statusIRI}.
id := author.URI + "/activity#vote/" + poll.Status.URI createID := fmt.Sprintf("%s/activity#vote%d/%s", author.URI, i, poll.Status.URI)
ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(create), id) ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(create), createID)
// Set Create actor appropriately. // Set Create actor appropriately.
ap.AppendActorIRIs(create, authorIRI) ap.AppendActorIRIs(create, authorIRI)
@ -1757,8 +1765,7 @@ func (c *Converter) PollVoteToASCreate(
// Set publish time for activity. // Set publish time for activity.
ap.SetPublished(create, vote.CreatedAt) ap.SetPublished(create, vote.CreatedAt)
// Parse each choice to a Note and add it to the Create. // Allocate new note to hold the vote.
for _, choice := range vote.Choices {
note := streams.NewActivityStreamsNote() note := streams.NewActivityStreamsNote()
// For AP IRI generate from author URI + poll ID + vote choice. // For AP IRI generate from author URI + poll ID + vote choice.
@ -1775,11 +1782,14 @@ func (c *Converter) PollVoteToASCreate(
ap.AppendInReplyTo(note, statusIRI) ap.AppendInReplyTo(note, statusIRI)
ap.AppendTo(note, pollAuthorIRI) ap.AppendTo(note, pollAuthorIRI)
// Append this note as Create Object. // Append this note to the Create Object.
appendStatusableToActivity(create, note, false) appendStatusableToActivity(create, note, false)
// Set create in slice.
creates[i] = create
} }
return create, nil return creates, nil
} }
// populateValuesForProp appends the given PolicyValues // populateValuesForProp appends the given PolicyValues

View file

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

View file

@ -1534,6 +1534,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Title: i.Title, Title: i.Title,
Description: i.Description, Description: i.Description,
DescriptionText: i.DescriptionText, DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
ShortDescription: i.ShortDescription, ShortDescription: i.ShortDescription,
ShortDescriptionText: i.ShortDescriptionText, ShortDescriptionText: i.ShortDescriptionText,
Email: i.ContactEmail, Email: i.ContactEmail,
@ -1674,6 +1675,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
SourceURL: instanceSourceURL, SourceURL: instanceSourceURL,
Description: i.Description, Description: i.Description,
DescriptionText: i.DescriptionText, DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(), Languages: config.GetInstanceLanguages().TagStrs(),
Rules: c.InstanceRulesToAPIRules(i.Rules), Rules: c.InstanceRulesToAPIRules(i.Rules),

View file

@ -189,6 +189,16 @@ func CustomCSS(customCSS string) error {
return nil 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 // 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, // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 1-30 characters,
// a-zA-Z, numbers, and underscores. // a-zA-Z, numbers, and underscores.

View file

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

View file

@ -129,6 +129,7 @@ func (m *Module) confirmEmailPOSTHandler(c *gin.Context) {
page := apiutil.WebPage{ page := apiutil.WebPage{
Template: "confirmed_email.tmpl", Template: "confirmed_email.tmpl",
Instance: instance, Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
Extra: map[string]any{ Extra: map[string]any{
"email": user.Email, "email": user.Email,
"username": user.Account.Username, "username": user.Account.Username,

View file

@ -55,3 +55,22 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
c.Header(cacheControlHeader, cacheControlNoCache) c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS)) 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", Template: "domain-blocklist.tmpl",
Instance: instance, Instance: instance,
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA}, Stylesheets: []string{cssFA, instanceCustomCSSPath},
Javascript: []string{jsFrontend}, Javascript: []string{jsFrontend},
Extra: map[string]any{"blocklist": domainBlocks}, Extra: map[string]any{"blocklist": domainBlocks},
} }

View file

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

View file

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

View file

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

View file

@ -128,6 +128,7 @@ func (m *Module) signupPOSTHandler(c *gin.Context) {
page := apiutil.WebPage{ page := apiutil.WebPage{
Template: "signed-up.tmpl", Template: "signed-up.tmpl",
Instance: instance, Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
OGMeta: apiutil.OGBase(instance), OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{ Extra: map[string]any{
"email": user.UnconfirmedEmail, "email": user.UnconfirmedEmail,

View file

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

View file

@ -115,7 +115,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
} }
// Prepare stylesheets for thread. // Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5) stylesheets := make([]string, 0, 6)
// Basic thread stylesheets. // Basic thread stylesheets.
stylesheets = append( stylesheets = append(
@ -131,6 +131,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
if theme := targetAccount.Theme; theme != "" { if theme := targetAccount.Theme; theme != "" {
stylesheets = append( stylesheets = append(
stylesheets, stylesheets,
instanceCustomCSSPath,
themesPathPrefix+"/"+theme, themesPathPrefix+"/"+theme,
) )
} }

View file

@ -41,6 +41,7 @@
statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group
tagsPath = "/tags/:" + apiutil.TagNameKey tagsPath = "/tags/:" + apiutil.TagNameKey
customCSSPath = profileGroupPath + "/custom.css" customCSSPath = profileGroupPath + "/custom.css"
instanceCustomCSSPath = "/custom.css"
rssFeedPath = profileGroupPath + "/feed.rss" rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets" assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist" distPathPrefix = assetsPathPrefix + "/dist"
@ -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, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) 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, rssFeedPath, m.rssFeedGETHandler)
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler) r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler)

View file

@ -109,17 +109,17 @@ func (v *jpegVisitor) writeSegment(s *jpegstructure.Segment) error {
sizeLen, found := markerLen[s.MarkerId] sizeLen, found := markerLen[s.MarkerId]
if !found || sizeLen == 2 { if !found || sizeLen == 2 {
sizeLen = 2 l := uint16(len(s.Data) + 2)
l := uint16(len(s.Data) + sizeLen) b := make([]byte, 2)
binary.BigEndian.PutUint16(b, l)
if err := binary.Write(w, binary.BigEndian, &l); err != nil { if _, err := w.Write(b); err != nil {
return err return err
} }
} else if sizeLen == 4 { } else if sizeLen == 4 {
l := uint32(len(s.Data) + sizeLen) l := uint32(len(s.Data) + 4)
b := make([]byte, 4)
if err := binary.Write(w, binary.BigEndian, &l); err != nil { binary.BigEndian.PutUint32(b, l)
if _, err := w.Write(b); err != nil {
return err return err
} }
} else if sizeLen != 0 { } else if sizeLen != 0 {

View file

@ -25,17 +25,16 @@
) )
const ( const (
riffHeaderSize = 4 * 3 riffHeader = "RIFF"
webpHeader = "WEBP"
exifFourcc = "EXIF"
xmpFourcc = "XMP "
) )
var ( var (
riffHeader = [4]byte{'R', 'I', 'F', 'F'}
webpHeader = [4]byte{'W', 'E', 'B', 'P'}
exifFourcc = [4]byte{'E', 'X', 'I', 'F'}
xmpFourcc = [4]byte{'X', 'M', 'P', ' '}
errNoRiffHeader = errors.New("no RIFF header") errNoRiffHeader = errors.New("no RIFF header")
errNoWebpHeader = errors.New("not a WEBP file") errNoWebpHeader = errors.New("not a WEBP file")
errInvalidChunk = errors.New("invalid chunk")
) )
type webpVisitor struct { type webpVisitor struct {
@ -43,59 +42,68 @@ type webpVisitor struct {
doneHeader bool doneHeader bool
} }
func fourCC(b []byte) [4]byte {
return [4]byte{b[0], b[1], b[2], b[3]}
}
func (v *webpVisitor) split(data []byte, atEOF bool) (advance int, token []byte, err error) { func (v *webpVisitor) split(data []byte, atEOF bool) (advance int, token []byte, err error) {
// parse/write the header first // parse/write the header first
if !v.doneHeader { if !v.doneHeader {
if len(data) < riffHeaderSize {
// need the full header // const rifHeaderSize = 12
if len(data) < 12 {
if atEOF {
err = errNoRiffHeader
}
return return
} }
if fourCC(data) != riffHeader {
if string(data[:4]) != riffHeader {
err = errNoRiffHeader err = errNoRiffHeader
return return
} }
if fourCC(data[8:]) != webpHeader {
if string(data[8:12]) != webpHeader {
err = errNoWebpHeader err = errNoWebpHeader
return return
} }
if _, err = v.writer.Write(data[:riffHeaderSize]); err != nil {
if _, err = v.writer.Write(data[:12]); err != nil {
return return
} }
advance += riffHeaderSize
data = data[riffHeaderSize:] advance += 12
data = data[12:]
v.doneHeader = true v.doneHeader = true
} }
// need enough for fourcc and size for {
// need enough for
// fourcc and size
if len(data) < 8 { if len(data) < 8 {
return return
} }
size := int64(binary.LittleEndian.Uint32(data[4:])) size := int64(binary.LittleEndian.Uint32(data[4:]))
if (size & 1) != 0 { if (size & 1) != 0 {
// odd chunk size - extra padding byte // odd chunk size:
// extra padding byte
size++ size++
} }
// wait until there is enough // wait until there is enough
if int64(len(data)-8) < size { if int64(len(data)) < 8+size {
return return
} }
fourcc := fourCC(data)
rawChunkData := data[8 : 8+size]
if fourcc == exifFourcc || fourcc == xmpFourcc {
// replace exif/xmp with blank // replace exif/xmp with blank
rawChunkData = make([]byte, size) switch string(data[:4]) {
} case exifFourcc, xmpFourcc:
clear(data[8 : 8+size])
if _, err = v.writer.Write(data[:8]); err == nil {
if _, err = v.writer.Write(rawChunkData); err == nil {
advance += 8 + int(size)
}
} }
if _, err = v.writer.Write(data[:8+size]); err != nil {
return return
} }
advance += 8 + int(size)
data = data[8+size:]
}
}

View file

@ -0,0 +1,78 @@
/*
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
* Copyright 2015-2024 MinIO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package minio
import (
"bytes"
"context"
"io"
"net/http"
"github.com/goccy/go-json"
"github.com/minio/minio-go/v7/pkg/s3utils"
)
// PromptObject performs language model inference with the prompt and referenced object as context.
// Inference is performed using a Lambda handler that can process the prompt and object.
// Currently, this functionality is limited to certain MinIO servers.
func (c *Client) PromptObject(ctx context.Context, bucketName, objectName, prompt string, opts PromptObjectOptions) (io.ReadCloser, error) {
// Input validation.
if err := s3utils.CheckValidBucketName(bucketName); err != nil {
return nil, ErrorResponse{
StatusCode: http.StatusBadRequest,
Code: "InvalidBucketName",
Message: err.Error(),
}
}
if err := s3utils.CheckValidObjectName(objectName); err != nil {
return nil, ErrorResponse{
StatusCode: http.StatusBadRequest,
Code: "XMinioInvalidObjectName",
Message: err.Error(),
}
}
opts.AddLambdaArnToReqParams(opts.LambdaArn)
opts.SetHeader("Content-Type", "application/json")
opts.AddPromptArg("prompt", prompt)
promptReqBytes, err := json.Marshal(opts.PromptArgs)
if err != nil {
return nil, err
}
// Execute POST on bucket/object.
resp, err := c.executeMethod(ctx, http.MethodPost, requestMetadata{
bucketName: bucketName,
objectName: objectName,
queryValues: opts.toQueryValues(),
customHeader: opts.Header(),
contentSHA256Hex: sum256Hex(promptReqBytes),
contentBody: bytes.NewReader(promptReqBytes),
contentLength: int64(len(promptReqBytes)),
})
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer closeResponse(resp)
return nil, httpRespToErrorResponse(resp, bucketName, objectName)
}
return resp.Body, nil
}

View file

@ -0,0 +1,84 @@
/*
* MinIO Go Library for Amazon S3 Compatible Cloud Storage
* Copyright 2015-2024 MinIO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package minio
import (
"net/http"
"net/url"
)
// PromptObjectOptions provides options to PromptObject call.
// LambdaArn is the ARN of the Prompt Lambda to be invoked.
// PromptArgs is a map of key-value pairs to be passed to the inference action on the Prompt Lambda.
// "prompt" is a reserved key and should not be used as a key in PromptArgs.
type PromptObjectOptions struct {
LambdaArn string
PromptArgs map[string]any
headers map[string]string
reqParams url.Values
}
// Header returns the http.Header representation of the POST options.
func (o PromptObjectOptions) Header() http.Header {
headers := make(http.Header, len(o.headers))
for k, v := range o.headers {
headers.Set(k, v)
}
return headers
}
// AddPromptArg Add a key value pair to the prompt arguments where the key is a string and
// the value is a JSON serializable.
func (o *PromptObjectOptions) AddPromptArg(key string, value any) {
if o.PromptArgs == nil {
o.PromptArgs = make(map[string]any)
}
o.PromptArgs[key] = value
}
// AddLambdaArnToReqParams adds the lambdaArn to the request query string parameters.
func (o *PromptObjectOptions) AddLambdaArnToReqParams(lambdaArn string) {
if o.reqParams == nil {
o.reqParams = make(url.Values)
}
o.reqParams.Add("lambdaArn", lambdaArn)
}
// SetHeader adds a key value pair to the options. The
// key-value pair will be part of the HTTP POST request
// headers.
func (o *PromptObjectOptions) SetHeader(key, value string) {
if o.headers == nil {
o.headers = make(map[string]string)
}
o.headers[http.CanonicalHeaderKey(key)] = value
}
// toQueryValues - Convert the reqParams in Options to query string parameters.
func (o *PromptObjectOptions) toQueryValues() url.Values {
urlValues := make(url.Values)
if o.reqParams != nil {
for key, values := range o.reqParams {
for _, value := range values {
urlValues.Add(key, value)
}
}
}
return urlValues
}

View file

@ -85,7 +85,10 @@ func (c *Client) PutObjectFanOut(ctx context.Context, bucket string, fanOutData
policy.SetEncryption(fanOutReq.SSE) policy.SetEncryption(fanOutReq.SSE)
// Set checksum headers if any. // Set checksum headers if any.
policy.SetChecksum(fanOutReq.Checksum) err := policy.SetChecksum(fanOutReq.Checksum)
if err != nil {
return nil, err
}
url, formData, err := c.PresignedPostPolicy(ctx, policy) url, formData, err := c.PresignedPostPolicy(ctx, policy)
if err != nil { if err != nil {

View file

@ -133,7 +133,7 @@ type Options struct {
// Global constants. // Global constants.
const ( const (
libraryName = "minio-go" libraryName = "minio-go"
libraryVersion = "v7.0.80" libraryVersion = "v7.0.81"
) )
// User Agent should always following the below style. // User Agent should always following the below style.

File diff suppressed because it is too large Load diff

View file

@ -60,6 +60,7 @@ type WebIdentityResult struct {
type WebIdentityToken struct { type WebIdentityToken struct {
Token string Token string
AccessToken string AccessToken string
RefreshToken string
Expiry int Expiry int
} }

View file

@ -85,7 +85,7 @@ func (p *PostPolicy) SetExpires(t time.Time) error {
// SetKey - Sets an object name for the policy based upload. // SetKey - Sets an object name for the policy based upload.
func (p *PostPolicy) SetKey(key string) error { func (p *PostPolicy) SetKey(key string) error {
if strings.TrimSpace(key) == "" || key == "" { if strings.TrimSpace(key) == "" {
return errInvalidArgument("Object name is empty.") return errInvalidArgument("Object name is empty.")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -118,7 +118,7 @@ func (p *PostPolicy) SetKeyStartsWith(keyStartsWith string) error {
// SetBucket - Sets bucket at which objects will be uploaded to. // SetBucket - Sets bucket at which objects will be uploaded to.
func (p *PostPolicy) SetBucket(bucketName string) error { func (p *PostPolicy) SetBucket(bucketName string) error {
if strings.TrimSpace(bucketName) == "" || bucketName == "" { if strings.TrimSpace(bucketName) == "" {
return errInvalidArgument("Bucket name is empty.") return errInvalidArgument("Bucket name is empty.")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -135,7 +135,7 @@ func (p *PostPolicy) SetBucket(bucketName string) error {
// SetCondition - Sets condition for credentials, date and algorithm // SetCondition - Sets condition for credentials, date and algorithm
func (p *PostPolicy) SetCondition(matchType, condition, value string) error { func (p *PostPolicy) SetCondition(matchType, condition, value string) error {
if strings.TrimSpace(value) == "" || value == "" { if strings.TrimSpace(value) == "" {
return errInvalidArgument("No value specified for condition") return errInvalidArgument("No value specified for condition")
} }
@ -156,7 +156,7 @@ func (p *PostPolicy) SetCondition(matchType, condition, value string) error {
// SetTagging - Sets tagging for the object for this policy based upload. // SetTagging - Sets tagging for the object for this policy based upload.
func (p *PostPolicy) SetTagging(tagging string) error { func (p *PostPolicy) SetTagging(tagging string) error {
if strings.TrimSpace(tagging) == "" || tagging == "" { if strings.TrimSpace(tagging) == "" {
return errInvalidArgument("No tagging specified.") return errInvalidArgument("No tagging specified.")
} }
_, err := tags.ParseObjectXML(strings.NewReader(tagging)) _, err := tags.ParseObjectXML(strings.NewReader(tagging))
@ -178,7 +178,7 @@ func (p *PostPolicy) SetTagging(tagging string) error {
// SetContentType - Sets content-type of the object for this policy // SetContentType - Sets content-type of the object for this policy
// based upload. // based upload.
func (p *PostPolicy) SetContentType(contentType string) error { func (p *PostPolicy) SetContentType(contentType string) error {
if strings.TrimSpace(contentType) == "" || contentType == "" { if strings.TrimSpace(contentType) == "" {
return errInvalidArgument("No content type specified.") return errInvalidArgument("No content type specified.")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -211,7 +211,7 @@ func (p *PostPolicy) SetContentTypeStartsWith(contentTypeStartsWith string) erro
// SetContentDisposition - Sets content-disposition of the object for this policy // SetContentDisposition - Sets content-disposition of the object for this policy
func (p *PostPolicy) SetContentDisposition(contentDisposition string) error { func (p *PostPolicy) SetContentDisposition(contentDisposition string) error {
if strings.TrimSpace(contentDisposition) == "" || contentDisposition == "" { if strings.TrimSpace(contentDisposition) == "" {
return errInvalidArgument("No content disposition specified.") return errInvalidArgument("No content disposition specified.")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -226,27 +226,44 @@ func (p *PostPolicy) SetContentDisposition(contentDisposition string) error {
return nil return nil
} }
// SetContentEncoding - Sets content-encoding of the object for this policy
func (p *PostPolicy) SetContentEncoding(contentEncoding string) error {
if strings.TrimSpace(contentEncoding) == "" {
return errInvalidArgument("No content encoding specified.")
}
policyCond := policyCondition{
matchType: "eq",
condition: "$Content-Encoding",
value: contentEncoding,
}
if err := p.addNewPolicy(policyCond); err != nil {
return err
}
p.formData["Content-Encoding"] = contentEncoding
return nil
}
// SetContentLengthRange - Set new min and max content length // SetContentLengthRange - Set new min and max content length
// condition for all incoming uploads. // condition for all incoming uploads.
func (p *PostPolicy) SetContentLengthRange(min, max int64) error { func (p *PostPolicy) SetContentLengthRange(minLen, maxLen int64) error {
if min > max { if minLen > maxLen {
return errInvalidArgument("Minimum limit is larger than maximum limit.") return errInvalidArgument("Minimum limit is larger than maximum limit.")
} }
if min < 0 { if minLen < 0 {
return errInvalidArgument("Minimum limit cannot be negative.") return errInvalidArgument("Minimum limit cannot be negative.")
} }
if max <= 0 { if maxLen <= 0 {
return errInvalidArgument("Maximum limit cannot be non-positive.") return errInvalidArgument("Maximum limit cannot be non-positive.")
} }
p.contentLengthRange.min = min p.contentLengthRange.min = minLen
p.contentLengthRange.max = max p.contentLengthRange.max = maxLen
return nil return nil
} }
// SetSuccessActionRedirect - Sets the redirect success url of the object for this policy // SetSuccessActionRedirect - Sets the redirect success url of the object for this policy
// based upload. // based upload.
func (p *PostPolicy) SetSuccessActionRedirect(redirect string) error { func (p *PostPolicy) SetSuccessActionRedirect(redirect string) error {
if strings.TrimSpace(redirect) == "" || redirect == "" { if strings.TrimSpace(redirect) == "" {
return errInvalidArgument("Redirect is empty") return errInvalidArgument("Redirect is empty")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -264,7 +281,7 @@ func (p *PostPolicy) SetSuccessActionRedirect(redirect string) error {
// SetSuccessStatusAction - Sets the status success code of the object for this policy // SetSuccessStatusAction - Sets the status success code of the object for this policy
// based upload. // based upload.
func (p *PostPolicy) SetSuccessStatusAction(status string) error { func (p *PostPolicy) SetSuccessStatusAction(status string) error {
if strings.TrimSpace(status) == "" || status == "" { if strings.TrimSpace(status) == "" {
return errInvalidArgument("Status is empty") return errInvalidArgument("Status is empty")
} }
policyCond := policyCondition{ policyCond := policyCondition{
@ -282,10 +299,10 @@ func (p *PostPolicy) SetSuccessStatusAction(status string) error {
// SetUserMetadata - Set user metadata as a key/value couple. // SetUserMetadata - Set user metadata as a key/value couple.
// Can be retrieved through a HEAD request or an event. // Can be retrieved through a HEAD request or an event.
func (p *PostPolicy) SetUserMetadata(key, value string) error { func (p *PostPolicy) SetUserMetadata(key, value string) error {
if strings.TrimSpace(key) == "" || key == "" { if strings.TrimSpace(key) == "" {
return errInvalidArgument("Key is empty") return errInvalidArgument("Key is empty")
} }
if strings.TrimSpace(value) == "" || value == "" { if strings.TrimSpace(value) == "" {
return errInvalidArgument("Value is empty") return errInvalidArgument("Value is empty")
} }
headerName := fmt.Sprintf("x-amz-meta-%s", key) headerName := fmt.Sprintf("x-amz-meta-%s", key)
@ -304,7 +321,7 @@ func (p *PostPolicy) SetUserMetadata(key, value string) error {
// SetUserMetadataStartsWith - Set how an user metadata should starts with. // SetUserMetadataStartsWith - Set how an user metadata should starts with.
// Can be retrieved through a HEAD request or an event. // Can be retrieved through a HEAD request or an event.
func (p *PostPolicy) SetUserMetadataStartsWith(key, value string) error { func (p *PostPolicy) SetUserMetadataStartsWith(key, value string) error {
if strings.TrimSpace(key) == "" || key == "" { if strings.TrimSpace(key) == "" {
return errInvalidArgument("Key is empty") return errInvalidArgument("Key is empty")
} }
headerName := fmt.Sprintf("x-amz-meta-%s", key) headerName := fmt.Sprintf("x-amz-meta-%s", key)
@ -321,11 +338,29 @@ func (p *PostPolicy) SetUserMetadataStartsWith(key, value string) error {
} }
// SetChecksum sets the checksum of the request. // SetChecksum sets the checksum of the request.
func (p *PostPolicy) SetChecksum(c Checksum) { func (p *PostPolicy) SetChecksum(c Checksum) error {
if c.IsSet() { if c.IsSet() {
p.formData[amzChecksumAlgo] = c.Type.String() p.formData[amzChecksumAlgo] = c.Type.String()
p.formData[c.Type.Key()] = c.Encoded() p.formData[c.Type.Key()] = c.Encoded()
policyCond := policyCondition{
matchType: "eq",
condition: fmt.Sprintf("$%s", amzChecksumAlgo),
value: c.Type.String(),
} }
if err := p.addNewPolicy(policyCond); err != nil {
return err
}
policyCond = policyCondition{
matchType: "eq",
condition: fmt.Sprintf("$%s", c.Type.Key()),
value: c.Encoded(),
}
if err := p.addNewPolicy(policyCond); err != nil {
return err
}
}
return nil
} }
// SetEncryption - sets encryption headers for POST API // SetEncryption - sets encryption headers for POST API

View file

@ -20,7 +20,7 @@
import "time" import "time"
// newRetryTimerContinous creates a timer with exponentially increasing delays forever. // newRetryTimerContinous creates a timer with exponentially increasing delays forever.
func (c *Client) newRetryTimerContinous(unit, cap time.Duration, jitter float64, doneCh chan struct{}) <-chan int { func (c *Client) newRetryTimerContinous(baseSleep, maxSleep time.Duration, jitter float64, doneCh chan struct{}) <-chan int {
attemptCh := make(chan int) attemptCh := make(chan int)
// normalize jitter to the range [0, 1.0] // normalize jitter to the range [0, 1.0]
@ -39,10 +39,10 @@ func (c *Client) newRetryTimerContinous(unit, cap time.Duration, jitter float64,
if attempt > maxAttempt { if attempt > maxAttempt {
attempt = maxAttempt attempt = maxAttempt
} }
// sleep = random_between(0, min(cap, base * 2 ** attempt)) // sleep = random_between(0, min(maxSleep, base * 2 ** attempt))
sleep := unit * time.Duration(1<<uint(attempt)) sleep := baseSleep * time.Duration(1<<uint(attempt))
if sleep > cap { if sleep > maxSleep {
sleep = cap sleep = maxSleep
} }
if jitter != NoJitter { if jitter != NoJitter {
sleep -= time.Duration(c.random.Float64() * float64(sleep) * jitter) sleep -= time.Duration(c.random.Float64() * float64(sleep) * jitter)

View file

@ -45,7 +45,7 @@
// newRetryTimer creates a timer with exponentially increasing // newRetryTimer creates a timer with exponentially increasing
// delays until the maximum retry attempts are reached. // delays until the maximum retry attempts are reached.
func (c *Client) newRetryTimer(ctx context.Context, maxRetry int, unit, cap time.Duration, jitter float64) <-chan int { func (c *Client) newRetryTimer(ctx context.Context, maxRetry int, baseSleep, maxSleep time.Duration, jitter float64) <-chan int {
attemptCh := make(chan int) attemptCh := make(chan int)
// computes the exponential backoff duration according to // computes the exponential backoff duration according to
@ -59,10 +59,10 @@ func (c *Client) newRetryTimer(ctx context.Context, maxRetry int, unit, cap time
jitter = MaxJitter jitter = MaxJitter
} }
// sleep = random_between(0, min(cap, base * 2 ** attempt)) // sleep = random_between(0, min(maxSleep, base * 2 ** attempt))
sleep := unit * time.Duration(1<<uint(attempt)) sleep := baseSleep * time.Duration(1<<uint(attempt))
if sleep > cap { if sleep > maxSleep {
sleep = cap sleep = maxSleep
} }
if jitter != NoJitter { if jitter != NoJitter {
sleep -= time.Duration(c.random.Float64() * float64(sleep) * jitter) sleep -= time.Duration(c.random.Float64() * float64(sleep) * jitter)

4
vendor/modules.txt vendored
View file

@ -66,7 +66,7 @@ codeberg.org/gruf/go-storage/s3
# codeberg.org/gruf/go-structr v0.8.11 # codeberg.org/gruf/go-structr v0.8.11
## explicit; go 1.21 ## explicit; go 1.21
codeberg.org/gruf/go-structr codeberg.org/gruf/go-structr
# codeberg.org/superseriousbusiness/exif-terminator v0.9.0 # codeberg.org/superseriousbusiness/exif-terminator v0.9.1
## explicit; go 1.21 ## explicit; go 1.21
codeberg.org/superseriousbusiness/exif-terminator codeberg.org/superseriousbusiness/exif-terminator
# github.com/DmitriyVTitov/size v1.5.0 # github.com/DmitriyVTitov/size v1.5.0
@ -488,7 +488,7 @@ github.com/miekg/dns
# github.com/minio/md5-simd v1.1.2 # github.com/minio/md5-simd v1.1.2
## explicit; go 1.14 ## explicit; go 1.14
github.com/minio/md5-simd github.com/minio/md5-simd
# github.com/minio/minio-go/v7 v7.0.80 # github.com/minio/minio-go/v7 v7.0.81
## explicit; go 1.22 ## explicit; go 1.22
github.com/minio/minio-go/v7 github.com/minio/minio-go/v7
github.com/minio/minio-go/v7/pkg/cors github.com/minio/minio-go/v7/pkg/cors

View file

@ -0,0 +1,10 @@
/*
theme-title: Blurple (auto)
theme-description: Official blurple theme that adapts to system preferences
*/
/* Default to dark theme */
@import url("blurple-dark.css");
@import url("blurple-light.css") screen and (prefers-color-scheme: light);
@import url("blurple-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -0,0 +1,10 @@
/*
theme-title: Brutalist (auto)
theme-description: Official (Pseudo-)monochrome brutality theme that adapts to system preferences
*/
/* Default to brutalist theme */
@import url("brutalist.css");
@import url("brutalist.css") screen and (prefers-color-scheme: light);
@import url("brutalist-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -0,0 +1,10 @@
/*
theme-title: Solarized (auto)
theme-description: Solarized theme that adapts to system preferences
*/
/* Default to dark theme */
@import url("solarized-dark.css");
@import url("solarized-light.css") screen and (prefers-color-scheme: light);
@import url("solarized-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -25,6 +25,7 @@ export interface InstanceV1 {
description_text?: string; description_text?: string;
short_description: string; short_description: string;
short_description_text?: string; short_description_text?: string;
custom_css: string;
email: string; email: string;
version: string; version: string;
debug?: boolean; debug?: boolean;

View file

@ -66,6 +66,10 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
valueSelector: (s: InstanceV1) => s.description_text, valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less` validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}), }),
customCSS: useTextInput("custom_css", {
source: instance,
valueSelector: (s: InstanceV1) => s.custom_css
}),
terms: useTextInput("terms", { terms: useTextInput("terms", {
source: instance, source: instance,
// Select "raw" text version of parsed field for editing. // Select "raw" text version of parsed field for editing.
@ -191,6 +195,15 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
type="email" type="email"
/> />
<TextArea
field={form.customCSS}
label={"Custom CSS"}
className="monospace"
rows={8}
autoCapitalize="none"
spellCheck="false"
/>
<MutationButton label="Save" result={result} disabled={false} /> <MutationButton label="Save" result={result} disabled={false} />
</form> </form>
); );

View file

@ -29,27 +29,27 @@
<ul class="applist nodot" role="group"> <ul class="applist nodot" role="group">
<li class="applist-entry"> <li class="applist-entry">
<div class="applist-text"> <div class="applist-text">
<p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p> <p><strong>Pinafore</strong> is a web client designed for speed and simplicity.</p>
<a <a
href="https://semaphore.social/" href="https://pinafore.social/"
rel="nofollow noreferrer noopener" rel="nofollow noreferrer noopener"
target="_blank" target="_blank"
> >
Use Semaphore Use Pinafore
</a> </a>
</div> </div>
<svg <svg
role="img" role="img"
aria-labelledby="semaphore-title semaphore-desc" aria-labelledby="pinafore-title pinafore-desc"
class="applist-logo redraw" class="applist-logo redraw"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 146 120" viewBox="0 0 10000 10000"
width="100" width="100"
height="100" height="100"
> >
<title id="semaphore-title">The Semaphore logo</title> <title id="pinafore-title">The Pinafore logo</title>
<desc id="semaphore-desc">A waving flag</desc> <desc id="pinafore-desc">A sailboat</desc>
<path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path> <path d="M9212 5993H5987V823c1053 667 2747 2177 3225 5170zM3100 2690A12240 12240 0 01939 6035h2161zm676 7210h2448a3067 3067 0 003067-3067H5052V627a527 527 0 00-1052 0v6206H709a3067 3067 0 003067 3067z"></path>
</svg> </svg>
</li> </li>
<li class="applist-entry"> <li class="applist-entry">