Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ jobs:
CREATE DATABASE home_server_accounts_runtime_test;
CREATE DATABASE home_server_analytics_test_ci;
CREATE DATABASE home_server_readcache_test_ci;
CREATE DATABASE home_server_manuals_test_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'home_server_ci'@'%' IDENTIFIED BY 'ci-isolated-tests';
GRANT ALL ON home_server_accounts_http_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_accounts_migration_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_account_sessions_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_accounts_runtime_test.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_analytics_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_readcache_test_ci.* TO 'home_server_ci'@'%';
GRANT ALL ON home_server_manuals_test_ci.* TO 'home_server_ci'@'%';
SQL

- name: Exercise availability-first Redis policy
Expand All @@ -72,20 +74,25 @@ jobs:
HOME_SERVER_RUNTIME_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_accounts_runtime_test?parseTime=true'
HOME_SERVER_ANALYTICS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_analytics_test_ci?parseTime=true'
HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true'
run: go test -v ./config ./api/accounts ./api/webprojects ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics
MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true'
run: go test -v ./config ./api/accounts ./api/webprojects ./api/manuals ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics

- name: Test isolated DAL cache bindings
env:
MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true'
HOME_SERVER_TEST_REDIS_ADDR: '127.0.0.1:16389'
HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true'
run: go test -v ./domain/dal -run TestReadCache -count=1
run: go test -v ./domain/dal -run 'Test(ReadCache|Manual)' -count=1

- name: Test AI API client
run: python3 script/test_home_server_api.py -v

- name: Test web hosting skill over isolated HTTPS
run: python3 script/test_web_hosting_skill.py -v

- name: Test manuals skill over isolated HTTPS
run: python3 script/test_manuals_skill.py -v

- name: Use public npm registry in the disposable CI checkout
working-directory: front_vue
run: python3 -c "from pathlib import Path; path = Path('package-lock.json'); path.write_text(path.read_text(encoding='utf-8').replace('http://bnpm.byted.org/', 'https://registry.npmjs.org/'), encoding='utf-8')"
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ cq_ddns_client -conf /etc/home_server/client_config.yaml -dry-run

回滚时停用新服务,再启动原 `home_client.service`。切换验证完成前保留旧二进制、配置和 unit。

## 说明书管理

说明书将同一设备的 PDF、图片、文字和链接放在一起,支持每份说明书归属多个个人单层分类、名称包含关键词检索和缩略图列表。新建默认仅自己可见,也可选择登录用户或所有人可见;仅所有者可以编辑和删除。

| 操作 | 行为 |
| --- | --- |
| 批量录入 | 图片可多选并多次追加,PDF、文字和 URL 可同时录入;上传失败保留成功项,重新选择或重试失败项 |
| 查阅 | 按分类和名称组合筛选,点击封面进入有序资料列表;PDF 可打开或下载,URL 打开原站 |
| 分享 | 分享说明书地址,访问者按当前可见范围鉴权;缩略图和原件受相同权限控制 |
| 删除 | 逻辑删除后立即停止访问,原始文件保留并继续占用存储空间 |

系统相册是否支持一次跨相册选择取决于手机;页面会保留多次选择的队列。图片支持 JPEG、PNG、GIF、WebP,HEIC/HEIF 需要先转换。PDF 预览使用 `pdftoppm`;无法生成预览时保留原件并显示占位状态。URL 只保存地址,需要长期归档的内容应上传文件。

设计、接口与默认限额见 [说明书管理技术方案](docs/specs/manual-management/design.md),安装及回滚见 [Pi 部署说明](deploy/pi/README.md)。AI 操作入口包含在 [home-server-web-share skill](skills/home-server-web-share/SKILL.md) 中,使用独立的 `manuals:read` / `manuals:write` scope;旧凭证不会自动扩大权限。

## 网页托管

### 使用方式
Expand Down
4 changes: 2 additions & 2 deletions api/accounts/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ type accountView struct {

func view(user *model.UserAccount, token string) accountView {
runtime := config.Runtime()
return accountView{UserAccount: user, CSRFToken: middleware.CSRFToken(token), Capabilities: map[string]bool{"library": user.LibraryEnabled && runtime.LibraryEnabled, "webdav": user.WebDAVPermission != model.WebDAVNone && runtime.WebDAVEnabled, "applications": runtime.Auth.ApplicationsEnabled, "web_projects": runtime.WebProjects.Enabled}, PasswordPolicy: map[string]int{"min_length": runtime.AccountPolicy.MinPasswordLength}, ApplicationPolicy: map[string]int{"default_credential_ttl_days": runtime.Auth.DefaultCredentialTTLDays, "max_credential_ttl_days": runtime.Auth.MaxCredentialTTLDays, "max_applications_per_user": runtime.Auth.MaxApplicationsPerUser}, SessionPolicy: map[string]int{"max_active_sessions": runtime.AccountPolicy.MaxActiveSessions}}
return accountView{UserAccount: user, CSRFToken: middleware.CSRFToken(token), Capabilities: map[string]bool{"library": user.LibraryEnabled && runtime.LibraryEnabled, "webdav": user.WebDAVPermission != model.WebDAVNone && runtime.WebDAVEnabled, "applications": runtime.Auth.ApplicationsEnabled, "web_projects": runtime.WebProjects.Enabled, "manuals": runtime.Manuals.Enabled}, PasswordPolicy: map[string]int{"min_length": runtime.AccountPolicy.MinPasswordLength}, ApplicationPolicy: map[string]int{"default_credential_ttl_days": runtime.Auth.DefaultCredentialTTLDays, "max_credential_ttl_days": runtime.Auth.MaxCredentialTTLDays, "max_applications_per_user": runtime.Auth.MaxApplicationsPerUser}, SessionPolicy: map[string]int{"max_active_sessions": runtime.AccountPolicy.MaxActiveSessions}}
}

// login 处理 POST /api/auth/login:验证用户名和密码,建立 HttpOnly 浏览器会话并返回本人资料与 CSRF 信息。
Expand Down Expand Up @@ -247,7 +247,7 @@ func publicBootstrap(c *gin.Context) {
return
}
runtime := config.Runtime()
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value}, nil)
respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled}}, nil)
}

