Waiting feedback and tooltips now share consistent presentation lifecycles, while App shutdown and native-media operation failures stay within their owning session or output. This phase also consolidates the project standards and publishes searchable English/Chinese reader documentation alongside the homepage. Validation: repository hygiene and Markdown links; TypeScript, 971 Web tests and production build; website/documentation build; Go tests, vet and Windows/Linux builds with Windows capture checks; desktop/mobile UI and documentation review. Linux race, container and three-platform packaging checks run in the release pipeline. Physical macOS/Linux capture and broader network coverage remain outside this acceptance. The owner approved v1.4.0 publication. Existing empty-source, Windows URL-handler and public-invitation timeout reports remain tracked; this release makes no claim to resolve those device/network reports. The Windows capture-border request is next-phase work. ## Release notes ### 中文 这是一次以稳定性和交互一致性为重点的功能更新。等待与提示更有反馈,App 启动、原生分享和连接恢复的边界处理得到改善,使用与部署指南也有了统一的文档中心。 #### 新增与改进 - 欢迎语、等待短句和六类可轮换的标签页标题共用一致的显示规则。等待短句从一开始就出现,并在停留时轮换;明确的错误与操作提示保持固定。 - 房间准入等漫画采用更克制的小人物与循环动效。改进 tooltip 的定位与指向,减少对播放器的遮挡;状态、图标和文案保持相同含义。 - 新增中英文文档中心,提供搜索、手机目录、使用教程、问题排查与自部署指南。官网 FAQ 聚焦开始使用前的问题,详细排查统一进入文档中心。 #### 修复 - 使用 `--local` 启动不再清空已保存的 Piik 站点地址。 - 打开系统浏览器不再阻塞 App 的退出流程;改进临时公网邀请、终端和诊断导出在关闭时的清理及错误反馈。 - 单条原生媒体连接的操作失败不再被误判为整个会话的协议错误;重复停止分享保持幂等。 - 修复连接替换、转发停止和异常退出时的部分资源清理问题,避免迟到结果影响当前连接。 - Windows 原生采集中,单个输出的编码错误只结束对应输出;共享采集源本身的错误仍会结束分享。 #### 升级与已知边界 本次保持现有协议版本与配置格式。更新 App 或自托管 Server 后,重新打开页面即可使用对应的新界面。 个别设备的空窗口列表、Windows 浏览器启动异常,以及 Linux 编码输出的故障隔离仍在跟进。macOS/Linux 的物理采集和更广泛的公网网络场景仍需实机验证;本次不宣称这些问题已经解决,也不承诺所有网络都能直连。 ### English This feature update focuses on stability and consistent feedback. Waiting states are easier to follow, App startup and native sharing handle several failure cases more reliably, and the new documentation center brings usage and deployment guides together. #### New and improved - Welcome lines, waiting captions and six groups of rotating tab titles follow one presentation lifecycle. Waiting captions appear immediately and rotate while visible; actionable errors and instructions remain fixed. - Room-admission comics use restrained characters and looping motion. Tooltips point more accurately to their controls and interfere less with the player, with consistent meanings across illustrations, icons and copy. - A new English/Chinese documentation center includes search, mobile navigation, usage guides, troubleshooting and self-hosting. The homepage FAQ covers questions before getting started and links to the documentation for detailed help. #### Fixes - Starting with `--local` no longer clears a saved Piik site address. - Opening the system browser no longer blocks App shutdown. Temporary public invitations, the terminal and diagnostic exports handle cleanup and exit errors more consistently. - An operation failure on one native media connection no longer becomes a protocol failure for the entire session. Repeated stop-sharing requests are safe. - Fixed cleanup cases during connection replacement, relay shutdown and unexpected exits, preventing late results from affecting the current connection. - On Windows, an encoder failure for one native output stops that output only. A failure of the shared capture source still ends sharing. #### Updating and known limits Protocol versions and configuration formats are unchanged. After updating the App or your self-hosted Server, reopen the page to use its updated interface. Empty source lists on some devices, Windows browser-launch failures and Linux output-failure isolation remain under investigation. Physical macOS/Linux capture and broader public-network coverage still need device testing; this update does not claim to resolve those cases or guarantee a direct connection on every network.
640 lines
18 KiB
Go
640 lines
18 KiB
Go
package room
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
// The pure-Go driver supports builds with CGO_ENABLED=0.
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
|
|
)
|
|
|
|
// Database identity and the single current schema are checked before adopting
|
|
// room authority. The application ID is ASCII "SCRN".
|
|
const (
|
|
roomDatabaseApplicationID = 0x5343524e
|
|
roomDatabaseSchemaVersion = 2
|
|
)
|
|
|
|
// roomSchema stores durable authority only, never presence or media state.
|
|
const roomSchema = `
|
|
CREATE TABLE rooms (
|
|
room_id TEXT PRIMARY KEY NOT NULL
|
|
CHECK (room_id GLOB '[1-9][0-9][0-9][0-9]'),
|
|
host_token_digest BLOB NOT NULL
|
|
CHECK (typeof(host_token_digest) = 'blob' AND length(host_token_digest) = 32),
|
|
viewer_grant_digest BLOB NOT NULL
|
|
CHECK (typeof(viewer_grant_digest) = 'blob' AND length(viewer_grant_digest) = 32),
|
|
viewer_authorization_generation TEXT NOT NULL
|
|
CHECK (
|
|
length(viewer_authorization_generation) = 22 AND
|
|
viewer_authorization_generation NOT GLOB '*[^A-Za-z0-9_-]*'
|
|
),
|
|
code_entry_policy TEXT NOT NULL
|
|
CHECK (code_entry_policy IN ('open', 'private')),
|
|
viewer_password_material BLOB NULL
|
|
CHECK (
|
|
viewer_password_material IS NULL OR
|
|
(typeof(viewer_password_material) = 'blob' AND length(viewer_password_material) = 48)
|
|
)
|
|
) STRICT;
|
|
PRAGMA application_id = 1396920910;
|
|
PRAGMA user_version = 2;
|
|
`
|
|
|
|
const selectStoredRooms = `SELECT
|
|
room_id,
|
|
host_token_digest,
|
|
viewer_grant_digest,
|
|
viewer_authorization_generation,
|
|
code_entry_policy,
|
|
viewer_password_material
|
|
FROM rooms
|
|
ORDER BY room_id`
|
|
|
|
const insertStoredRoom = `INSERT INTO rooms (
|
|
room_id,
|
|
host_token_digest,
|
|
viewer_grant_digest,
|
|
viewer_authorization_generation,
|
|
code_entry_policy,
|
|
viewer_password_material
|
|
) VALUES (?, ?, ?, ?, ?, ?)`
|
|
|
|
const deleteRoomStatement = "DELETE FROM rooms WHERE room_id = ? AND host_token_digest = ?"
|
|
|
|
// StoredRoomAuthority excludes transient sessions and presence.
|
|
type StoredRoomAuthority struct {
|
|
RoomID string
|
|
HostTokenDigest []byte
|
|
ViewerGrantDigest []byte
|
|
ViewerAuthorizationGeneration string
|
|
CodeEntryPolicy protocol.CodeEntryPolicy
|
|
ViewerPasswordMaterial []byte
|
|
}
|
|
|
|
// Database is RoomDatabase: the SQLite stable authority. It holds one
|
|
// connection for its lifetime, because `PRAGMA locking_mode = EXCLUSIVE` is a
|
|
// connection property that a recycled pool connection would silently drop.
|
|
type Database struct {
|
|
path string
|
|
db *sql.DB
|
|
conn *sql.Conn
|
|
// inTransaction reproduces DatabaseSync.isTransaction, which the driver
|
|
// does not expose; the ROLLBACK guard depends on it.
|
|
inTransaction bool
|
|
}
|
|
|
|
// NewDatabase is the RoomDatabase constructor.
|
|
func NewDatabase(path string) (*Database, error) {
|
|
if path == "" || path == ":memory:" {
|
|
return nil, errors.New("Room database path must identify a file")
|
|
}
|
|
return &Database{path: path}, nil
|
|
}
|
|
|
|
// Initialize opens the file, takes the exclusive lock, creates or verifies the
|
|
// schema and restores all stored room authority.
|
|
func (d *Database) Initialize() (restored []StoredRoomAuthority, err error) {
|
|
if d.conn != nil {
|
|
return nil, errors.New("Room database is already initialized")
|
|
}
|
|
|
|
// `new DatabaseSync(this.path, { timeout: 0 })`: a lock conflict must fail
|
|
// immediately instead of waiting.
|
|
db, err := sql.Open("sqlite", "file:"+uriPath(d.path)+"?_pragma=busy_timeout(0)")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(0)
|
|
db.SetConnMaxIdleTime(0)
|
|
conn, err := db.Conn(context.Background())
|
|
if err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
d.db, d.conn = db, conn
|
|
defer func() {
|
|
if err != nil {
|
|
d.closeHandles()
|
|
}
|
|
}()
|
|
|
|
// The pragma answers "exclusive" even when another process owns the file;
|
|
// the BEGIN EXCLUSIVE below is what actually takes the lock.
|
|
var lockingMode string
|
|
if err = d.conn.QueryRowContext(
|
|
context.Background(), "PRAGMA main.locking_mode = EXCLUSIVE").Scan(&lockingMode); err != nil {
|
|
return nil, err
|
|
}
|
|
if lockingMode != "exclusive" {
|
|
return nil, errors.New("Room database could not acquire exclusive locking mode")
|
|
}
|
|
if err = d.begin("BEGIN EXCLUSIVE"); err != nil {
|
|
return nil, err
|
|
}
|
|
restored, err = d.recover()
|
|
if err != nil {
|
|
d.rollbackIfNeeded()
|
|
return nil, err
|
|
}
|
|
if err = d.finish("COMMIT"); err != nil {
|
|
d.rollbackIfNeeded()
|
|
return nil, err
|
|
}
|
|
return restored, nil
|
|
}
|
|
|
|
// uriPath is the literal path `new DatabaseSync(this.path)` opened, written for
|
|
// the driver's URI DSN: the driver splits on the first `?` and SQLite decodes
|
|
// `%HH` and stops at `#`, so those three characters are escaped.
|
|
func uriPath(path string) string {
|
|
return strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F").Replace(filepath.ToSlash(path))
|
|
}
|
|
|
|
func (d *Database) recover() ([]StoredRoomAuthority, error) {
|
|
if err := d.ensureExactSchema(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.assertDatabaseIntegrity(); err != nil {
|
|
return nil, err
|
|
}
|
|
return d.readStoredRooms()
|
|
}
|
|
|
|
// InsertRoom is insertRoom.
|
|
func (d *Database) InsertRoom(room StoredRoomAuthority) error {
|
|
if err := assertStoredRoom(room); err != nil {
|
|
return err
|
|
}
|
|
return d.transaction(func() error {
|
|
changes, err := d.exec(insertStoredRoom,
|
|
room.RoomID,
|
|
room.HostTokenDigest,
|
|
room.ViewerGrantDigest,
|
|
room.ViewerAuthorizationGeneration,
|
|
room.CodeEntryPolicy,
|
|
room.ViewerPasswordMaterial)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(changes, "insert")
|
|
})
|
|
}
|
|
|
|
// SetViewerPassword is setViewerPassword; nil material clears the password.
|
|
func (d *Database) SetViewerPassword(
|
|
roomID string, hostTokenDigest, viewerPasswordMaterial []byte,
|
|
) error {
|
|
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
|
return err
|
|
}
|
|
if err := assertPasswordMaterial(viewerPasswordMaterial); err != nil {
|
|
return err
|
|
}
|
|
return d.transaction(func() error {
|
|
changes, err := d.exec(
|
|
`UPDATE rooms
|
|
SET viewer_password_material = ?
|
|
WHERE room_id = ? AND host_token_digest = ?`,
|
|
viewerPasswordMaterial, roomID, hostTokenDigest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(changes, "password update")
|
|
})
|
|
}
|
|
|
|
// SetCodeEntryPolicy is setCodeEntryPolicy.
|
|
func (d *Database) SetCodeEntryPolicy(
|
|
roomID string, hostTokenDigest []byte, codeEntryPolicy protocol.CodeEntryPolicy,
|
|
) error {
|
|
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
|
return err
|
|
}
|
|
if !validCodeEntryPolicy(codeEntryPolicy) {
|
|
return errors.New("Room code-entry policy is invalid")
|
|
}
|
|
return d.transaction(func() error {
|
|
changes, err := d.exec(
|
|
`UPDATE rooms
|
|
SET code_entry_policy = ?
|
|
WHERE room_id = ? AND host_token_digest = ?`,
|
|
codeEntryPolicy, roomID, hostTokenDigest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(changes, "code-entry policy update")
|
|
})
|
|
}
|
|
|
|
// SetViewerGrant is setViewerGrant.
|
|
func (d *Database) SetViewerGrant(
|
|
roomID string, hostTokenDigest, viewerGrantDigest []byte,
|
|
viewerAuthorizationGeneration string,
|
|
) error {
|
|
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
|
return err
|
|
}
|
|
if err := assertDigest(viewerGrantDigest, "Viewer grant"); err != nil {
|
|
return err
|
|
}
|
|
if err := assertAuthorizationGeneration(viewerAuthorizationGeneration); err != nil {
|
|
return err
|
|
}
|
|
return d.transaction(func() error {
|
|
changes, err := d.exec(
|
|
`UPDATE rooms
|
|
SET viewer_grant_digest = ?,
|
|
viewer_authorization_generation = ?
|
|
WHERE room_id = ? AND host_token_digest = ?`,
|
|
viewerGrantDigest, viewerAuthorizationGeneration, roomID, hostTokenDigest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(changes, "Viewer grant update")
|
|
})
|
|
}
|
|
|
|
// DeleteRoom is deleteRoom.
|
|
func (d *Database) DeleteRoom(roomID string, hostTokenDigest []byte) error {
|
|
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
|
return err
|
|
}
|
|
return d.transaction(func() error {
|
|
changes, err := d.exec(deleteRoomStatement, roomID, hostTokenDigest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(changes, "delete")
|
|
})
|
|
}
|
|
|
|
// ReplaceRoom is replaceRoom: the new room and the removal of the old one are
|
|
// one authority transition.
|
|
func (d *Database) ReplaceRoom(
|
|
oldRoomID string, oldHostTokenDigest []byte, replacement StoredRoomAuthority,
|
|
) error {
|
|
if err := assertRoomIdentity(oldRoomID, oldHostTokenDigest); err != nil {
|
|
return err
|
|
}
|
|
if err := assertStoredRoom(replacement); err != nil {
|
|
return err
|
|
}
|
|
if replacement.RoomID == oldRoomID {
|
|
return errors.New("Replacement room ID must be different")
|
|
}
|
|
return d.transaction(func() error {
|
|
inserted, err := d.exec(insertStoredRoom,
|
|
replacement.RoomID,
|
|
replacement.HostTokenDigest,
|
|
replacement.ViewerGrantDigest,
|
|
replacement.ViewerAuthorizationGeneration,
|
|
replacement.CodeEntryPolicy,
|
|
replacement.ViewerPasswordMaterial)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := assertSingleChange(inserted, "replacement insert"); err != nil {
|
|
return err
|
|
}
|
|
deleted, err := d.exec(deleteRoomStatement, oldRoomID, oldHostTokenDigest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return assertSingleChange(deleted, "replacement delete")
|
|
})
|
|
}
|
|
|
|
// Close closes the connection; it is safe on a database that never opened one.
|
|
func (d *Database) Close() error {
|
|
return d.closeHandles()
|
|
}
|
|
|
|
func (d *Database) closeHandles() error {
|
|
conn, db := d.conn, d.db
|
|
d.conn, d.db, d.inTransaction = nil, nil, false
|
|
var connError error
|
|
if conn != nil {
|
|
connError = conn.Close()
|
|
}
|
|
if db != nil {
|
|
if err := db.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return connError
|
|
}
|
|
|
|
func (d *Database) ensureExactSchema() error {
|
|
applicationID, err := d.readPragmaInteger("application_id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
schemaVersion, err := d.readPragmaInteger("user_version")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
schemaObjects, err := d.readSchemaObjects()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if applicationID == 0 && schemaVersion == 0 && len(schemaObjects) == 0 {
|
|
_, err := d.conn.ExecContext(context.Background(), roomSchema)
|
|
return err
|
|
}
|
|
|
|
if applicationID != roomDatabaseApplicationID {
|
|
return errors.New("Room database application identity does not match")
|
|
}
|
|
if schemaVersion != roomDatabaseSchemaVersion {
|
|
return errors.New("Room database schema version does not match")
|
|
}
|
|
if len(schemaObjects) != 1 ||
|
|
schemaObjects[0][0] != "table" || schemaObjects[0][1] != "rooms" {
|
|
return errors.New("Room database schema objects do not match")
|
|
}
|
|
return d.assertExactColumns()
|
|
}
|
|
|
|
func (d *Database) readSchemaObjects() ([][2]string, error) {
|
|
rows, err := d.conn.QueryContext(context.Background(),
|
|
`SELECT type, name
|
|
FROM sqlite_schema
|
|
WHERE name NOT LIKE 'sqlite_%'
|
|
ORDER BY type, name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var objects [][2]string
|
|
for rows.Next() {
|
|
var object [2]string
|
|
if err := rows.Scan(&object[0], &object[1]); err != nil {
|
|
return nil, err
|
|
}
|
|
objects = append(objects, object)
|
|
}
|
|
return objects, rows.Err()
|
|
}
|
|
|
|
func (d *Database) assertDatabaseIntegrity() error {
|
|
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA quick_check")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
var results []string
|
|
for rows.Next() {
|
|
var result string
|
|
if err := rows.Scan(&result); err != nil {
|
|
return err
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if len(results) != 1 || results[0] != "ok" {
|
|
return errors.New("Room database integrity check failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) assertExactColumns() error {
|
|
expected := [...]struct {
|
|
name string
|
|
columnType string
|
|
notNull int
|
|
primaryKey int
|
|
}{
|
|
{"room_id", "TEXT", 1, 1},
|
|
{"host_token_digest", "BLOB", 1, 0},
|
|
{"viewer_grant_digest", "BLOB", 1, 0},
|
|
{"viewer_authorization_generation", "TEXT", 1, 0},
|
|
{"code_entry_policy", "TEXT", 1, 0},
|
|
{"viewer_password_material", "BLOB", 0, 0},
|
|
}
|
|
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA table_info(rooms)")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
index := 0
|
|
for rows.Next() {
|
|
var (
|
|
cid int
|
|
name string
|
|
columnType string
|
|
notNull int
|
|
defaultValue sql.NullString
|
|
primaryKey int
|
|
)
|
|
if err := rows.Scan(
|
|
&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
|
return err
|
|
}
|
|
if index >= len(expected) || name != expected[index].name ||
|
|
columnType != expected[index].columnType ||
|
|
notNull != expected[index].notNull ||
|
|
primaryKey != expected[index].primaryKey {
|
|
return errors.New("Room database columns do not match the current schema")
|
|
}
|
|
index++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if index != len(expected) {
|
|
return errors.New("Room database columns do not match the current schema")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
|
|
rows, err := d.conn.QueryContext(context.Background(), selectStoredRooms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var stored []StoredRoomAuthority
|
|
for rows.Next() {
|
|
var (
|
|
room StoredRoomAuthority
|
|
policy string
|
|
material []byte
|
|
)
|
|
if err := rows.Scan(
|
|
&room.RoomID,
|
|
&room.HostTokenDigest,
|
|
&room.ViewerGrantDigest,
|
|
&room.ViewerAuthorizationGeneration,
|
|
&policy,
|
|
&material); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := readDigest(room.HostTokenDigest, "Host token"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := readDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil {
|
|
return nil, err
|
|
}
|
|
if !validCodeEntryPolicy(policy) {
|
|
return nil, errors.New("Room database contains an invalid code-entry policy")
|
|
}
|
|
room.CodeEntryPolicy = policy
|
|
if material != nil {
|
|
if len(material) != viewerPasswordMaterialBytes {
|
|
return nil, errors.New("Room database contains invalid Viewer password material")
|
|
}
|
|
room.ViewerPasswordMaterial = material
|
|
}
|
|
if err := assertStoredRoom(room); err != nil {
|
|
return nil, err
|
|
}
|
|
stored = append(stored, room)
|
|
}
|
|
return stored, rows.Err()
|
|
}
|
|
|
|
func (d *Database) readPragmaInteger(name string) (int64, error) {
|
|
var value int64
|
|
if err := d.conn.QueryRowContext(
|
|
context.Background(), "PRAGMA "+name).Scan(&value); err != nil {
|
|
return 0, fmt.Errorf("Room database %s is invalid", name)
|
|
}
|
|
if value < -protocol.MaxSafeInteger || value > protocol.MaxSafeInteger {
|
|
return 0, fmt.Errorf("Room database %s is invalid", name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (d *Database) transaction(operation func() error) error {
|
|
if d.conn == nil {
|
|
return errors.New("Room database is not initialized")
|
|
}
|
|
if err := d.begin("BEGIN IMMEDIATE"); err != nil {
|
|
return err
|
|
}
|
|
if err := operation(); err != nil {
|
|
d.rollbackIfNeeded()
|
|
return err
|
|
}
|
|
if err := d.finish("COMMIT"); err != nil {
|
|
d.rollbackIfNeeded()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) begin(statement string) error {
|
|
if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
|
|
return err
|
|
}
|
|
d.inTransaction = true
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) finish(statement string) error {
|
|
if _, err := d.conn.ExecContext(context.Background(), statement); err != nil {
|
|
return err
|
|
}
|
|
d.inTransaction = false
|
|
return nil
|
|
}
|
|
|
|
// rollbackIfNeeded is `if (database.isTransaction) database.exec("ROLLBACK")`:
|
|
// a ROLLBACK with no active transaction is itself an error.
|
|
func (d *Database) rollbackIfNeeded() {
|
|
if d.inTransaction {
|
|
_ = d.finish("ROLLBACK")
|
|
}
|
|
}
|
|
|
|
func (d *Database) exec(query string, args ...any) (int64, error) {
|
|
result, err := d.conn.ExecContext(context.Background(), query, args...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
func assertStoredRoom(room StoredRoomAuthority) error {
|
|
if !protocol.ValidRoomCode(room.RoomID) {
|
|
return errors.New("Room database contains an invalid room ID")
|
|
}
|
|
if err := assertDigest(room.HostTokenDigest, "Host token"); err != nil {
|
|
return err
|
|
}
|
|
if err := assertDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil {
|
|
return err
|
|
}
|
|
if err := assertAuthorizationGeneration(room.ViewerAuthorizationGeneration); err != nil {
|
|
return err
|
|
}
|
|
if !validCodeEntryPolicy(room.CodeEntryPolicy) {
|
|
return errors.New("Room database contains an invalid code-entry policy")
|
|
}
|
|
if err := assertPasswordMaterial(room.ViewerPasswordMaterial); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertRoomIdentity(roomID string, hostTokenDigest []byte) error {
|
|
if !protocol.ValidRoomCode(roomID) {
|
|
return errors.New("Room ID is invalid")
|
|
}
|
|
return assertDigest(hostTokenDigest, "Host token")
|
|
}
|
|
|
|
func readDigest(value []byte, name string) error {
|
|
if len(value) != 32 {
|
|
return fmt.Errorf("Room database contains an invalid %s digest", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertDigest(value []byte, name string) error {
|
|
if len(value) != 32 {
|
|
return fmt.Errorf("%s digest must contain 32 bytes", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertPasswordMaterial(value []byte) error {
|
|
if value != nil && len(value) != viewerPasswordMaterialBytes {
|
|
return errors.New("Viewer password material must contain 48 bytes")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertAuthorizationGeneration(value string) error {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
|
if err != nil || len(decoded) != 16 ||
|
|
base64.RawURLEncoding.EncodeToString(decoded) != value {
|
|
return errors.New("Viewer authorization generation is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertSingleChange(changes int64, operation string) error {
|
|
if changes != 1 {
|
|
return fmt.Errorf("Room database %s did not match current authority", operation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validCodeEntryPolicy(policy protocol.CodeEntryPolicy) bool {
|
|
return policy == protocol.CodeEntryOpen || policy == protocol.CodeEntryPrivate
|
|
}
|