elenvind 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elenvind/__init__.py +34 -0
- elenvind/__main__.py +10 -0
- elenvind/app.py +188 -0
- elenvind/cli.py +538 -0
- elenvind/core/__init__.py +21 -0
- elenvind/core/app.py +223 -0
- elenvind/core/auth.py +137 -0
- elenvind/core/config.py +426 -0
- elenvind/core/console.py +40 -0
- elenvind/core/content.py +172 -0
- elenvind/core/context.py +170 -0
- elenvind/core/csrf.py +72 -0
- elenvind/core/http.py +707 -0
- elenvind/core/i18n.py +90 -0
- elenvind/core/lifespan.py +114 -0
- elenvind/core/logging_config.py +121 -0
- elenvind/core/markdown.py +545 -0
- elenvind/core/routing.py +345 -0
- elenvind/core/security.py +554 -0
- elenvind/core/session.py +129 -0
- elenvind/core/templating.py +178 -0
- elenvind/core/utils.py +271 -0
- elenvind/core/version.py +75 -0
- elenvind/database/__init__.py +172 -0
- elenvind/database/auth.py +202 -0
- elenvind/database/backup.py +108 -0
- elenvind/database/comment.py +193 -0
- elenvind/database/comment_rate.py +116 -0
- elenvind/database/connection.py +180 -0
- elenvind/database/maintenance.py +101 -0
- elenvind/database/migration.py +509 -0
- elenvind/database/session.py +214 -0
- elenvind/database/transaction.py +111 -0
- elenvind/database/user.py +177 -0
- elenvind/i18n/en.toml +150 -0
- elenvind/i18n/ja.toml +150 -0
- elenvind/i18n/zh.toml +150 -0
- elenvind/launcher.py +280 -0
- elenvind/modules/__init__.py +27 -0
- elenvind/modules/admin/__init__.py +6 -0
- elenvind/modules/admin/routes.py +29 -0
- elenvind/modules/auth/__init__.py +5 -0
- elenvind/modules/auth/routes.py +345 -0
- elenvind/modules/blog/__init__.py +9 -0
- elenvind/modules/blog/logic.py +500 -0
- elenvind/modules/blog/routes.py +263 -0
- elenvind/modules/pages/__init__.py +10 -0
- elenvind/modules/pages/logic.py +139 -0
- elenvind/modules/pages/routes.py +46 -0
- elenvind/modules/seo/__init__.py +7 -0
- elenvind/modules/seo/routes.py +69 -0
- elenvind/modules/system/__init__.py +10 -0
- elenvind/modules/system/routes.py +57 -0
- elenvind/modules/users/__init__.py +5 -0
- elenvind/modules/users/routes.py +231 -0
- elenvind/resources/config.example.toml +249 -0
- elenvind/resources/elenvind.service +79 -0
- elenvind/reverse_proxy.py +735 -0
- elenvind/scaffold.py +81 -0
- elenvind/templates/admin/index.html +29 -0
- elenvind/templates/article.html +29 -0
- elenvind/templates/auth/login.html +30 -0
- elenvind/templates/auth/logout.html +19 -0
- elenvind/templates/auth/register.html +41 -0
- elenvind/templates/base.html +26 -0
- elenvind/templates/errors/400.html +6 -0
- elenvind/templates/errors/404.html +6 -0
- elenvind/templates/errors/error.html +6 -0
- elenvind/templates/home.html +81 -0
- elenvind/templates/page.html +8 -0
- elenvind/templates/partials/comments.html +71 -0
- elenvind/templates/partials/footer.html +15 -0
- elenvind/templates/partials/form_message.html +10 -0
- elenvind/templates/partials/navbar.html +38 -0
- elenvind/templates/users/profile.html +115 -0
- elenvind/wsgi.py +37 -0
- elenvind-1.0.1.dist-info/METADATA +246 -0
- elenvind-1.0.1.dist-info/RECORD +82 -0
- elenvind-1.0.1.dist-info/WHEEL +5 -0
- elenvind-1.0.1.dist-info/entry_points.txt +2 -0
- elenvind-1.0.1.dist-info/licenses/LICENSE +674 -0
- elenvind-1.0.1.dist-info/top_level.txt +1 -0
elenvind/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Elenvind —— 一个安全默认的小型个人网站服务器。
|
|
2
|
+
|
|
3
|
+
定位:**标准库为绝对核心** + WSGI + 单机 SQLite + Jinja2/Markdown,服务端渲染、
|
|
4
|
+
Zero-JS。它不是通用 Web 框架:没有 ORM、依赖注入、事件总线、插件系统,也没有
|
|
5
|
+
asyncio 或后台 writer 进程;SQLite 是唯一数据存储与不变量来源(WAL,写事务统一
|
|
6
|
+
走 `database.write_tx()`)。
|
|
7
|
+
|
|
8
|
+
分层与依赖方向(**只有这一个方向**,由 `tests/test_architecture.py` 守卫):
|
|
9
|
+
|
|
10
|
+
app.py / wsgi.py / cli.py 装配与入口:唯一同时认识 Core 与业务模块的地方
|
|
11
|
+
↓
|
|
12
|
+
core/ 运行时基座:配置 / HTTP 边界 / 路由 / 安全 / 会话 /
|
|
13
|
+
↓ 模板 / Markdown / 内容格式 / 日志
|
|
14
|
+
db/ 持久化基座(唯一 SQLite 边界):连接 / 锁 / 写事务 /
|
|
15
|
+
↓ schema 迁移 / 各表读写
|
|
16
|
+
modules/ 业务模块:blog / pages / auth / users / admin /
|
|
17
|
+
seo / system
|
|
18
|
+
|
|
19
|
+
两条铁律:
|
|
20
|
+
|
|
21
|
+
- Core 不认识 database,也不认识业务模块;database 不认识 core 与 modules。模块可以依赖 core
|
|
22
|
+
与 database,但**模块之间互不 import** —— 需要协作时由装配层显式注入(见 `elenvind/app.py`)。
|
|
23
|
+
- 业务模块只管业务,Web 安全与基础设施归 Core:模块不得自行实现会话、CSRF、
|
|
24
|
+
Cookie、密码、安全响应头或请求限制。
|
|
25
|
+
|
|
26
|
+
包内随发行版分发的还有 `templates/`(模板)、`i18n/`(界面文案)与 `resources/`
|
|
27
|
+
(示例配置与 systemd 单元);站点实例(`config.toml`、文章、数据库、日志)不在这里。
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__"]
|
|
31
|
+
|
|
32
|
+
from .core.version import raw_version
|
|
33
|
+
|
|
34
|
+
__version__ = raw_version()
|
elenvind/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""`python -m elenvind` 的入口:等价于 console script `elenvind`。
|
|
2
|
+
|
|
3
|
+
装过包就有 `elenvind` 命令;这个文件让"没装进 PATH / 用虚拟环境绝对路径"的场景也能
|
|
4
|
+
直接跑起来:`/srv/elenvind/.venv/bin/python -m elenvind serve`。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
raise SystemExit(main())
|
elenvind/app.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""应用装配点:把 Core、database 与各业务模块装成一个可运行的站点。
|
|
2
|
+
|
|
3
|
+
这是**唯一**允许同时认识 `core`、`database` 与业务模块的地方。依赖方向(由
|
|
4
|
+
`tests/test_architecture.py` 守卫):
|
|
5
|
+
|
|
6
|
+
app.py / wsgi.py / cli.py 装配与入口
|
|
7
|
+
│
|
|
8
|
+
├──► core (core ↛ database、core ↛ modules)
|
|
9
|
+
├──► database (database ↛ core、database ↛ modules)
|
|
10
|
+
└──► modules (modules ↛ modules、modules ↛ app)
|
|
11
|
+
|
|
12
|
+
路由注册顺序**就是优先级**,改顺序会改变谁能命中:
|
|
13
|
+
|
|
14
|
+
1. auth / users / admin(固定路径);
|
|
15
|
+
2. blog(`/`、`/article/<slug>` 与评论写操作);
|
|
16
|
+
3. seo(`/robots.txt`、`/sitemap.xml`,文章条目由这里注入);
|
|
17
|
+
4. pages(`/theme` 与 `/<slug>` 兜底)——**必须最后**,否则兜底会遮蔽固定路径。
|
|
18
|
+
|
|
19
|
+
本服务**不发出任何静态文件**:样式表、图片、视频一律由管理员自己托管
|
|
20
|
+
(CDN / 图床 / 自建 Web 服务器),地址写进 `config.toml` 的 `[static]`。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
from functools import partial
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import database
|
|
30
|
+
from .core.app import App
|
|
31
|
+
from .core.context import set_user_count_provider
|
|
32
|
+
from .core.session import load_user
|
|
33
|
+
from .modules.admin import routes as admin_routes
|
|
34
|
+
from .modules.auth import routes as auth_routes
|
|
35
|
+
from .modules.blog import logic as blog_logic
|
|
36
|
+
from .modules.blog import routes as blog_routes
|
|
37
|
+
from .modules.pages import logic as pages_logic
|
|
38
|
+
from .modules.pages import routes as pages_routes
|
|
39
|
+
from .modules.seo import routes as seo_routes
|
|
40
|
+
from .modules.system import routes as system_routes
|
|
41
|
+
from .modules.users import routes as users_routes
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
#: 启动钩子:数据库与模板就绪后按序执行(模块的内容缓存预热)
|
|
46
|
+
STARTUP_HOOKS = (
|
|
47
|
+
("Articles loaded", blog_logic.load_articles),
|
|
48
|
+
("Custom pages loaded", pages_logic.load_pages),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_db_path() -> Path:
|
|
53
|
+
"""数据库文件路径(**装配层职责**)。
|
|
54
|
+
|
|
55
|
+
优先级:`ELENVIND_DB` 环境变量 > `config.toml` 的 `database` > `<实例根>/data/sqlite.db`;
|
|
56
|
+
相对路径一律相对实例根解析。
|
|
57
|
+
|
|
58
|
+
兜底刻意落在 `data/` 下:systemd 单元只把 `data/`、`logs/`、`backups/` 授予服务账号
|
|
59
|
+
写权限,落在实例根会直接写失败。配置语义留在这一层,database 只接受解析好的路径
|
|
60
|
+
(`database.configure()`),因此持久化基座不必认识配置。
|
|
61
|
+
"""
|
|
62
|
+
from .core.config import ROOT, config, resolve_path
|
|
63
|
+
|
|
64
|
+
override = os.environ.get("ELENVIND_DB")
|
|
65
|
+
if override:
|
|
66
|
+
return Path(override)
|
|
67
|
+
configured = config.get("database")
|
|
68
|
+
if isinstance(configured, str) and configured.strip():
|
|
69
|
+
return resolve_path(configured, ROOT / "data" / "sqlite.db")
|
|
70
|
+
return ROOT / "data" / "sqlite.db"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def prepare_database() -> None:
|
|
74
|
+
"""数据库启动步骤:绑定路径与会话窗口 → 建表迁移 → 清理过期数据 → 记录库信息。
|
|
75
|
+
|
|
76
|
+
`core.lifespan.startup(prepare_database=…)` 只决定"何时执行";"怎么初始化数据库"
|
|
77
|
+
属于装配层与 database 层。
|
|
78
|
+
"""
|
|
79
|
+
from .core.config import config
|
|
80
|
+
|
|
81
|
+
warn_on_exposed_bind()
|
|
82
|
+
database.configure(resolve_db_path())
|
|
83
|
+
# 会话窗口实时从 config 取:database 不认识 config,只接受这个 callable
|
|
84
|
+
database.session.set_limits_provider(lambda: (
|
|
85
|
+
config.get("session_absolute_days", database.DEFAULT_ABSOLUTE_DAYS),
|
|
86
|
+
config.get("session_idle_days", database.DEFAULT_IDLE_DAYS),
|
|
87
|
+
))
|
|
88
|
+
database.init_db()
|
|
89
|
+
warn_about_missing_admin()
|
|
90
|
+
|
|
91
|
+
removed_sessions = database.cleanup_expired_sessions()
|
|
92
|
+
database.cleanup_old_login_attempts(days=30)
|
|
93
|
+
database.cleanup_old_comment_attempts(days=7)
|
|
94
|
+
database.cleanup_old_attempts(days=7)
|
|
95
|
+
|
|
96
|
+
logger.info("Database: %s (schema v%s, journal_mode=%s, write lock %s)",
|
|
97
|
+
database.db_path(), database.SCHEMA_VERSION, database.journal_mode(),
|
|
98
|
+
database.lock_path_for(database.db_path()))
|
|
99
|
+
logger.info("Expired sessions and rate-limit history cleaned "
|
|
100
|
+
"(%s session(s) removed)", removed_sessions)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: 回环地址:只有监听这些地址时,"应用端口不可从公网直连"才成立
|
|
104
|
+
_LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def warn_on_exposed_bind() -> None:
|
|
108
|
+
"""启动告警:**实际**监听地址不是回环。
|
|
109
|
+
|
|
110
|
+
本服务的形态是"只监听回环 + 由反向代理终结 TLS":应用不自己提供对外服务,也不使用
|
|
111
|
+
任何 WSGI 服务器特有的 TLS 能力。因此把端口暴露到回环之外就是在绕过整个安全边界 ——
|
|
112
|
+
能连到它的人跳过了 TLS/HSTS/边缘限速,也跳过了连接级慢连接防护。这里只告警、不拒绝
|
|
113
|
+
启动:这是部署配置问题,不是代码缺陷。
|
|
114
|
+
|
|
115
|
+
host 取**实际 bind**(启动器通过 `config.set_effective_bind()` 注入),只有在直接
|
|
116
|
+
`gunicorn elenvind.wsgi:application` 时才回落到配置值 —— 只读配置会在 `--bind`
|
|
117
|
+
覆盖时漏报或误报。
|
|
118
|
+
"""
|
|
119
|
+
from .core.config import config, effective_bind_host
|
|
120
|
+
|
|
121
|
+
server = config.get("server") or {}
|
|
122
|
+
host = effective_bind_host() or str(server.get("host", "127.0.0.1") or "").strip()
|
|
123
|
+
trusted = server.get("trusted_proxies") or []
|
|
124
|
+
if host in _LOOPBACK_HOSTS:
|
|
125
|
+
return
|
|
126
|
+
if trusted:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Listening on %s (not loopback) while trusting forwarded headers from %s: "
|
|
129
|
+
"clients that can reach this port bypass the reverse proxy "
|
|
130
|
+
"(TLS/HSTS/edge limits). Bind to 127.0.0.1 and let the proxy forward, "
|
|
131
|
+
"or firewall the port.", host, trusted)
|
|
132
|
+
return
|
|
133
|
+
logger.warning(
|
|
134
|
+
"Listening on %s (not loopback) and trusting no proxy: this port serves "
|
|
135
|
+
"PLAINTEXT traffic with no reverse proxy in front. Bind to 127.0.0.1 and put "
|
|
136
|
+
"a reverse proxy in front of it (see manual/HOSTING.md).", host)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def warn_about_missing_admin() -> None:
|
|
140
|
+
"""启动告警:`admin_user_id` 缺失、为 null 或指向不存在的用户。
|
|
141
|
+
|
|
142
|
+
管理员身份的唯一来源是配置里那个 id,所以配置写错(或该账号后来注销)的结果不是
|
|
143
|
+
报错,而是**静默地没有管理员** —— 评论区被刷屏时没人能涂黑。启动日志里点名,
|
|
144
|
+
避免"以为自己是管理员"(新库还没注册时尤其容易踩)。
|
|
145
|
+
"""
|
|
146
|
+
from .core.security import admin_id
|
|
147
|
+
|
|
148
|
+
owner = admin_id()
|
|
149
|
+
if owner is None:
|
|
150
|
+
logger.warning("No administrator configured (admin_user_id missing, null, "
|
|
151
|
+
"or invalid): nobody can moderate comments")
|
|
152
|
+
return
|
|
153
|
+
if database.get_user_by_id(owner) is None:
|
|
154
|
+
logger.warning("admin_user_id=%s does not match an active user: nobody can "
|
|
155
|
+
"moderate comments. Register that account, or point "
|
|
156
|
+
"admin_user_id at an existing user id.", owner)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def create_app() -> App:
|
|
160
|
+
"""构造应用:Core App + 错误页 + 会话加载 + 各模块路由。"""
|
|
161
|
+
# Core 不认识 database:用户总数(页脚用)由这里注入一个数据源
|
|
162
|
+
set_user_count_provider(database.get_user_number)
|
|
163
|
+
|
|
164
|
+
app = App(
|
|
165
|
+
not_found=system_routes.not_found,
|
|
166
|
+
forbidden=system_routes.forbidden,
|
|
167
|
+
server_error=system_routes.server_error,
|
|
168
|
+
bad_request=system_routes.bad_request,
|
|
169
|
+
# 会话 → 用户:Core 的 HTTP 层 + 注入的持久化 store
|
|
170
|
+
user_loader=partial(load_user, store=database),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
router = app.router
|
|
174
|
+
auth_routes.register(router)
|
|
175
|
+
users_routes.register(router)
|
|
176
|
+
admin_routes.register(router)
|
|
177
|
+
blog_routes.register(router, render_not_found=system_routes.not_found)
|
|
178
|
+
# 模块之间不互相 import:seo 需要的文章条目与页面 slug 在这里显式注入
|
|
179
|
+
seo_routes.register(router, articles=blog_logic.sitemap_articles,
|
|
180
|
+
pages=pages_logic.page_slugs)
|
|
181
|
+
pages_routes.register(router, render_not_found=system_routes.not_found)
|
|
182
|
+
return app
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
app = create_app()
|
|
186
|
+
|
|
187
|
+
__all__ = ["STARTUP_HOOKS", "app", "create_app", "prepare_database",
|
|
188
|
+
"resolve_db_path", "warn_about_missing_admin", "warn_on_exposed_bind"]
|