// validateInvitation 处理 POST /api/auth/invitations/validate:在来源限流后校验邀请码,仅返回有效状态与到期时间,不消费邀请码。
Expand Down
2 changes: 2 additions & 0 deletions api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/mcoder2014/home_server/api/applications"
"github.com/mcoder2014/home_server/api/auth"
"github.com/mcoder2014/home_server/api/library"
"github.com/mcoder2014/home_server/api/manuals"
"github.com/mcoder2014/home_server/api/middleware"
"github.com/mcoder2014/home_server/api/passport"
"github.com/mcoder2014/home_server/api/webdav"
Expand Down Expand Up @@ -35,6 +36,7 @@ func InitRouter() error {
webdav.InitRouter,
// 静态网页托管相关接口
webprojects.InitRouter,
manuals.InitRouter,
auth.InitRouter,
applications.InitRouter,
} {
Expand Down
28 changes: 28 additions & 0 deletions api/manuals/categories_pagination_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package manuals

import (
"net/http"
"net/url"
"testing"

"github.com/stretchr/testify/require"
)

func TestManualCategoriesUseBinaryCursorPagination(t *testing.T) {
fixture := newManualHTTPFixture(t)
requireManualSuccess(t, fixture.request(http.MethodPost, "/api/manuals", fixture.owner, map[string]interface{}{
"name": "分类分页", "categories": []string{"厨房", "a", "A"}, "access_mode": "owner", "client_request_id": "category-page-manual",
}, nil))

first := requireManualSuccess(t, fixture.request(http.MethodGet, "/api/manuals/categories?mine=true&limit=2", fixture.owner, nil, nil))
require.Equal(t, []interface{}{"A", "a"}, first["items"])
require.Equal(t, true, first["has_more"])
require.Equal(t, "a", first["next_cursor"])

second := requireManualSuccess(t, fixture.request(http.MethodGet, "/api/manuals/categories?mine=true&limit=2&cursor="+url.QueryEscape(first["next_cursor"].(string)), fixture.owner, nil, nil))
require.Equal(t, []interface{}{"厨房"}, second["items"])
require.Equal(t, false, second["has_more"])
require.Equal(t, "", second["next_cursor"])

requireManualDenied(t, fixture.request(http.MethodGet, "/api/manuals/categories?mine=true&limit=1001", fixture.owner, nil, nil))
}
85 changes: 85 additions & 0 deletions api/manuals/content.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package manuals

import (
"mime"
"net/http"
"strings"

"github.com/gin-gonic/gin"
application "github.com/mcoder2014/home_server/app/manuals"
"github.com/mcoder2014/home_server/config"
"github.com/mcoder2014/home_server/domain/model"
service "github.com/mcoder2014/home_server/domain/service/manuals"
apperrors "github.com/mcoder2014/home_server/errors"
"github.com/mcoder2014/home_server/utils/ginfmt"
)

func serveItemResource(c *gin.Context) {
manualID, err := pathID(c, "id")
if err != nil {
ginfmt.Fail(c, err)
return
}
itemID, err := pathID(c, "item_id")
if err != nil {
ginfmt.Fail(c, err)
return
}
principal, err := optionalReader(c)
if err != nil {
ginfmt.Fail(c, err)
return
}
thumbnail := strings.HasSuffix(c.FullPath(), "/thumbnail")
item, err := application.Default.ResourceItem(ginfmt.RPCContext(c), manualID, itemID, principalUserID(principal), thumbnail)
if err != nil {
ginfmt.Fail(c, err)
return
}
key := item.StorageKey
if thumbnail {
key = item.ThumbnailKey
}
if key == nil {
ginfmt.Fail(c, apperrors.ErrNotFound)
return
}
conf := config.Global().Manuals
file, info, err := service.OpenStoredFile(&conf, *key)
if err != nil {
ginfmt.Fail(c, err)
return
}
defer file.Close()
setResourceHeaders(c, item, thumbnail)
http.ServeContent(c.Writer, c.Request, resourceFileName(item, thumbnail), info.ModTime(), file)
}

func setResourceHeaders(c *gin.Context, item *model.ManualItem, thumbnail bool) {
c.Header("Cache-Control", "private, no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cross-Origin-Resource-Policy", "same-origin")
contentType, disposition := item.ContentType, "inline"
if thumbnail {
contentType = "image/jpeg"
} else if c.Query("download") == "1" {
disposition = "attachment"
}
if item.Kind == model.ManualItemPDF {
c.Header("Content-Security-Policy", "sandbox")
}
c.Header("Content-Type", contentType)
if value := mime.FormatMediaType(disposition, map[string]string{"filename": resourceFileName(item, thumbnail)}); value != "" {
c.Header("Content-Disposition", value)
}
}

func resourceFileName(item *model.ManualItem, thumbnail bool) string {
if thumbnail {
return "thumbnail.jpg"
}
if item.OriginalName != "" {
return item.OriginalName
}
return "manual-item"
}
Loading
Loading