补齐后端最小功能和前端
This commit is contained in:
1 parent
a8c22b1b07
commit
a6179aba6a
56 files changed
+8121
-321
No files matched your search
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: frontend-design
|
||||||
|
description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Design
|
||||||
|
|
||||||
|
Approach this as the design lead at a design studio known for giving every client a distinct visual identity that is not mistaken for anyone else's. This client has already rejected proposals that felt cliché or templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take aesthetic risk if justified.
|
||||||
|
|
||||||
|
## Ground your designs in the subject matter
|
||||||
|
|
||||||
|
If the brief does not identify what the product or subject matter is, identify it yourself before designing, and confirm with the client. You can come up with one concrete subject, the design's audience, and the design's primary job, as a proposal. If there's any information in your memory about the client's preferences or context about what they're building, use that as a hint. The subject's industry, subject matter, materials, and vernacular are where distinctive visual choices come from — a design for a toy for girls aged 8–11 will be very aesthetically different from a dashboard for financial analysts. Build with the brief's real content and subject matter throughout.
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
For web designs, the hero is the first thing viewers will see. Open with the most characteristic thing in the subject's world, in the form that is most appropriate: a headline, an image, an animation, a live demo, an interactive moment, or other treatments. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the default treatment, so only use it if that's truly the best option.
|
||||||
|
|
||||||
|
Typography carries the personality of the page. You don't need a different typeface for display or headline text and body content: use one family or two, and if two, make them clearly distinct.
|
||||||
|
|
||||||
|
Choose your typefaces deliberately, not the default families you would reach for on any other project, and set a clear type scale following the default guidance of The Elements of Typographic Style with intentional weights, widths, and spacing. When type is used as a headline or visual element, use the type treatment itself as an active part of the design, not a neutral delivery vehicle for the content.
|
||||||
|
|
||||||
|
Default to line lengths of less than 80 characters. Serif typefaces can have slightly longer line lengths; give serif body text slightly more line-height than a sans-serif.
|
||||||
|
|
||||||
|
Avoid these default typographic treatments; they are the commonest tells of a generated page:
|
||||||
|
- Accenting just a single word or phrase in a headline, like putting one word in italic/bold or a different color.
|
||||||
|
- Using all caps for labels.
|
||||||
|
- Adding unnecessary typographic labels above content.
|
||||||
|
|
||||||
|
Visual structure is information. Structural devices like outlines, borders, numbering, eyebrows, dividers, labels, etc., encode useful information about the content rather than decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence — like a stepped process or a timeline. Before adding numbered markers, check the content really is a sequence.
|
||||||
|
|
||||||
|
Use non-user-triggered motion sparingly and deliberately, only to draw attention. A single orchestrated moment — one page-load sequence or one reveal — lands better than scattered effects; fade-and-slide-up entrances on each section and hover transitions on every card are the generic default and read as AI-generated. Motion that answers a person's action (opening, expanding, confirming) is welcome when it shows what changed.
|
||||||
|
|
||||||
|
Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy and placeholder content. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance.
|
||||||
|
|
||||||
|
## Process: plan, review against the brief, build, critique
|
||||||
|
|
||||||
|
For calibration, AI-generated design right now clusters around some traits:
|
||||||
|
1. a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta or warm-clay accent (often near #D97757 — Anthropic's own Claude-interaction accent, so on a user's brief it reads as a tell);
|
||||||
|
2. a near-black background with a single bright acid-green or vermilion accent;
|
||||||
|
3. a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns;
|
||||||
|
4. the SaaS-card kit: content chopped into identical rounded cards, one border-radius on everything regardless of hierarchy, the same soft grey shadow (rgba(0,0,0,.1)) under each, and gradient washes as decoration;
|
||||||
|
5. template chrome that appears whatever the subject: a tracked-out ALL-CAPS eyebrow label above every heading; meta strings joined with middle dots ('A · B · C'); labels built as 'WORD — fragment' with a spaced em dash; tinted near-black (#0B0B0B, #111) standing in for black; a monospace face for small data labels; a '→' appended to link and button text.
|
||||||
|
|
||||||
|
All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. As with a hired human designer, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn.
|
||||||
|
|
||||||
|
Work in two passes. First, brainstorm a short design plan based on the client's design brief: create a compact token system with color, type, layout, and principles.
|
||||||
|
- Color: describe the core base palette as 4–6 named hex values.
|
||||||
|
- Type: the typefaces and their roles.
|
||||||
|
- Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Include alignment guidance; should the content be left aligned, center aligned, justified?
|
||||||
|
- Principles: the high-level guidance for what makes this page unique.
|
||||||
|
|
||||||
|
Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan.
|
||||||
|
|
||||||
|
When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and an element-based selector like .cta). This can happen often with padding/margin between sections.
|
||||||
|
|
||||||
|
## Restraint and self-critique
|
||||||
|
|
||||||
|
Spend your boldness in one place. Let one element be the memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected, visually accessible, harmonious color palettes. Critique your own work as you build, taking screenshots to review if your environment supports it — a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creatives have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes.
|
||||||
|
|
||||||
|
## More on writing in design
|
||||||
|
|
||||||
|
Words appear in a design for one reason: to make it easier to understand and use. They are design content, not decoration. Bring the same intentionality and minimalism to copywriting that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience.
|
||||||
|
|
||||||
|
Write from the end user's perspective. Name things by what users will understand in simple language, not by how the system is built. A user manages notifications, not webhook config. Describe what something is or does in plain terms rather than selling it. Being specific and legible to new users is always better than being clever.
|
||||||
|
|
||||||
|
Use active voice as default. A CTA says exactly what happens when it is used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around.
|
||||||
|
|
||||||
|
Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act.
|
||||||
|
|
||||||
|
Keep the tone conversational: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each written element do exactly one job.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: web-design-guidelines
|
||||||
|
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
argument-hint: <file-or-pattern>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Interface Guidelines
|
||||||
|
|
||||||
|
Review files for compliance with Web Interface Guidelines.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Fetch the latest guidelines from the source URL below
|
||||||
|
2. Read the specified files (or prompt user for files/pattern)
|
||||||
|
3. Check against all rules in the fetched guidelines
|
||||||
|
4. Output findings in the terse `file:line` format
|
||||||
|
|
||||||
|
## Guidelines Source
|
||||||
|
|
||||||
|
Fetch fresh guidelines before each review:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When a user provides a file or pattern argument:
|
||||||
|
1. Fetch guidelines from the source URL above
|
||||||
|
2. Read the specified files
|
||||||
|
3. Apply all rules from the fetched guidelines
|
||||||
|
4. Output findings using the format specified in the guidelines
|
||||||
|
|
||||||
|
If no files specified, ask the user which files to review.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Anthropic, PBC.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: webapp-testing
|
||||||
|
description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
|
||||||
|
license: Complete terms in LICENSE.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web Application Testing
|
||||||
|
|
||||||
|
To test local web applications, write native Python Playwright scripts.
|
||||||
|
|
||||||
|
**Helper Scripts Available**:
|
||||||
|
- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)
|
||||||
|
|
||||||
|
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
|
||||||
|
|
||||||
|
## Decision Tree: Choosing Your Approach
|
||||||
|
|
||||||
|
```
|
||||||
|
User task → Is it static HTML?
|
||||||
|
├─ Yes → Read HTML file directly to identify selectors
|
||||||
|
│ ├─ Success → Write Playwright script using selectors
|
||||||
|
│ └─ Fails/Incomplete → Treat as dynamic (below)
|
||||||
|
│
|
||||||
|
└─ No (dynamic webapp) → Is the server already running?
|
||||||
|
├─ No → Run: python scripts/with_server.py --help
|
||||||
|
│ Then use the helper + write simplified Playwright script
|
||||||
|
│
|
||||||
|
└─ Yes → Reconnaissance-then-action:
|
||||||
|
1. Navigate and wait for networkidle
|
||||||
|
2. Take screenshot or inspect DOM
|
||||||
|
3. Identify selectors from rendered state
|
||||||
|
4. Execute actions with discovered selectors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Using with_server.py
|
||||||
|
|
||||||
|
To start a server, run `--help` first, then use the helper:
|
||||||
|
|
||||||
|
**Single server:**
|
||||||
|
```bash
|
||||||
|
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiple servers (e.g., backend + frontend):**
|
||||||
|
```bash
|
||||||
|
python scripts/with_server.py \
|
||||||
|
--server "cd backend && python server.py" --port 3000 \
|
||||||
|
--server "cd frontend && npm run dev" --port 5173 \
|
||||||
|
-- python your_automation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
To create an automation script, include only Playwright logic (servers are managed automatically):
|
||||||
|
```python
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('http://localhost:5173') # Server already running and ready
|
||||||
|
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
|
||||||
|
# ... your automation logic
|
||||||
|
browser.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reconnaissance-Then-Action Pattern
|
||||||
|
|
||||||
|
1. **Inspect rendered DOM**:
|
||||||
|
```python
|
||||||
|
page.screenshot(path='/tmp/inspect.png', full_page=True)
|
||||||
|
content = page.content()
|
||||||
|
page.locator('button').all()
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Identify selectors** from inspection results
|
||||||
|
|
||||||
|
3. **Execute actions** using discovered selectors
|
||||||
|
|
||||||
|
## Common Pitfall
|
||||||
|
|
||||||
|
❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps
|
||||||
|
✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly.
|
||||||
|
- Use `sync_playwright()` for synchronous scripts
|
||||||
|
- Always close the browser when done
|
||||||
|
- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs
|
||||||
|
- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()`
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
- **examples/** - Examples showing common patterns:
|
||||||
|
- `element_discovery.py` - Discovering buttons, links, and inputs on a page
|
||||||
|
- `static_html_automation.py` - Using file:// URLs for local HTML
|
||||||
|
- `console_logging.py` - Capturing console logs during automation
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
# Example: Capturing console logs during browser automation
|
||||||
|
|
||||||
|
url = 'http://localhost:5173' # Replace with your URL
|
||||||
|
|
||||||
|
console_logs = []
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||||
|
|
||||||
|
# Set up console log capture
|
||||||
|
def handle_console_message(msg):
|
||||||
|
console_logs.append(f"[{msg.type}] {msg.text}")
|
||||||
|
print(f"Console: [{msg.type}] {msg.text}")
|
||||||
|
|
||||||
|
page.on("console", handle_console_message)
|
||||||
|
|
||||||
|
# Navigate to page
|
||||||
|
page.goto(url)
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
|
||||||
|
# Interact with the page (triggers console logs)
|
||||||
|
page.click('text=Dashboard')
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
# Save console logs to file
|
||||||
|
with open('/mnt/user-data/outputs/console.log', 'w') as f:
|
||||||
|
f.write('\n'.join(console_logs))
|
||||||
|
|
||||||
|
print(f"\nCaptured {len(console_logs)} console messages")
|
||||||
|
print(f"Logs saved to: /mnt/user-data/outputs/console.log")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
# Example: Discovering buttons and other elements on a page
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
|
||||||
|
# Navigate to page and wait for it to fully load
|
||||||
|
page.goto('http://localhost:5173')
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
|
||||||
|
# Discover all buttons on the page
|
||||||
|
buttons = page.locator('button').all()
|
||||||
|
print(f"Found {len(buttons)} buttons:")
|
||||||
|
for i, button in enumerate(buttons):
|
||||||
|
text = button.inner_text() if button.is_visible() else "[hidden]"
|
||||||
|
print(f" [{i}] {text}")
|
||||||
|
|
||||||
|
# Discover links
|
||||||
|
links = page.locator('a[href]').all()
|
||||||
|
print(f"\nFound {len(links)} links:")
|
||||||
|
for link in links[:5]: # Show first 5
|
||||||
|
text = link.inner_text().strip()
|
||||||
|
href = link.get_attribute('href')
|
||||||
|
print(f" - {text} -> {href}")
|
||||||
|
|
||||||
|
# Discover input fields
|
||||||
|
inputs = page.locator('input, textarea, select').all()
|
||||||
|
print(f"\nFound {len(inputs)} input fields:")
|
||||||
|
for input_elem in inputs:
|
||||||
|
name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]"
|
||||||
|
input_type = input_elem.get_attribute('type') or 'text'
|
||||||
|
print(f" - {name} ({input_type})")
|
||||||
|
|
||||||
|
# Take screenshot for visual reference
|
||||||
|
page.screenshot(path='/tmp/page_discovery.png', full_page=True)
|
||||||
|
print("\nScreenshot saved to /tmp/page_discovery.png")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Example: Automating interaction with static HTML files using file:// URLs
|
||||||
|
|
||||||
|
html_file_path = os.path.abspath('path/to/your/file.html')
|
||||||
|
file_url = f'file://{html_file_path}'
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||||
|
|
||||||
|
# Navigate to local HTML file
|
||||||
|
page.goto(file_url)
|
||||||
|
|
||||||
|
# Take screenshot
|
||||||
|
page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True)
|
||||||
|
|
||||||
|
# Interact with elements
|
||||||
|
page.click('text=Click Me')
|
||||||
|
page.fill('#name', 'John Doe')
|
||||||
|
page.fill('#email', 'john@example.com')
|
||||||
|
|
||||||
|
# Submit form
|
||||||
|
page.click('button[type="submit"]')
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Take final screenshot
|
||||||
|
page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print("Static HTML automation completed!")
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Start one or more servers, wait for them to be ready, run a command, then clean up.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Single server
|
||||||
|
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
|
||||||
|
python scripts/with_server.py --server "npm start" --port 3000 -- python test.py
|
||||||
|
|
||||||
|
# Multiple servers
|
||||||
|
python scripts/with_server.py \
|
||||||
|
--server "cd backend && python server.py" --port 3000 \
|
||||||
|
--server "cd frontend && npm run dev" --port 5173 \
|
||||||
|
-- python test.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
def is_server_ready(port, timeout=30):
|
||||||
|
"""Wait for server to be ready by polling the port."""
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(('localhost', port), timeout=1):
|
||||||
|
return True
|
||||||
|
except (socket.error, ConnectionRefusedError):
|
||||||
|
time.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Run command with one or more servers')
|
||||||
|
parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)')
|
||||||
|
parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)')
|
||||||
|
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)')
|
||||||
|
parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Remove the '--' separator if present
|
||||||
|
if args.command and args.command[0] == '--':
|
||||||
|
args.command = args.command[1:]
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
print("Error: No command specified to run")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Parse server configurations
|
||||||
|
if len(args.servers) != len(args.ports):
|
||||||
|
print("Error: Number of --server and --port arguments must match")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
servers = []
|
||||||
|
for cmd, port in zip(args.servers, args.ports):
|
||||||
|
servers.append({'cmd': cmd, 'port': port})
|
||||||
|
|
||||||
|
server_processes = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start all servers
|
||||||
|
for i, server in enumerate(servers):
|
||||||
|
print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
|
||||||
|
|
||||||
|
# Use shell=True to support commands with cd and &&
|
||||||
|
process = subprocess.Popen(
|
||||||
|
server['cmd'],
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
server_processes.append(process)
|
||||||
|
|
||||||
|
# Wait for this server to be ready
|
||||||
|
print(f"Waiting for server on port {server['port']}...")
|
||||||
|
if not is_server_ready(server['port'], timeout=args.timeout):
|
||||||
|
raise RuntimeError(f"Server failed to start on port {server['port']} within {args.timeout}s")
|
||||||
|
|
||||||
|
print(f"Server ready on port {server['port']}")
|
||||||
|
|
||||||
|
print(f"\nAll {len(servers)} server(s) ready")
|
||||||
|
|
||||||
|
# Run the command
|
||||||
|
print(f"Running: {' '.join(args.command)}\n")
|
||||||
|
result = subprocess.run(args.command)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up all servers
|
||||||
|
print(f"\nStopping {len(server_processes)} server(s)...")
|
||||||
|
for i, process in enumerate(server_processes):
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
print(f"Server {i+1} stopped")
|
||||||
|
print("All servers stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
PUBLIC_ORIGIN=https://blog.example.com
|
PUBLIC_ORIGIN=https://blog.example.com
|
||||||
SITE_ADDRESS=blog.example.com
|
SITE_ADDRESS=blog.example.com
|
||||||
|
SITE_TITLE=ChuBlog
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
# Generate with chublog-api hash-password; keep single quotes around the hash.
|
# Generate with chublog-api hash-password; keep single quotes around the hash.
|
||||||
ADMIN_PASSWORD_HASH='$argon2id$REPLACE_WITH_GENERATED_HASH'
|
ADMIN_PASSWORD_HASH='$argon2id$REPLACE_WITH_GENERATED_HASH'
|
||||||
@@ -9,3 +10,6 @@ CHUSQL_PASSWORD=replace-with-different-random-index-password-at-least-16-charact
|
|||||||
SSH_PORT=2222
|
SSH_PORT=2222
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
HTTPS_PORT=443
|
HTTPS_PORT=443
|
||||||
|
|
||||||
|
# Tencent Cloud VPC image mirror. Outside Tencent Cloud, clear this value.
|
||||||
|
REGISTRY_PREFIX=mirror.ccs.tencentyun.com/library/
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
# ChuBlog
|
# ChuBlog
|
||||||
|
|
||||||
Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown,通过受限 SSH Git 推送发布;`index.txt` 管理文章清单。HTTP API 提供阅读、账户与发布授权,ChuSQL 保存可重建索引。
|
Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown,通过受限 SSH Git 推送发布;`index.toml` 管理文章清单、元信息和多页入口。HTTP API 提供阅读、账户与发布授权,ChuSQL 保存可重建索引。
|
||||||
|
|
||||||
当前仅提供后端,暂无前端和在线文章编辑。
|
提供 Rust/Axum 后端和 Astro + TypeScript 简单前端。首页为居中的施工说明与导航,支持文章阅读、注册登录和账户设置;文章继续通过 Git 发布。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [开发与依赖](docs/development.md) | 获取代码、双 HTTPS 依赖源、开发检查与联调 |
|
| [开发与依赖](docs/development.md) | 获取代码、双 HTTPS 依赖源、开发检查与联调 |
|
||||||
|
| [前端](docs/frontend.md) | 页面、本地开发、浏览器联调和项目技能 |
|
||||||
| [部署](docs/deployment.md) | Debian 13、Docker Compose、首次初始化 |
|
| [部署](docs/deployment.md) | Debian 13、Docker Compose、首次初始化 |
|
||||||
| [文章发布](docs/publishing.md) | 账户授权、个人仓库、文章清单与 Git 工作流 |
|
| [文章发布](docs/publishing.md) | 账户授权、个人仓库、文章清单与 Git 工作流 |
|
||||||
| [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 |
|
| [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ChuBlog — 后续待办计划
|
# ChuBlog — 后续待办计划
|
||||||
|
|
||||||
更新:2026-10-16。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。以下未勾选项均为待办,不代表已经实现。
|
更新:2026-10-16。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。已提供简单 Astro 前端与后端,Git 是唯一文章写入入口。以下未勾选项均为待办,不代表已经实现。
|
||||||
|
|
||||||
## 当前已实现
|
## 当前已实现
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
- [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。
|
- [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。
|
||||||
- [x] 每位作者独立 SSH 文章仓库与 Git 对象历史,公钥身份映射固定仓库别名,无法读取或改写他人仓库。
|
- [x] 每位作者独立 SSH 文章仓库与 Git 对象历史,公钥身份映射固定仓库别名,无法读取或改写他人仓库。
|
||||||
- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。
|
- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。
|
||||||
- [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。
|
- [x] TOML 元信息、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。
|
||||||
- [x] 按作者隔离 SSH 接收仓库与 API 私有 Git 缓存;三列 `index.txt` 登记文章 ID、目录和正文文件名,增删条目自动同步阅读快照和 ChuSQL 元信息;未登记文件不导入,草稿不出现在公开接口。
|
- [x] 按作者隔离 SSH 接收仓库与 API 私有 Git 缓存;`index.toml` 登记文章 ID、目录、六项作者元信息与默认 `main.md` 的入口;支持可达多页、索引错误暂存、入口缺失自动撤回和系统生成作者/时间,增删条目自动同步阅读快照和 ChuSQL 元信息;未登记文件不导入,草稿不出现在公开接口。
|
||||||
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
|
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
|
||||||
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
|
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
|
||||||
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
|
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
|
||||||
@@ -84,15 +84,17 @@
|
|||||||
- [ ] 为旧共享仓库迁移执行所有权核对、只导出最新文件的个人仓库重建及链接重定向;不得向作者分发旧共享历史。
|
- [ ] 为旧共享仓库迁移执行所有权核对、只导出最新文件的个人仓库重建及链接重定向;不得向作者分发旧共享历史。
|
||||||
- [ ] 在索引导入与公开发布之间加入站长审核:新增文章及后续修改先成为待审核版本,MASTER 决定通过或驳回;未通过内容不得公开,修改审核期间保留上一已通过版本,明确索引删除/撤回语义、审计与作者状态查询。本轮只保留该待办,不启用审核。
|
- [ ] 在索引导入与公开发布之间加入站长审核:新增文章及后续修改先成为待审核版本,MASTER 决定通过或驳回;未通过内容不得公开,修改审核期间保留上一已通过版本,明确索引删除/撤回语义、审计与作者状态查询。本轮只保留该待办,不启用审核。
|
||||||
|
|
||||||
## 第三优先级:Astro + TypeScript 阅读前端
|
## 第三优先级:前端完善
|
||||||
|
|
||||||
- [ ] SSR 读取后端已发布 API:首页、文章详情、标签、归档、404、暗色模式与移动端排版。
|
- [x] Astro + TypeScript SSR:全部居中的施工首页,文章列表、元信息搜索、标签筛选、分页、文章详情、多页链接、图片和 404,适配手机。
|
||||||
- [ ] 使用 Astro Markdown/remark/rehype 和 Shiki:持续禁用原始 HTML/MDX,并清理输出,元数据必须作为文本转义。
|
- [x] 注册、登录、账户、公钥设置、密码修改、退出和管理员发布授权页面,不增加在线文章写入口。
|
||||||
- [ ] 文章目录、SEO、canonical、Open Graph、RSS、sitemap、robots。
|
- [x] Markdown 原始 HTML 禁用及输出清理,元信息转义、相对页面/图片链接映射、CSP、Cookie/CSRF 与错误状态。
|
||||||
- [ ] 注册/登录/账户页面、SSH 公钥设置和管理员发布授权页面;不增加在线文章写入口。
|
- [x] 前端 Docker 镜像、Caddy 页面/API 同源分流;ChuSQL 仅运行数据库,不启用其 Web 管理端。
|
||||||
- [ ] 无障碍、错误状态、限流提示、安全 CSP 与浏览器端 XSS 回归验证。
|
- [ ] 归档、暗色模式、完整 SEO、canonical、Open Graph、RSS、sitemap、robots。
|
||||||
|
- [ ] 代码语法高亮、更丰富的文章目录及阅读导航。
|
||||||
|
- [ ] 扩展可访问性与跨浏览器检查,持续执行浏览器 XSS 回归验证。
|
||||||
|
|
||||||
验收:作者只需在本地编辑并 push,阅读端自动看到更新;草稿及资源无法由未登录访问者枚举或直链读取。
|
验收:作者在本地编辑并 push 后阅读端可自动获取更新;草稿及资源无法由公共接口读取。首页展示的完成状态需随实际功能更新。
|
||||||
|
|
||||||
## 第四优先级:发布和运维体验
|
## 第四优先级:发布和运维体验
|
||||||
|
|
||||||
|
|||||||
+23
-15
@@ -27,6 +27,8 @@ pub enum Status {
|
|||||||
pub struct Metadata {
|
pub struct Metadata {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub author: String,
|
||||||
|
#[serde(default)]
|
||||||
pub cover: Option<String>,
|
pub cover: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -107,6 +109,8 @@ pub fn slug(value: &str) -> Result<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stamp(post: &mut Post, previous: Option<&Post>, now: &str) {
|
pub fn stamp(post: &mut Post, previous: Option<&Post>, now: &str) {
|
||||||
|
// Legacy author-supplied dates are not trusted as server publication records.
|
||||||
|
let previous = previous.filter(|p| !p.meta.created_at.is_empty());
|
||||||
post.meta.created_at = previous
|
post.meta.created_at = previous
|
||||||
.map(|p| p.meta.created_at.clone())
|
.map(|p| p.meta.created_at.clone())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -284,19 +288,9 @@ pub enum Scan {
|
|||||||
Ready(Snapshot),
|
Ready(Snapshot),
|
||||||
Pending(String),
|
Pending(String),
|
||||||
}
|
}
|
||||||
impl Scan {
|
|
||||||
pub fn ready(self) -> Result<Snapshot> {
|
|
||||||
match self {
|
|
||||||
Self::Ready(snapshot) => Ok(snapshot),
|
|
||||||
Self::Pending(reason) => anyhow::bail!("索引待修复:{reason}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct Manifest {
|
struct Manifest {
|
||||||
#[serde(default)]
|
|
||||||
articles: Vec<ManifestEntry>,
|
articles: Vec<ManifestEntry>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -322,7 +316,8 @@ fn default_entry() -> String {
|
|||||||
pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
|
pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
|
||||||
ensure!(text.len() <= 65536, "index.toml 超过 64 KiB");
|
ensure!(text.len() <= 65536, "index.toml 超过 64 KiB");
|
||||||
validate_text(text)?;
|
validate_text(text)?;
|
||||||
let index: Manifest = toml::from_str(text).context("index.toml 格式或字段无效")?;
|
let index: Manifest = toml::from_str(text)
|
||||||
|
.map_err(|error: toml::de::Error| anyhow::anyhow!("index.toml: {}", error.message()))?;
|
||||||
ensure!(index.articles.len() <= 1000, "最多 1000 篇文章");
|
ensure!(index.articles.len() <= 1000, "最多 1000 篇文章");
|
||||||
let mut ids = std::collections::BTreeSet::new();
|
let mut ids = std::collections::BTreeSet::new();
|
||||||
let mut folders = std::collections::BTreeSet::new();
|
let mut folders = std::collections::BTreeSet::new();
|
||||||
@@ -361,6 +356,11 @@ pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
|
|||||||
.all(|t| !t.trim().is_empty() && t.len() <= 80),
|
.all(|t| !t.trim().is_empty() && t.len() <= 80),
|
||||||
"标签无效"
|
"标签无效"
|
||||||
);
|
);
|
||||||
|
validate_text(&article.title)?;
|
||||||
|
validate_text(&article.description)?;
|
||||||
|
for tag in &article.tags {
|
||||||
|
validate_text(tag)?;
|
||||||
|
}
|
||||||
if let Some(cover) = &article.cover {
|
if let Some(cover) = &article.cover {
|
||||||
filename(cover)?;
|
filename(cover)?;
|
||||||
}
|
}
|
||||||
@@ -450,7 +450,11 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
|||||||
ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额");
|
ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额");
|
||||||
let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?;
|
let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?;
|
||||||
if is_index {
|
if is_index {
|
||||||
index = Some(String::from_utf8(bytes).context("index.toml 必须为 UTF-8"));
|
let text = String::from_utf8(bytes).context("index.toml 必须为 UTF-8");
|
||||||
|
if let Ok(value) = &text {
|
||||||
|
validate_text(value)?;
|
||||||
|
}
|
||||||
|
index = Some(text);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let folder = grouped.entry(format!("posts/{}", parts[1])).or_default();
|
let folder = grouped.entry(format!("posts/{}", parts[1])).or_default();
|
||||||
@@ -482,9 +486,11 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
|||||||
let index = match index {
|
let index = match index {
|
||||||
Some(Ok(text)) => match manifest(&text) {
|
Some(Ok(text)) => match manifest(&text) {
|
||||||
Ok(index) => index,
|
Ok(index) => index,
|
||||||
Err(error) => return Ok(Scan::Pending(error.to_string())),
|
Err(error) => return Ok(Scan::Pending(error.to_string().chars().take(512).collect())),
|
||||||
},
|
},
|
||||||
Some(Err(error)) => return Ok(Scan::Pending(error.to_string())),
|
Some(Err(error)) => {
|
||||||
|
return Ok(Scan::Pending(error.to_string().chars().take(512).collect()));
|
||||||
|
}
|
||||||
None => return Ok(Scan::Pending("缺少 index.toml".into())),
|
None => return Ok(Scan::Pending("缺少 index.toml".into())),
|
||||||
};
|
};
|
||||||
let result = build_posts(index, &mut grouped, commit);
|
let result = build_posts(index, &mut grouped, commit);
|
||||||
@@ -495,7 +501,7 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
|
|||||||
revisions: BTreeMap::new(),
|
revisions: BTreeMap::new(),
|
||||||
pending: BTreeMap::new(),
|
pending: BTreeMap::new(),
|
||||||
})),
|
})),
|
||||||
Err(error) => Ok(Scan::Pending(error.to_string())),
|
Err(error) => Ok(Scan::Pending(error.to_string().chars().take(512).collect())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,6 +565,7 @@ fn build_posts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pages.insert(name, text.clone());
|
pages.insert(name, text.clone());
|
||||||
|
ensure!(pages.len() <= 64, "每篇文章最多 64 个可达页面");
|
||||||
}
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
images
|
images
|
||||||
@@ -577,6 +584,7 @@ fn build_posts(
|
|||||||
Post {
|
Post {
|
||||||
meta: Metadata {
|
meta: Metadata {
|
||||||
title: article.title,
|
title: article.title,
|
||||||
|
author: String::new(),
|
||||||
slug: id.clone(),
|
slug: id.clone(),
|
||||||
description: article.description,
|
description: article.description,
|
||||||
tags: article.tags,
|
tags: article.tags,
|
||||||
|
|||||||
+70
-5
@@ -46,13 +46,35 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if db
|
if db
|
||||||
.query("SELECT id FROM blog_articles LIMIT 1")
|
.query("SELECT id, created_at FROM blog_articles LIMIT 1")
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
db.query("CREATE TABLE blog_articles (id TEXT PRIMARY KEY, author TEXT, article_id TEXT, title TEXT, description TEXT, status TEXT, published_at TEXT, updated_at TEXT, path TEXT, format TEXT, git_commit TEXT)").await?;
|
// Only this rebuildable table changes shape; no identity/interaction data lives here.
|
||||||
|
if db
|
||||||
|
.query("SELECT id FROM blog_articles LIMIT 1")
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
db.query("DROP TABLE blog_articles").await?;
|
||||||
|
}
|
||||||
|
db.query("CREATE TABLE blog_articles (id TEXT PRIMARY KEY, author TEXT, article_id TEXT, title TEXT, description TEXT, status TEXT, created_at TEXT, published_at TEXT, updated_at TEXT, path TEXT, format TEXT, cover TEXT, entry TEXT, git_commit TEXT)").await?;
|
||||||
|
}
|
||||||
|
if db.query("SELECT id FROM blog_pages LIMIT 1").await.is_err() {
|
||||||
|
db.query("CREATE TABLE blog_pages (id TEXT, name TEXT, path TEXT, git_commit TEXT)")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if db
|
||||||
|
.query("SELECT id FROM blog_article_tags LIMIT 1")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
db.query("CREATE TABLE blog_article_tags (id TEXT, tag TEXT)")
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
db.query("BEGIN").await?;
|
db.query("BEGIN").await?;
|
||||||
|
db.query("DELETE FROM blog_pages").await?;
|
||||||
|
db.query("DELETE FROM blog_article_tags").await?;
|
||||||
db.query("DELETE FROM blog_articles").await?;
|
db.query("DELETE FROM blog_articles").await?;
|
||||||
for post in snapshot.posts.values() {
|
for post in snapshot.posts.values() {
|
||||||
let meta = &post.meta;
|
let meta = &post.meta;
|
||||||
@@ -60,14 +82,36 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
|||||||
Status::Draft => "draft",
|
Status::Draft => "draft",
|
||||||
Status::Published => "published",
|
Status::Published => "published",
|
||||||
};
|
};
|
||||||
let q = Query::new("INSERT INTO blog_articles (id, author, article_id, title, description, status, published_at, updated_at, path, format, git_commit) VALUES (")
|
let q = Query::new("INSERT INTO blog_articles (id, author, article_id, title, description, status, created_at, published_at, updated_at, path, format, cover, entry, git_commit) VALUES (")
|
||||||
.bind(meta.slug.as_str()).push(", ").bind(post.author.as_str()).push(", ")
|
.bind(meta.slug.as_str()).push(", ").bind(post.author.as_str()).push(", ")
|
||||||
.bind(post.article_id.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
|
.bind(post.article_id.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
|
||||||
.bind(meta.description.as_str()).push(", ").bind(status).push(", ")
|
.bind(meta.description.as_str()).push(", ").bind(status).push(", ")
|
||||||
.bind(meta.date.as_str()).push(", ").bind(meta.updated.as_str()).push(", ")
|
.bind(meta.created_at.as_str()).push(", ").bind(meta.date.as_str()).push(", ").bind(meta.updated.as_str()).push(", ")
|
||||||
.bind(post.path.as_str()).push(", ").bind(post.format.as_str()).push(", ")
|
.bind(post.path.as_str()).push(", ").bind(post.format.as_str()).push(", ")
|
||||||
|
.bind(meta.cover.as_deref().unwrap_or("")).push(", ").bind(post.entry.as_str()).push(", ")
|
||||||
.bind(post.git_commit.as_str()).push(")");
|
.bind(post.git_commit.as_str()).push(")");
|
||||||
db.query(q.as_str()).await?;
|
db.query(q.as_str()).await?;
|
||||||
|
let folder = post.path.rsplit_once('/').context("正文路径无效")?.0;
|
||||||
|
for name in post.pages.keys() {
|
||||||
|
let q = Query::new("INSERT INTO blog_pages (id, name, path, git_commit) VALUES (")
|
||||||
|
.bind(meta.slug.as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(name.as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(format!("{folder}/{name}").as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(post.git_commit.as_str())
|
||||||
|
.push(")");
|
||||||
|
db.query(q.as_str()).await?;
|
||||||
|
}
|
||||||
|
for tag in &meta.tags {
|
||||||
|
let q = Query::new("INSERT INTO blog_article_tags (id, tag) VALUES (")
|
||||||
|
.bind(meta.slug.as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(tag.as_str())
|
||||||
|
.push(")");
|
||||||
|
db.query(q.as_str()).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
db.query("DELETE FROM blog_tags").await?;
|
db.query("DELETE FROM blog_tags").await?;
|
||||||
db.query("DELETE FROM blog_posts").await?;
|
db.query("DELETE FROM blog_posts").await?;
|
||||||
@@ -169,7 +213,28 @@ pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
|
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
|
||||||
.collect();
|
.collect();
|
||||||
let matches = !articles.truncated
|
let page_count = db.query("SELECT count(*) FROM blog_pages").await?;
|
||||||
|
let all_tag_count = db.query("SELECT count(*) FROM blog_article_tags").await?;
|
||||||
|
let expected_pages = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.map(|p| p.pages.len())
|
||||||
|
.sum::<usize>() as u64;
|
||||||
|
let expected_all_tags = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.map(|p| p.meta.tags.len())
|
||||||
|
.sum::<usize>() as u64;
|
||||||
|
let count_value = |result: &chusql_driver::QueryResult| {
|
||||||
|
result
|
||||||
|
.rows
|
||||||
|
.first()
|
||||||
|
.and_then(|r| r.first())
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
};
|
||||||
|
let matches = count_value(&page_count) == Some(expected_pages)
|
||||||
|
&& count_value(&all_tag_count) == Some(expected_all_tags)
|
||||||
|
&& !articles.truncated
|
||||||
&& articles.rows.len() == expected_articles.len()
|
&& articles.rows.len() == expected_articles.len()
|
||||||
&& actual_articles == expected_articles
|
&& actual_articles == expected_articles
|
||||||
&& !result.truncated
|
&& !result.truncated
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ async fn page(
|
|||||||
.pages
|
.pages
|
||||||
.get(&name)
|
.get(&name)
|
||||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "页面不存在".into()))?;
|
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "页面不存在".into()))?;
|
||||||
|
app.views.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
json!({"meta":post.meta,"name":name,"markdown":text,"format":if name.ends_with(".txt") { "text" } else { "markdown" },
|
json!({"meta":post.meta,"name":name,"markdown":text,"format":if name.ends_with(".txt") { "text" } else { "markdown" },
|
||||||
"pages":post.pages.keys().map(|page| json!({"name":page,"url":format!("/api/posts/{key}/pages/{page}")})).collect::<Vec<_>>(),
|
"pages":post.pages.keys().map(|page| json!({"name":page,"url":format!("/api/posts/{key}/pages/{page}")})).collect::<Vec<_>>(),
|
||||||
|
|||||||
+2
-1
@@ -315,6 +315,7 @@ pub async fn import_authors(
|
|||||||
candidate.posts.retain(|_, post| post.author != *identity);
|
candidate.posts.retain(|_, post| post.author != *identity);
|
||||||
for (id, mut post) in snapshot.posts {
|
for (id, mut post) in snapshot.posts {
|
||||||
post.author = identity.clone();
|
post.author = identity.clone();
|
||||||
|
post.meta.author = identity.clone();
|
||||||
// Neither username nor local ID allows '~', so this is collision-free.
|
// Neither username nor local ID allows '~', so this is collision-free.
|
||||||
let key = format!("{identity}~{id}");
|
let key = format!("{identity}~{id}");
|
||||||
post.meta.slug = key.clone();
|
post.meta.slug = key.clone();
|
||||||
@@ -327,7 +328,7 @@ pub async fn import_authors(
|
|||||||
.posts
|
.posts
|
||||||
.values()
|
.values()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
p.markdown.len()
|
p.pages.values().map(String::len).sum::<usize>()
|
||||||
+ p.assets.iter().map(|a| a.data.len() * 3 / 4).sum::<usize>()
|
+ p.assets.iter().map(|a| a.data.len() * 3 / 4).sum::<usize>()
|
||||||
})
|
})
|
||||||
.sum();
|
.sum();
|
||||||
|
|||||||
+246
-159
@@ -4,11 +4,46 @@ use axum::http::Request;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
fn markdown(key: &str, status: &str, body: &str) -> String {
|
fn manifest(id: &str, folder: &str, public: bool) -> String {
|
||||||
format!(
|
format!(
|
||||||
"---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n"
|
"[[articles]]\nid = {id:?}\nfolder = {folder:?}\ntitle = \"Test\"\ndescription = \"Test article\"\ntags = [\"Rust\"]\npublic = {public}\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn ready(outcome: content::Scan) -> Snapshot {
|
||||||
|
match outcome {
|
||||||
|
content::Scan::Ready(snapshot) => snapshot,
|
||||||
|
content::Scan::Pending(reason) => panic!("unexpected staged index: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn test_post(id: &str, public: bool, body: &str) -> content::Post {
|
||||||
|
content::Post {
|
||||||
|
meta: content::Metadata {
|
||||||
|
title: "Test".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
slug: id.into(),
|
||||||
|
date: "2026-10-16T00:00:00Z".into(),
|
||||||
|
updated: "2026-10-16T00:00:00Z".into(),
|
||||||
|
created_at: "2026-10-16T00:00:00Z".into(),
|
||||||
|
status: if public {
|
||||||
|
Status::Published
|
||||||
|
} else {
|
||||||
|
Status::Draft
|
||||||
|
},
|
||||||
|
tags: vec!["Rust".into()],
|
||||||
|
description: "Test article".into(),
|
||||||
|
cover: None,
|
||||||
|
},
|
||||||
|
markdown: body.into(),
|
||||||
|
pages: BTreeMap::from([("main.md".into(), body.into())]),
|
||||||
|
entry: "main.md".into(),
|
||||||
|
assets: vec![],
|
||||||
|
author: "alice".into(),
|
||||||
|
article_id: id.into(),
|
||||||
|
path: "posts/first/main.md".into(),
|
||||||
|
git_commit: "test".into(),
|
||||||
|
format: "markdown".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
async fn commit(
|
async fn commit(
|
||||||
repo: &std::path::Path,
|
repo: &std::path::Path,
|
||||||
parent: Option<&str>,
|
parent: Option<&str>,
|
||||||
@@ -61,7 +96,7 @@ async fn commit_bytes(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
fn rejects_active_content_and_unsafe_links_but_keeps_inert_code() {
|
||||||
for body in [
|
for body in [
|
||||||
"<script>alert(1)</script>",
|
"<script>alert(1)</script>",
|
||||||
"<img src=x onerror=alert(1)>",
|
"<img src=x onerror=alert(1)>",
|
||||||
@@ -72,104 +107,127 @@ fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
|||||||
"a\u{202e}exe",
|
"a\u{202e}exe",
|
||||||
"[x](//evil.test)",
|
"[x](//evil.test)",
|
||||||
"[x](file:///etc/passwd)",
|
"[x](file:///etc/passwd)",
|
||||||
|
"[x](../other/main.md)",
|
||||||
|
"[x](%2e%2e%2fsecret.md)",
|
||||||
|
"[x](a.md?script=1)",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
content::parse(markdown("alice-test", "published", body), vec![]).is_err(),
|
content::validate_markdown(body, &[]).is_err(),
|
||||||
"accepted {body}"
|
"accepted {body}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(content::parse(markdown("../escape", "published", "Hello"), vec![]).is_err());
|
for body in [
|
||||||
assert!(
|
"[next](next.md#chapter)",
|
||||||
content::parse(
|
"[next](./next.markdown)",
|
||||||
markdown(
|
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)",
|
||||||
"alice-test",
|
] {
|
||||||
"published",
|
assert!(content::validate_markdown(body, &[]).is_ok());
|
||||||
&"x".repeat(content::MAX_MARKDOWN)
|
}
|
||||||
),
|
for (name, data) in [("hidden.svg", "PHN2Zz4="), ("fake.png", "PHNjcmlwdD4=")] {
|
||||||
vec![]
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
content::parse(
|
|
||||||
markdown(
|
|
||||||
"alice-test",
|
|
||||||
"published",
|
|
||||||
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)"
|
|
||||||
),
|
|
||||||
vec![]
|
|
||||||
)
|
|
||||||
.is_ok()
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
content::decode_asset(&content::Asset {
|
content::decode_asset(&content::Asset {
|
||||||
name: "hidden.svg".into(),
|
name: name.into(),
|
||||||
data: "PHN2Zz4=".into()
|
data: data.into()
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
content::decode_asset(&content::Asset {
|
|
||||||
name: "fake.png".into(),
|
|
||||||
data: "PHNjcmlwdD4=".into()
|
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn manifest_requires_unique_ids_safe_folders_and_bounded_text() {
|
fn toml_manifest_limits_author_fields_and_defaults_entry_to_main() {
|
||||||
let index =
|
let text = manifest("first", "posts/writing", true);
|
||||||
content::manifest("first posts/writing article.md\nsecond posts/notes body.markdown\n")
|
let index = content::manifest(&text).unwrap();
|
||||||
.unwrap();
|
assert_eq!(index[0].id, "first");
|
||||||
assert_eq!(index["first"].folder, "posts/writing");
|
assert_eq!(index[0].entry, "main.md");
|
||||||
assert_eq!(index["first"].file, "article.md");
|
for input in [
|
||||||
for text in [
|
format!("{text}author = \"master\""),
|
||||||
"first posts/a a.md\nfirst posts/b b.md",
|
format!("{text}date = \"2000-01-01\""),
|
||||||
"first posts/a a.md\nsecond posts/a b.md",
|
format!("{text}updated = \"2000-01-01\""),
|
||||||
"first ../escape",
|
format!("{text}entry = \"../secret.md\""),
|
||||||
"first posts/../escape",
|
format!("{text}entry = \".gitignore\""),
|
||||||
"first posts/.git",
|
format!("{text}entry = \"cert.pem\""),
|
||||||
"first posts/a ../a.md",
|
format!("{text}{}", manifest("first", "posts/second", true)),
|
||||||
"first posts/a .gitignore",
|
format!("{text}{}", manifest("second", "posts/writing", true)),
|
||||||
"first posts/a a.pem",
|
manifest("../escape", "posts/a", true),
|
||||||
"../escape posts/a",
|
manifest("first", "posts/../escape", true),
|
||||||
"first posts/a\0",
|
"".into(),
|
||||||
"-----BEGIN CERTIFICATE-----",
|
"[[articles".into(),
|
||||||
"first posts/a\u{202e}",
|
"articles = 1".into(),
|
||||||
|
" ".repeat(65537),
|
||||||
] {
|
] {
|
||||||
assert!(content::manifest(text).is_err(), "accepted {text}");
|
assert!(content::manifest(&input).is_err(), "accepted {input}");
|
||||||
}
|
}
|
||||||
assert!(content::manifest(&" ".repeat(65537)).is_err());
|
|
||||||
assert!(ssh::author_repository(std::path::Path::new("/private"), "../bob").is_err());
|
assert!(ssh::author_repository(std::path::Path::new("/private"), "../bob").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn git_manifest_controls_publication_and_validates_unlisted_files_and_history() {
|
async fn git_stages_invalid_index_but_rejects_unsafe_files_and_history() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let repo = content::init(dir.path()).await.unwrap();
|
let repo = content::init(dir.path()).await.unwrap();
|
||||||
let zero = "0000000000000000000000000000000000000000";
|
let zero = "0000000000000000000000000000000000000000";
|
||||||
let body = markdown("first", "published", "Hello");
|
let index = manifest("first", "posts/writing", true);
|
||||||
let files = [
|
let files = [
|
||||||
("index.txt", "100644", "first posts/writing index.md\n"),
|
("index.toml", "100644", index.as_str()),
|
||||||
("posts/writing/index.md", "100644", body.as_str()),
|
("posts/writing/main.md", "100644", "Hello"),
|
||||||
];
|
];
|
||||||
let first = commit(&repo, None, &files).await;
|
let first = commit(&repo, None, &files).await;
|
||||||
let snapshot = ssh::validate_change(&repo, zero, &first).await.unwrap();
|
let snapshot = ready(ssh::validate_change(&repo, zero, &first).await.unwrap());
|
||||||
assert_eq!(snapshot.posts["first"].path, "posts/writing/index.md");
|
assert_eq!(snapshot.posts["first"].path, "posts/writing/main.md");
|
||||||
let removed = commit(
|
let removed = commit(
|
||||||
&repo,
|
&repo,
|
||||||
Some(&first),
|
Some(&first),
|
||||||
&[("index.txt", "100644", ""), files[1]],
|
&[("index.toml", "100644", "articles = []"), files[1]],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
ssh::validate_change(&repo, &first, &removed)
|
ready(ssh::validate_change(&repo, &first, &removed).await.unwrap())
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.posts
|
.posts
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
// Unlisted content is inert but must never bypass the allowlist or validation.
|
let missing = commit(&repo, Some(&first), &[files[0]]).await;
|
||||||
|
assert!(
|
||||||
|
ready(content::scan_at(&repo, &missing).await.unwrap())
|
||||||
|
.posts
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
for bad_index in ["[[articles", "articles = 1"] {
|
||||||
|
let candidate = commit(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[("index.toml", "100644", bad_index), files[1]],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
ssh::validate_change(&repo, &first, &candidate)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
content::Scan::Pending(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let malicious_staged = commit(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[
|
||||||
|
("index.toml", "100644", "[[articles"),
|
||||||
|
(
|
||||||
|
"posts/writing/main.md",
|
||||||
|
"100644",
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
ssh::validate_change(&repo, &first, &malicious_staged)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"broken TOML must not bypass content security checks"
|
||||||
|
);
|
||||||
|
let no_index = commit(&repo, Some(&first), &[files[1]]).await;
|
||||||
|
assert!(matches!(
|
||||||
|
content::scan_at(&repo, &no_index).await.unwrap(),
|
||||||
|
content::Scan::Pending(_)
|
||||||
|
));
|
||||||
for (path, mode, value) in [
|
for (path, mode, value) in [
|
||||||
(".gitignore", "100644", "secret"),
|
(".gitignore", "100644", "secret"),
|
||||||
("posts/writing/cert.pem", "100644", "cert"),
|
("posts/writing/cert.pem", "100644", "cert"),
|
||||||
@@ -179,13 +237,13 @@ async fn git_manifest_controls_publication_and_validates_unlisted_files_and_hist
|
|||||||
"-----BEGIN PRIVATE KEY-----",
|
"-----BEGIN PRIVATE KEY-----",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"posts/writing/hidden.md",
|
"posts/unlisted/hidden.md",
|
||||||
"100644",
|
"100644",
|
||||||
"<script>alert(1)</script>",
|
"<script>alert(1)</script>",
|
||||||
),
|
),
|
||||||
("posts/writing/fake.png", "100644", "not an image"),
|
("posts/writing/fake.png", "100644", "not an image"),
|
||||||
("posts/other/index.md", "120000", "/etc/passwd"),
|
("posts/other/main.md", "120000", "/etc/passwd"),
|
||||||
("posts/other/index.md", "100755", "executable"),
|
("posts/other/main.md", "100755", "executable"),
|
||||||
("posts/writing/payload.bin", "100644", "binary"),
|
("posts/writing/payload.bin", "100644", "binary"),
|
||||||
] {
|
] {
|
||||||
let candidate = commit(
|
let candidate = commit(
|
||||||
@@ -201,74 +259,31 @@ async fn git_manifest_controls_publication_and_validates_unlisted_files_and_hist
|
|||||||
"accepted {path}"
|
"accepted {path}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let missing = commit(
|
let oversize = "x".repeat(content::MAX_MARKDOWN + 1);
|
||||||
|
let candidate = commit(
|
||||||
&repo,
|
&repo,
|
||||||
Some(&first),
|
Some(&first),
|
||||||
&[("index.txt", "100644", "first posts/missing index.md")],
|
&[files[0], ("posts/writing/main.md", "100644", &oversize)],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(content::scan_at(&repo, &missing).await.is_err());
|
assert!(
|
||||||
let duplicate_body = commit(
|
ssh::validate_change(&repo, &first, &candidate)
|
||||||
&repo,
|
|
||||||
Some(&first),
|
|
||||||
&[
|
|
||||||
files[0],
|
|
||||||
files[1],
|
|
||||||
("posts/writing/index.txt", "100644", body.as_str()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
content::scan_at(&repo, &duplicate_body)
|
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.is_err()
|
||||||
.posts["first"]
|
|
||||||
.path,
|
|
||||||
"posts/writing/index.md"
|
|
||||||
);
|
);
|
||||||
let skipped = commit(&repo, Some(&removed), &files).await;
|
let skipped = commit(&repo, Some(&removed), &files).await;
|
||||||
assert!(ssh::validate_change(&repo, &first, &skipped).await.is_err());
|
assert!(ssh::validate_change(&repo, &first, &skipped).await.is_err());
|
||||||
assert!(ssh::validate_change(&repo, &first, zero).await.is_err());
|
assert!(ssh::validate_change(&repo, &first, zero).await.is_err());
|
||||||
let plain = commit(
|
|
||||||
&repo,
|
|
||||||
Some(&first),
|
|
||||||
&[
|
|
||||||
("index.txt", "100644", "first posts/writing index.txt"),
|
|
||||||
("posts/writing/index.txt", "100644", body.as_str()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
content::scan_at(&repo, &plain).await.unwrap().posts["first"].format,
|
|
||||||
"text"
|
|
||||||
);
|
|
||||||
let draft_body = markdown("first", "draft", "Secret");
|
|
||||||
let draft = commit(
|
|
||||||
&repo,
|
|
||||||
Some(&first),
|
|
||||||
&[
|
|
||||||
("index.txt", "100644", "first posts/writing index.markdown"),
|
|
||||||
("posts/writing/index.markdown", "100644", &draft_body),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
content::persist(dir.path(), &content::scan_at(&repo, &draft).await.unwrap())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let public: Snapshot = serde_json::from_slice(
|
|
||||||
&tokio::fs::read(dir.path().join("published.json"))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(public.posts.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images() {
|
async fn multi_page_graph_and_cover_exclude_unlinked_documents_and_assets() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let repo = content::init(dir.path()).await.unwrap();
|
let repo = content::init(dir.path()).await.unwrap();
|
||||||
let body = markdown("first", "published", "").replace("slug: first\n", "");
|
let index = format!(
|
||||||
|
"{}entry = \"article.markdown\"\ncover = \"cover.png\"\n",
|
||||||
|
manifest("first", "posts/writing", true)
|
||||||
|
);
|
||||||
let mut image = std::io::Cursor::new(Vec::new());
|
let mut image = std::io::Cursor::new(Vec::new());
|
||||||
image::DynamicImage::new_rgb8(1, 1)
|
image::DynamicImage::new_rgb8(1, 1)
|
||||||
.write_to(&mut image, image::ImageFormat::Png)
|
.write_to(&mut image, image::ImageFormat::Png)
|
||||||
@@ -277,60 +292,93 @@ async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images()
|
|||||||
&repo,
|
&repo,
|
||||||
None,
|
None,
|
||||||
&[
|
&[
|
||||||
|
("index.toml", "100644", index.as_bytes()),
|
||||||
(
|
(
|
||||||
"index.txt",
|
"posts/writing/article.markdown",
|
||||||
"100644",
|
"100644",
|
||||||
b"first posts/writing article.markdown\n",
|
b"[Next](chapter.md#part)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"posts/writing/chapter.md",
|
||||||
|
"100644",
|
||||||
|
b"[Back](./article.markdown)\n",
|
||||||
),
|
),
|
||||||
("posts/writing/article.markdown", "100644", body.as_bytes()),
|
|
||||||
("posts/writing/notes.txt", "100644", b"Unselected notes"),
|
("posts/writing/notes.txt", "100644", b"Unselected notes"),
|
||||||
("posts/unlisted/draft.txt", "100644", b"Not registered"),
|
("posts/unlisted/draft.txt", "100644", b"Not registered"),
|
||||||
("posts/writing/cover.png", "100644", image.get_ref()),
|
("posts/writing/cover.png", "100644", image.get_ref()),
|
||||||
|
("posts/writing/figure.png", "100644", image.get_ref()),
|
||||||
("posts/writing/private.png", "100644", image.get_ref()),
|
("posts/writing/private.png", "100644", image.get_ref()),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let snapshot = content::scan_at(&repo, &commit).await.unwrap();
|
let snapshot = ready(content::scan_at(&repo, &commit).await.unwrap());
|
||||||
assert_eq!(snapshot.posts.len(), 1);
|
assert_eq!(snapshot.posts.len(), 1);
|
||||||
let post = &snapshot.posts["first"];
|
let post = &snapshot.posts["first"];
|
||||||
assert_eq!(post.path, "posts/writing/article.markdown");
|
assert_eq!(post.path, "posts/writing/article.markdown");
|
||||||
assert_eq!(post.meta.slug, "first");
|
assert_eq!(post.pages.len(), 2);
|
||||||
assert_eq!(post.assets.len(), 1);
|
assert_eq!(post.assets.len(), 2);
|
||||||
assert_eq!(post.assets[0].name, "cover.png");
|
assert!(!post.assets.iter().any(|a| a.name == "private.png"));
|
||||||
assert!(
|
assert!(
|
||||||
!serde_json::to_string(&snapshot)
|
!serde_json::to_string(&snapshot)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("Unselected notes")
|
.contains("Unselected notes")
|
||||||
);
|
);
|
||||||
|
let mut draft = snapshot.clone();
|
||||||
|
draft.posts.get_mut("first").unwrap().meta.status = Status::Draft;
|
||||||
|
content::persist(dir.path(), &draft).await.unwrap();
|
||||||
|
let public: Snapshot =
|
||||||
|
serde_json::from_slice(&std::fs::read(dir.path().join("published.json")).unwrap()).unwrap();
|
||||||
|
assert!(public.posts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timestamps_are_server_assigned_and_only_change_with_article_content() {
|
||||||
|
let mut post = test_post("first", false, "Hello");
|
||||||
|
post.meta.date.clear();
|
||||||
|
post.meta.created_at.clear();
|
||||||
|
post.meta.updated.clear();
|
||||||
|
content::stamp(&mut post, None, "2026-10-16T00:00:00Z");
|
||||||
|
assert_eq!(post.meta.date, "");
|
||||||
|
let initial = post.clone();
|
||||||
|
post.meta.status = Status::Published;
|
||||||
|
content::stamp(&mut post, Some(&initial), "2026-10-16T01:00:00Z");
|
||||||
|
assert_eq!(post.meta.date, "2026-10-16T01:00:00Z");
|
||||||
|
assert_eq!(post.meta.created_at, "2026-10-16T00:00:00Z");
|
||||||
|
let published = post.clone();
|
||||||
|
post.git_commit = "another unrelated commit".into();
|
||||||
|
content::stamp(&mut post, Some(&published), "2026-10-16T02:00:00Z");
|
||||||
|
assert_eq!(post.meta.updated, published.meta.updated);
|
||||||
|
let unchanged = post.clone();
|
||||||
|
post.pages
|
||||||
|
.insert("chapter.md".into(), "Changed second page".into());
|
||||||
|
content::stamp(&mut post, Some(&unchanged), "2026-10-16T03:00:00Z");
|
||||||
|
assert_eq!(post.meta.updated, "2026-10-16T03:00:00Z");
|
||||||
|
assert_eq!(post.meta.date, published.meta.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_recovery() {
|
async fn author_import_preserves_live_articles_on_bad_index_and_recovers_after_fix() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let ingress = dir.path().join("ingress");
|
let ingress = dir.path().join("ingress");
|
||||||
std::fs::create_dir(&ingress).unwrap();
|
std::fs::create_dir(&ingress).unwrap();
|
||||||
let alice = ssh::init_author(&ingress, "alice").await.unwrap();
|
let alice = ssh::init_author(&ingress, "alice").await.unwrap();
|
||||||
let bob = ssh::init_author(&ingress, "bob").await.unwrap();
|
let bob = ssh::init_author(&ingress, "bob").await.unwrap();
|
||||||
let a_body = markdown("first", "draft", "Alice private body");
|
let a_index = manifest("first", "posts/a", false);
|
||||||
let b_body = markdown("first", "published", "Bob public body");
|
let b_index = manifest("first", "posts/b", true);
|
||||||
let a = commit(
|
let a = commit(
|
||||||
&alice,
|
&alice,
|
||||||
None,
|
None,
|
||||||
&[
|
&[
|
||||||
("index.txt", "100644", "first posts/a index.md"),
|
("index.toml", "100644", &a_index),
|
||||||
("posts/a/index.md", "100644", &a_body),
|
("posts/a/main.md", "100644", "Alice private body"),
|
||||||
],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let b = commit(
|
|
||||||
&bob,
|
|
||||||
None,
|
|
||||||
&[
|
|
||||||
("index.txt", "100644", "first posts/b index.md"),
|
|
||||||
("posts/b/index.md", "100644", &b_body),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
let b_files = [
|
||||||
|
("index.toml", "100644", b_index.as_str()),
|
||||||
|
("posts/b/main.md", "100644", "Bob public body"),
|
||||||
|
];
|
||||||
|
let b = commit(&bob, None, &b_files).await;
|
||||||
content::git(&alice, &["update-ref", "refs/heads/main", &a], None)
|
content::git(&alice, &["update-ref", "refs/heads/main", &a], None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -340,8 +388,7 @@ async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_
|
|||||||
assert!(
|
assert!(
|
||||||
content::git(&alice, &["cat-file", "-t", &b], None)
|
content::git(&alice, &["cat-file", "-t", &b], None)
|
||||||
.await
|
.await
|
||||||
.is_err(),
|
.is_err()
|
||||||
"Bob objects leaked into Alice repository"
|
|
||||||
);
|
);
|
||||||
let data = dir.path().join("api");
|
let data = dir.path().join("api");
|
||||||
let names = vec!["alice".into(), "bob".into()];
|
let names = vec!["alice".into(), "bob".into()];
|
||||||
@@ -351,28 +398,49 @@ async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_
|
|||||||
assert_eq!(first.posts.len(), 2);
|
assert_eq!(first.posts.len(), 2);
|
||||||
assert_eq!(first.posts["alice~first"].author, "alice");
|
assert_eq!(first.posts["alice~first"].author, "alice");
|
||||||
assert_eq!(first.posts["bob~first"].article_id, "first");
|
assert_eq!(first.posts["bob~first"].article_id, "first");
|
||||||
let same = ssh::import_authors(&data, &ingress, &first, &names)
|
let broken = commit(
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(same.commit, first.commit);
|
|
||||||
let removed = commit(
|
|
||||||
&bob,
|
&bob,
|
||||||
Some(&b),
|
Some(&b),
|
||||||
&[
|
&[
|
||||||
("index.txt", "100644", ""),
|
("index.toml", "100644", "[[articles"),
|
||||||
("posts/b/index.md", "100644", &b_body),
|
("posts/b/main.md", "100644", "Changed pending body"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
content::git(&bob, &["update-ref", "refs/heads/main", &broken], None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let staged = ssh::import_authors(&data, &ingress, &first, &names)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(staged.commit, first.commit);
|
||||||
|
assert_eq!(staged.posts["bob~first"], first.posts["bob~first"]);
|
||||||
|
assert_eq!(staged.pending["bob"].commit, broken);
|
||||||
|
let staged_again = ssh::import_authors(&data, &ingress, &staged, &names)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(staged_again.pending, staged.pending);
|
||||||
|
let fixed = commit(&bob, Some(&broken), &b_files).await;
|
||||||
|
content::git(&bob, &["update-ref", "refs/heads/main", &fixed], None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let repaired = ssh::import_authors(&data, &ingress, &staged, &names)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(repaired.pending.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
repaired.posts["bob~first"].meta.updated,
|
||||||
|
first.posts["bob~first"].meta.updated
|
||||||
|
);
|
||||||
|
let removed = commit(&bob, Some(&fixed), &[("index.toml", "100644", &b_index)]).await;
|
||||||
content::git(&bob, &["update-ref", "refs/heads/main", &removed], None)
|
content::git(&bob, &["update-ref", "refs/heads/main", &removed], None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let next = ssh::import_authors(&data, &ingress, &first, &names)
|
let next = ssh::import_authors(&data, &ingress, &repaired, &names)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!next.posts.contains_key("bob~first"));
|
assert!(!next.posts.contains_key("bob~first"));
|
||||||
assert!(next.posts.contains_key("alice~first"));
|
assert!(next.posts.contains_key("alice~first"));
|
||||||
// Recover all authors from private Git caches after losing the derived collection.
|
|
||||||
let rebuilt = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
|
let rebuilt = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -476,8 +544,8 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
|
|||||||
let accounts = accounts::load(dir, &config, grants.repository)
|
let accounts = accounts::load(dir, &config, grants.repository)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let published = content::parse(markdown("alice-public", "published", "Hello"), vec![]).unwrap();
|
let published = test_post("alice-public", true, "Hello");
|
||||||
let draft = content::parse(markdown("alice-secret", "draft", "Secret"), vec![]).unwrap();
|
let draft = test_post("alice-secret", false, "Secret");
|
||||||
Arc::new(App {
|
Arc::new(App {
|
||||||
data: dir.into(),
|
data: dir.into(),
|
||||||
ssh_config: config,
|
ssh_config: config,
|
||||||
@@ -489,6 +557,7 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
|
|||||||
snapshot: RwLock::new(Snapshot {
|
snapshot: RwLock::new(Snapshot {
|
||||||
commit: "test".into(),
|
commit: "test".into(),
|
||||||
revisions: BTreeMap::new(),
|
revisions: BTreeMap::new(),
|
||||||
|
pending: BTreeMap::new(),
|
||||||
posts: BTreeMap::from([
|
posts: BTreeMap::from([
|
||||||
("alice-public".into(), published),
|
("alice-public".into(), published),
|
||||||
("alice-secret".into(), draft),
|
("alice-secret".into(), draft),
|
||||||
@@ -680,6 +749,24 @@ async fn registration_login_grants_revocation_and_private_content_boundaries() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
let (status, _, value) = request(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
"/api/posts/alice-public/pages/main.md",
|
||||||
|
Value::Null,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(value["markdown"], "Hello");
|
||||||
|
for path in [
|
||||||
|
"/api/posts/alice-secret/pages/main.md",
|
||||||
|
"/api/posts/alice-public/pages/unlinked.md",
|
||||||
|
] {
|
||||||
|
let (status, _, _) = request(app.clone(), "GET", path, Value::Null, None, None).await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await;
|
let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await;
|
||||||
assert_eq!(value["published_posts"], 1);
|
assert_eq!(value["published_posts"], 1);
|
||||||
let (status, _, _) = request(
|
let (status, _, _) = request(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
tests
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
API_BASE_URL=http://127.0.0.1:3001
|
||||||
|
SITE_TITLE=ChuBlog
|
||||||
|
PUBLIC_ORIGIN=http://127.0.0.1:4321
|
||||||
|
SSH_PORT=2222
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 前端约定
|
||||||
|
|
||||||
|
首页只承担施工说明与导航,标题、进度文字、入口和页脚全部居中。大标题使用用户指定的“个人博客正在施工中”,只列真实已完成和仍未完成的功能。
|
||||||
|
|
||||||
|
颜色为白色 `#ffffff`、正文 `#202a2f`、次要文字 `#64727a`、分隔线 `#dce3e7`、浅底 `#f5f7f8` 和强调色 `#315e75`。使用系统中文无衬线字体,不从外部字体服务加载资源。标题靠字号和留白区分层次,不使用渐变、大幅插画或装饰标签。
|
||||||
|
|
||||||
|
阅读页和表单按阅读习惯左对齐。文章列表使用自然分行,标题及元信息比封面优先。桌面阅读宽度最多 720px,移动端至少保留 20px 边距。页面提供键盘焦点、显式标签、错误信息和真实空状态。
|
||||||
|
|
||||||
|
设计复核时移除了初始方案中的暖色纸张和陶土强调色,改用中性白底及克制的蓝灰,避免套用通用博客模板。唯一醒目的元素是施工页的大标题。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ARG NODE_IMAGE=node:24-bookworm-slim
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY astro.config.mjs tsconfig.json ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build && npm prune --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM ${NODE_IMAGE}
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321 ASTRO_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||||
|
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /app/dist ./dist
|
||||||
|
USER node
|
||||||
|
EXPOSE 4321
|
||||||
|
CMD ["node", "dist/server/entry.mjs"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
session: false,
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
server: { port: 4321 },
|
||||||
|
build: { inlineStylesheets: 'never' },
|
||||||
|
vite: {
|
||||||
|
build: { assetsInlineLimit: 0 },
|
||||||
|
server: {
|
||||||
|
proxy: { '/api': process.env.API_BASE_URL || 'http://127.0.0.1:3001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+5772
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "chublog-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.22.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 127.0.0.1",
|
||||||
|
"build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
|
||||||
|
"start": "node dist/server/entry.mjs",
|
||||||
|
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
||||||
|
"test": "tsx --test tests/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "11.1.8",
|
||||||
|
"astro": "7.4.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"sanitize-html": "^2.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.6",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/node": "^24.19.2",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
"tsx": "^4.20.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#315e75"/><path d="M32 15a13 13 0 1 0 0 18" fill="none" stroke="white" stroke-width="4" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 222 B |
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
interface Props { title?: string; description?: string; home?: boolean }
|
||||||
|
const { title, description = '文章与随笔。', home = false } = Astro.props;
|
||||||
|
const site = process.env.SITE_TITLE || 'ChuBlog';
|
||||||
|
const path = Astro.url.pathname;
|
||||||
|
---
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>{title ? `${title} · ${site}` : site}</title>
|
||||||
|
</head>
|
||||||
|
<body class:list={{ 'home-page': home }}>
|
||||||
|
<a class="skip-link" href="#main">跳到正文</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="wordmark" href="/" aria-label={`${site} 首页`}>{site}</a>
|
||||||
|
<nav class="site-nav" aria-label="主导航">
|
||||||
|
<a href="/" aria-current={path === '/' ? 'page' : undefined}>首页</a>
|
||||||
|
<a href="/articles" aria-current={path.startsWith('/articles') || path.startsWith('/posts/') ? 'page' : undefined}>文章</a>
|
||||||
|
<a href="/login" data-account-link aria-current={path === '/account' || path === '/login' ? 'page' : undefined}>登录</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main id="main" tabindex="-1"><slot /></main>
|
||||||
|
<footer class="site-footer"><span>{site}</span><span>© {new Date().getFullYear()}</span></footer>
|
||||||
|
<script>import '../scripts/client';</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface Metadata {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
cover: string | null;
|
||||||
|
created_at: string;
|
||||||
|
date: string;
|
||||||
|
updated: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
}
|
||||||
|
export interface DocumentLink { name: string; url: string }
|
||||||
|
export interface Article {
|
||||||
|
meta: Metadata;
|
||||||
|
markdown: string;
|
||||||
|
format: 'markdown' | 'text';
|
||||||
|
entry?: string;
|
||||||
|
name?: string;
|
||||||
|
pages: DocumentLink[];
|
||||||
|
assets: DocumentLink[];
|
||||||
|
}
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public status: number) { super('文章服务暂时不可用'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readApi<T>(path: string): Promise<T> {
|
||||||
|
const base = process.env.API_BASE_URL || 'http://127.0.0.1:3001';
|
||||||
|
const response = await fetch(new URL(path, base), {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new ApiError(response.status);
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
export const postUrl = (slug: string) => `/posts/${encodeURIComponent(slug)}`;
|
||||||
|
export const pageUrl = (slug: string, name: string, entry?: string) =>
|
||||||
|
name === entry ? postUrl(slug) : `${postUrl(slug)}/${encodeURIComponent(name)}`;
|
||||||
|
export const assetUrl = (slug: string, name: string) =>
|
||||||
|
`/api/assets/${encodeURIComponent(slug)}/${encodeURIComponent(name)}`;
|
||||||
|
export function displayDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Shanghai',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
import { assetUrl, pageUrl, type Article } from './api';
|
||||||
|
|
||||||
|
export function renderMarkdown(article: Article, slug: string): string {
|
||||||
|
const pages = new Set(article.pages.map((page) => page.name));
|
||||||
|
const assets = new Set(article.assets.map((asset) => asset.name));
|
||||||
|
const parser = new MarkdownIt({ html: false, linkify: false, typographer: false });
|
||||||
|
const normalLink = parser.renderer.rules.link_open || ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
|
||||||
|
parser.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
||||||
|
const token = tokens[index];
|
||||||
|
const href = token.attrGet('href') || '';
|
||||||
|
if (!/^(?:https?:\/\/|mailto:|\/|#)/i.test(href)) {
|
||||||
|
const [path, ...fragment] = href.split('#');
|
||||||
|
const name = path.replace(/^\.\//, '');
|
||||||
|
token.attrSet('href', pages.has(name)
|
||||||
|
? pageUrl(slug, name, article.entry) + (fragment.length ? `#${fragment.join('#')}` : '') : '#');
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(href)) token.attrSet('rel', 'noopener noreferrer');
|
||||||
|
return normalLink(tokens, index, options, env, self);
|
||||||
|
};
|
||||||
|
const normalImage = parser.renderer.rules.image!;
|
||||||
|
parser.renderer.rules.image = (tokens, index, options, env, self) => {
|
||||||
|
const token = tokens[index];
|
||||||
|
const name = (token.attrGet('src') || '').split('#')[0].replace(/^\.\//, '');
|
||||||
|
if (!assets.has(name)) return '';
|
||||||
|
token.attrSet('src', assetUrl(slug, name));
|
||||||
|
token.attrSet('loading', 'lazy');
|
||||||
|
token.attrSet('decoding', 'async');
|
||||||
|
return normalImage(tokens, index, options, env, self);
|
||||||
|
};
|
||||||
|
const used = new Map<string, number>();
|
||||||
|
parser.core.ruler.push('heading-anchors', (state) => {
|
||||||
|
state.tokens.forEach((token, index) => {
|
||||||
|
if (token.type !== 'heading_open') return;
|
||||||
|
const text = state.tokens[index + 1]?.content || '';
|
||||||
|
const base = text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-|-$/g, '') || 'section';
|
||||||
|
const count = used.get(base) || 0;
|
||||||
|
used.set(base, count + 1);
|
||||||
|
token.attrSet('id', count ? `${base}-${count}` : base);
|
||||||
|
const level = Math.min(Number(token.tag.slice(1)) + 1, 6);
|
||||||
|
token.tag = `h${level}`;
|
||||||
|
const close = state.tokens[index + 2];
|
||||||
|
if (close?.type === 'heading_close') close.tag = token.tag;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return sanitizeHtml(parser.render(article.markdown), {
|
||||||
|
allowedTags: ['p', 'a', 'em', 'strong', 's', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'br', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'title', 'rel'], img: ['src', 'alt', 'title', 'loading', 'decoding'],
|
||||||
|
code: ['class'], ol: ['start'], h2: ['id'], h3: ['id'], h4: ['id'], h5: ['id'], h6: ['id'],
|
||||||
|
},
|
||||||
|
allowedClasses: { code: [/^language-[a-z0-9_-]+$/i] },
|
||||||
|
allowedSchemes: ['http', 'https', 'mailto'],
|
||||||
|
allowProtocolRelative: false,
|
||||||
|
transformTags: {
|
||||||
|
a: (_tag, attrs) => ({ tagName: 'a', attribs: attrs.href?.startsWith('//') ? {} : attrs }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||||
|
const response = await next();
|
||||||
|
response.headers.set('Cache-Control', 'no-store');
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
response.headers.set('Referrer-Policy', 'same-origin');
|
||||||
|
response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||||
|
return response;
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
---
|
||||||
|
<Base title="页面不存在"><div class="empty-state page-shell"><h1>页面不存在</h1><p>检查一下地址,或从首页重新开始。</p><a class="text-link" href="/">返回首页</a></div></Base>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
const sshPort = Number(process.env.SSH_PORT || 2222);
|
||||||
|
const hostname = new URL(process.env.PUBLIC_ORIGIN || Astro.url.origin).hostname;
|
||||||
|
const sshConfig = `Host chublog\n HostName ${hostname}\n Port ${sshPort}\n User git\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes`;
|
||||||
|
---
|
||||||
|
<Base title="账户">
|
||||||
|
<div class="account-shell">
|
||||||
|
<header class="page-heading"><h1>账户</h1><p id="account-status" role="status">正在读取账户…</p></header>
|
||||||
|
<div id="account-content" hidden>
|
||||||
|
<div class="account-identity"><div><strong id="account-name"></strong><span class="role-label" id="account-role"></span></div><button id="logout" class="button button-quiet" type="button">退出登录</button></div>
|
||||||
|
<section class="settings-section" aria-labelledby="key-heading">
|
||||||
|
<h2 id="key-heading">SSH 公钥</h2><p id="publish-status" class="section-description"></p>
|
||||||
|
<form id="key-form"><label for="public-key">公钥内容</label><textarea id="public-key" name="public_key" rows="4" maxlength="4096" required spellcheck="false" autocomplete="off" placeholder="ssh-ed25519 AAAA… 或 ssh-rsa AAAA…" aria-describedby="key-help"></textarea><p class="field-help" id="key-help">只粘贴 .pub 文件中的公钥,请勿上传私钥。保存后替换原公钥。</p><p class="form-status" role="status" aria-live="polite"></p><button class="button" type="submit">保存公钥</button></form>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section" aria-labelledby="publishing-heading">
|
||||||
|
<h2 id="publishing-heading">从本地发布文章</h2><p class="section-description">获得站长授权后,配置 SSH 并克隆自己的文章仓库。</p>
|
||||||
|
<ol class="publishing-steps"><li><p>在本机生成密钥,并将公钥保存在上方。</p><pre><code>ssh-keygen -t ed25519</code></pre></li><li><p>在本机 ~/.ssh/config 中加入连接配置。首次连接时核对服务器指纹。</p><pre><code>{sshConfig}</code></pre></li><li><p>克隆仓库,创建索引和正文,再提交推送。</p><pre><code>git clone chublog:content.git my-articles
|
||||||
|
cd my-articles
|
||||||
|
# 创建 index.toml 和 posts/first/main.md
|
||||||
|
git add index.toml posts/first
|
||||||
|
git commit -m 'Publish article'
|
||||||
|
git push -u origin main</code></pre></li></ol>
|
||||||
|
<details><summary>查看 index.toml 示例</summary><pre><code>{`[[articles]]\nid = "first"\nfolder = "posts/first"\ntitle = "第一篇文章"\ndescription = "文章简介"\ntags = ["随笔"]\npublic = true\n# entry = "main.md"`}</code></pre><p class="field-help">作者和时间由系统生成。入口默认 main.md,正文不需要 Frontmatter。</p></details>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section" aria-labelledby="password-heading"><h2 id="password-heading">修改密码</h2><form id="password-form"><label for="old-password">原密码</label><input id="old-password" type="password" name="old_password" autocomplete="current-password" required maxlength="1024" /><label for="new-password">新密码</label><input id="new-password" type="password" name="new_password" autocomplete="new-password" required maxlength="1024" /><p class="form-status" role="status" aria-live="polite"></p><button class="button button-quiet" type="submit">修改密码</button></form></section>
|
||||||
|
<section id="admin-section" class="settings-section" aria-labelledby="admin-heading" hidden><h2 id="admin-heading">发布授权</h2><p class="section-description">公钥登记与发布授权分别检查。停用账户会同时撤销登录和发布权限。</p><p id="admin-status" class="form-status" role="status" aria-live="polite"></p><div id="admin-users" class="admin-users"></div><h3>暂存的索引</h3><div id="pending-indexes"></div></section>
|
||||||
|
</div>
|
||||||
|
<noscript><p class="notice">账户设置需要启用 JavaScript。</p></noscript>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { readApi, postUrl, displayDate, assetUrl, type Metadata } from '../lib/api';
|
||||||
|
const query = (Astro.url.searchParams.get('q') || '').trim().slice(0, 100);
|
||||||
|
const selectedTag = Astro.url.searchParams.get('tag') || '';
|
||||||
|
let articles: Metadata[] = [];
|
||||||
|
let failed = false;
|
||||||
|
try { articles = (await readApi<{ posts: Metadata[] }>('/api/posts')).posts; }
|
||||||
|
catch { failed = true; Astro.response.status = 503; }
|
||||||
|
const tags = [...new Set(articles.flatMap((article) => article.tags))].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
|
const filtered = articles.filter((article) => (!selectedTag || article.tags.includes(selectedTag)) && (!query || `${article.title} ${article.description} ${article.author} ${article.tags.join(' ')}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())));
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / 15));
|
||||||
|
const page = Math.min(totalPages, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') || '1', 10) || 1));
|
||||||
|
const visible = filtered.slice((page - 1) * 15, page * 15);
|
||||||
|
const urlFor = (page: number) => { const params = new URLSearchParams(); if (query) params.set('q', query); if (selectedTag) params.set('tag', selectedTag); params.set('page', String(page)); return `/articles?${params}`; };
|
||||||
|
---
|
||||||
|
<Base title="文章">
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="page-heading"><h1>文章</h1><p>写下的文字,按时间排列。</p></header>
|
||||||
|
<form class="search-form" action="/articles" method="get" role="search">
|
||||||
|
<label class="visually-hidden" for="search">搜索文章</label>
|
||||||
|
<input id="search" type="search" autocomplete="off" name="q" value={query} placeholder="搜索标题、简介或标签" maxlength="100" />
|
||||||
|
{selectedTag && <input type="hidden" name="tag" value={selectedTag} />}
|
||||||
|
<button type="submit" class="button button-quiet">搜索</button>
|
||||||
|
</form>
|
||||||
|
{tags.length > 0 && <nav class="tag-filter" aria-label="按标签筛选">
|
||||||
|
<a href="/articles" aria-current={!selectedTag ? 'true' : undefined}>全部</a>
|
||||||
|
{tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`} aria-current={tag === selectedTag ? 'true' : undefined}>{tag}</a>)}
|
||||||
|
</nav>}
|
||||||
|
{failed ? <div class="empty-state" role="status"><h2>文章暂时无法加载</h2><p>请稍后重试。</p><a class="text-link" href={Astro.url.pathname + Astro.url.search}>重新加载</a></div>
|
||||||
|
: visible.length === 0 ? <div class="empty-state"><h2>{query || selectedTag ? '没有匹配的文章' : '还没有公开文章'}</h2><p>{query || selectedTag ? '换个关键词,或查看全部文章。' : '文章发布后会出现在这里。'}</p><a class="text-link" href={query || selectedTag ? '/articles' : '/account'}>{query || selectedTag ? '查看全部文章' : '前往账户'}</a></div>
|
||||||
|
: <ol class="article-list">{visible.map((article) => <li>
|
||||||
|
<article class:list={{ 'article-with-cover': article.cover }}>
|
||||||
|
<div class="article-summary">
|
||||||
|
<div class="article-meta"><time datetime={article.date}>{displayDate(article.date)}</time><span>{article.author}</span></div>
|
||||||
|
<h2><a href={postUrl(article.slug)}>{article.title}</a></h2>
|
||||||
|
{article.description && <p>{article.description}</p>}
|
||||||
|
{article.tags.length > 0 && <div class="article-tags">{article.tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`}>{tag}</a>)}</div>}
|
||||||
|
</div>
|
||||||
|
{article.cover && <a href={postUrl(article.slug)} class="article-cover" aria-label={`阅读 ${article.title}`}><img src={assetUrl(article.slug, article.cover)} alt="" width="160" height="112" loading="lazy" /></a>}
|
||||||
|
</article>
|
||||||
|
</li>)}</ol>}
|
||||||
|
{totalPages > 1 && <nav class="pagination" aria-label="文章分页">{page > 1 ? <a href={urlFor(page - 1)}>上一页</a> : <span /> }<span>{page} / {totalPages}</span>{page < totalPages ? <a href={urlFor(page + 1)}>下一页</a> : <span />}</nav>}
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
---
|
||||||
|
<Base home title="个人博客正在施工中" description="个人博客正在施工中。已完成文章阅读、账户登录与 Git 发布,其他功能逐步完善。">
|
||||||
|
<section class="construction" aria-labelledby="construction-title">
|
||||||
|
<h1 id="construction-title">个人博客正在施工中</h1>
|
||||||
|
<div class="construction-progress">
|
||||||
|
<section aria-labelledby="finished-title">
|
||||||
|
<h2 id="finished-title">已完成</h2>
|
||||||
|
<p>文章阅读与多页导航、用户注册与登录、SSH 公钥设置、Git 文章发布</p>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="pending-title">
|
||||||
|
<h2 id="pending-title">未完成</h2>
|
||||||
|
<p>评论、点赞与收藏、热度推荐、内部文章、站长审核</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<nav class="home-links" aria-label="快捷入口">
|
||||||
|
<a class="button" href="/articles">阅读文章</a>
|
||||||
|
<a class="text-link" href="/account">账户与发布</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
const register = Astro.url.searchParams.get('mode') === 'register';
|
||||||
|
---
|
||||||
|
<Base title={register ? '注册' : '登录'}>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<header class="page-heading"><h1>{register ? '注册账户' : '登录'}</h1><p>{register ? '注册后可在账户中登记发布用的 SSH 公钥。' : '管理账户和文章发布权限。'}</p></header>
|
||||||
|
<nav class="auth-tabs" aria-label="账户入口"><a href="/login" aria-current={!register ? 'page' : undefined}>登录</a><a href="/login?mode=register" aria-current={register ? 'page' : undefined}>注册</a></nav>
|
||||||
|
{Astro.url.searchParams.get('changed') === '1' && <p class="notice">密码已修改,请重新登录。</p>}
|
||||||
|
{Astro.url.searchParams.get('registered') === '1' && <p class="notice">账户已创建,请登录。</p>}
|
||||||
|
<form id="auth-form" data-mode={register ? 'register' : 'login'}>
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input id="username" name="username" spellcheck="false" autocapitalize="none" autocomplete="username" required minlength="3" maxlength="32" pattern="[a-z][a-z0-9-]{2,31}" aria-describedby={register ? 'username-help' : undefined} />
|
||||||
|
{register && <p class="field-help" id="username-help">3 至 32 位小写字母、数字或短横线,以字母开头。</p>}
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input id="password" type="password" name="password" autocomplete={register ? 'new-password' : 'current-password'} required maxlength="1024" aria-describedby={register ? 'password-help' : undefined} />
|
||||||
|
{register && <p class="field-help" id="password-help">至少 12 个字节,请使用较长且不重复的密码。</p>}
|
||||||
|
{register && <><label for="password-confirm">确认密码</label><input id="password-confirm" type="password" name="confirm" autocomplete="new-password" required maxlength="1024" /></>}
|
||||||
|
<p class="form-status" role="status" aria-live="polite"></p>
|
||||||
|
<button class="button" type="submit">{register ? '创建账户' : '登录'}</button>
|
||||||
|
</form>
|
||||||
|
<noscript><p class="notice">登录与注册需要启用 JavaScript。</p></noscript>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import { ApiError, readApi, pageUrl, displayDate, type Article } from '../../lib/api';
|
||||||
|
import { renderMarkdown } from '../../lib/markdown';
|
||||||
|
const segments = (Astro.params.path || '').split('/');
|
||||||
|
const [slug, name] = segments;
|
||||||
|
let article: Article | undefined;
|
||||||
|
let missing = false;
|
||||||
|
try {
|
||||||
|
if (segments.length > 2 || !/^[a-z0-9-]+~[a-z0-9-]+$/.test(slug || '') || (name && !/^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*\.(md|markdown|txt)$/.test(name))) throw new ApiError(404);
|
||||||
|
article = await readApi<Article>(name ? `/api/posts/${encodeURIComponent(slug)}/pages/${encodeURIComponent(name)}` : `/api/posts/${encodeURIComponent(slug)}`);
|
||||||
|
} catch (error) { missing = error instanceof ApiError && error.status === 404; Astro.response.status = missing ? 404 : 503; }
|
||||||
|
let html = '';
|
||||||
|
if (article?.format === 'markdown') html = renderMarkdown(article, slug);
|
||||||
|
const currentPage = name || article?.entry;
|
||||||
|
---
|
||||||
|
<Base title={article?.meta.title || (missing ? '文章不存在' : '暂时无法读取')} description={article?.meta.description}>
|
||||||
|
{article ? <div class="reading-shell">
|
||||||
|
<a class="back-link" href="/articles">返回文章</a>
|
||||||
|
<header class="article-heading">
|
||||||
|
<div class="article-meta"><span>{article.meta.author}</span><time datetime={article.meta.date}>{displayDate(article.meta.date)}</time></div>
|
||||||
|
<h1>{article.meta.title}</h1>
|
||||||
|
{article.meta.description && <p class="article-description">{article.meta.description}</p>}
|
||||||
|
{article.meta.tags.length > 0 && <div class="article-tags">{article.meta.tags.map((tag) => <a href={`/articles?tag=${encodeURIComponent(tag)}`}>{tag}</a>)}</div>}
|
||||||
|
</header>
|
||||||
|
{article.pages.length > 1 && <nav class="document-nav" aria-label="文章页面">{article.pages.map((page) => <a href={pageUrl(slug, page.name, article.entry)} aria-current={page.name === currentPage ? 'page' : undefined}>{page.name === (article.entry || 'main.md') ? '正文' : page.name.replace(/\.(md|markdown|txt)$/, '').replace(/[-_]/g, ' ')}</a>)}</nav>}
|
||||||
|
{article.format === 'text' ? <pre class="plain-text">{article.markdown}</pre> : <div class="prose" set:html={html} />}
|
||||||
|
<footer class="article-end"><p>最后修改于 <time datetime={article.meta.updated}>{displayDate(article.meta.updated)}</time></p><a class="text-link" href="/articles">查看其他文章</a></footer>
|
||||||
|
</div> : <div class="empty-state page-shell"><h1>{missing ? '文章不存在' : '暂时无法读取文章'}</h1><p>{missing ? '文章可能已撤回,或尚未公开。' : '请稍后重试。'}</p><a class="text-link" href="/articles">返回文章列表</a></div>}
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
export {};
|
||||||
|
type Role = 'reader' | 'publisher' | 'admin';
|
||||||
|
interface Grant { enabled: boolean; role: Role; ssh_key: string | null }
|
||||||
|
interface Account { username: string; permissions: Grant; csrf: string }
|
||||||
|
let csrf: string | undefined;
|
||||||
|
const roles: Record<Role, string> = { reader: '普通用户', publisher: '作者', admin: '管理员' };
|
||||||
|
|
||||||
|
async function api<T>(path: string, method = 'GET', data?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
if (data !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (csrf && method !== 'GET') headers['X-CSRF-Token'] = csrf;
|
||||||
|
const response = await fetch(path, { method, credentials: 'same-origin', headers,
|
||||||
|
body: data === undefined ? undefined : JSON.stringify(data), signal: AbortSignal.timeout(10000) });
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(result?.error || (response.status === 429 ? '请求过于频繁,请稍后重试。' : '操作未完成,请稍后重试。'));
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
function showStatus(element: HTMLElement | null, message: string, error = false) {
|
||||||
|
if (!element) return;
|
||||||
|
element.textContent = message;
|
||||||
|
element.dataset.error = String(error);
|
||||||
|
}
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.name !== 'TimeoutError' && error.name !== 'TypeError'
|
||||||
|
? error.message : '无法连接服务器,请检查网络后重试。';
|
||||||
|
}
|
||||||
|
function formAction(id: string, action: (data: FormData, form: HTMLFormElement) => Promise<void>) {
|
||||||
|
const form = document.querySelector<HTMLFormElement>(`#${id}`);
|
||||||
|
form?.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
|
||||||
|
const message = form.querySelector<HTMLElement>('.form-status');
|
||||||
|
if (button.disabled) return;
|
||||||
|
button.disabled = true;
|
||||||
|
form.setAttribute('aria-busy', 'true');
|
||||||
|
showStatus(message, '正在处理…');
|
||||||
|
try { await action(new FormData(form), form); }
|
||||||
|
catch (error) { showStatus(message, errorMessage(error), true); }
|
||||||
|
finally { button.disabled = false; form.removeAttribute('aria-busy'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const value = (data: FormData, name: string) => String(data.get(name) || '');
|
||||||
|
function checkPassword(password: string) {
|
||||||
|
const size = new TextEncoder().encode(password).length;
|
||||||
|
if (size < 12 || size > 1024) throw new Error('密码需要在 12 至 1024 个字节之间。');
|
||||||
|
}
|
||||||
|
|
||||||
|
formAction('auth-form', async (data, form) => {
|
||||||
|
const credentials = { username: value(data, 'username'), password: value(data, 'password') };
|
||||||
|
if (form.dataset.mode === 'register') {
|
||||||
|
checkPassword(credentials.password);
|
||||||
|
if (credentials.password !== value(data, 'confirm')) throw new Error('两次输入的密码不一致。');
|
||||||
|
await api('/api/auth/register', 'POST', credentials);
|
||||||
|
// Registration succeeded even if the following login is temporarily rate-limited.
|
||||||
|
try { await api('/api/auth/login', 'POST', credentials); }
|
||||||
|
catch { location.assign('/login?registered=1'); return; }
|
||||||
|
} else { await api('/api/auth/login', 'POST', credentials); }
|
||||||
|
form.reset();
|
||||||
|
location.assign('/account');
|
||||||
|
});
|
||||||
|
|
||||||
|
formAction('key-form', async (data, form) => {
|
||||||
|
const key = value(data, 'public_key').trim();
|
||||||
|
if (key.includes('PRIVATE KEY')) throw new Error('这里需要 .pub 公钥,请勿提交私钥。');
|
||||||
|
await api('/api/account/ssh-key', 'PUT', { public_key: key });
|
||||||
|
showStatus(form.querySelector('.form-status'), '公钥已保存。发布仍需站长授权。');
|
||||||
|
});
|
||||||
|
formAction('password-form', async (data, form) => {
|
||||||
|
const password = value(data, 'new_password');
|
||||||
|
checkPassword(password);
|
||||||
|
await api('/api/account/password', 'PUT', { old_password: value(data, 'old_password'), new_password: password });
|
||||||
|
form.reset();
|
||||||
|
csrf = undefined;
|
||||||
|
location.assign('/login?changed=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector<HTMLButtonElement>('#logout')?.addEventListener('click', async (event) => {
|
||||||
|
const button = event.currentTarget as HTMLButtonElement;
|
||||||
|
button.disabled = true;
|
||||||
|
try { await api('/api/auth/logout', 'POST', {}); csrf = undefined; location.assign('/'); }
|
||||||
|
catch (error) { showStatus(document.querySelector('#account-status'), errorMessage(error), true); button.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function element<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
async function loadAdmin(account: Account) {
|
||||||
|
const section = document.querySelector<HTMLElement>('#admin-section')!;
|
||||||
|
section.hidden = false;
|
||||||
|
try {
|
||||||
|
const [users, state] = await Promise.all([
|
||||||
|
api<Record<string, Grant>>('/api/admin/users'),
|
||||||
|
api<{ pending: Record<string, { commit: string; reason: string }> }>('/api/admin/status'),
|
||||||
|
]);
|
||||||
|
const list = document.querySelector<HTMLElement>('#admin-users')!;
|
||||||
|
for (const [username, grant] of Object.entries(users)) {
|
||||||
|
const form = element('form'); form.className = 'admin-user';
|
||||||
|
form.append(element('h4', username === account.username ? `${username}(当前账户)` : username));
|
||||||
|
if (username === account.username) { form.append(element('p', '当前管理员')); list.append(form); continue; }
|
||||||
|
const controls = element('div'); controls.className = 'admin-user-controls';
|
||||||
|
const select = element('select'); select.setAttribute('aria-label', `${username} 的角色`);
|
||||||
|
for (const [role, label] of Object.entries(roles)) {
|
||||||
|
const option = element('option', label); option.value = role; option.selected = role === grant.role; select.append(option);
|
||||||
|
}
|
||||||
|
const label = element('label'); const enabled = element('input'); enabled.type = 'checkbox'; enabled.checked = grant.enabled;
|
||||||
|
label.append(enabled, document.createTextNode('启用账户'));
|
||||||
|
const button = element('button', '保存授权'); button.className = 'button button-quiet'; button.type = 'submit';
|
||||||
|
controls.append(select, label, button);
|
||||||
|
const message = element('p'); message.className = 'form-status'; message.setAttribute('role', 'status');
|
||||||
|
form.append(controls, message);
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault(); if (button.disabled) return; button.disabled = true;
|
||||||
|
try { await api(`/api/admin/users/${encodeURIComponent(username)}`, 'PATCH', { enabled: enabled.checked, role: select.value }); showStatus(message, '授权已保存。'); }
|
||||||
|
catch (error) { showStatus(message, errorMessage(error), true); }
|
||||||
|
finally { button.disabled = false; }
|
||||||
|
});
|
||||||
|
list.append(form);
|
||||||
|
}
|
||||||
|
const pending = document.querySelector<HTMLElement>('#pending-indexes')!;
|
||||||
|
const entries = Object.entries(state.pending);
|
||||||
|
if (!entries.length) pending.append(element('p', '没有待修复的索引。'));
|
||||||
|
for (const [author, item] of entries) {
|
||||||
|
const row = element('div'); row.className = 'pending-item'; row.append(element('strong', `${author} / ${item.commit.slice(0, 8)}`), element('p', item.reason)); pending.append(row);
|
||||||
|
}
|
||||||
|
} catch (error) { showStatus(document.querySelector('#admin-status'), errorMessage(error), true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount() {
|
||||||
|
const content = document.querySelector<HTMLElement>('#account-content');
|
||||||
|
try {
|
||||||
|
const account = await api<Account>('/api/account');
|
||||||
|
csrf = account.csrf;
|
||||||
|
const link = document.querySelector<HTMLAnchorElement>('[data-account-link]');
|
||||||
|
if (link) { link.href = '/account'; link.textContent = '账户'; }
|
||||||
|
if (!content) return;
|
||||||
|
content.hidden = false;
|
||||||
|
showStatus(document.querySelector('#account-status'), '');
|
||||||
|
document.querySelector('#account-name')!.textContent = account.username;
|
||||||
|
document.querySelector('#account-role')!.textContent = roles[account.permissions.role];
|
||||||
|
document.querySelector<HTMLTextAreaElement>('#public-key')!.value = account.permissions.ssh_key || '';
|
||||||
|
document.querySelector('#publish-status')!.textContent = account.permissions.role === 'reader'
|
||||||
|
? '发布文章需要登记公钥,并由站长授予发布权限。'
|
||||||
|
: account.permissions.ssh_key ? '已获得发布权限,可以使用下方配置连接个人仓库。' : '已获得发布权限,登记公钥后即可连接个人仓库。';
|
||||||
|
if (account.permissions.role === 'admin') await loadAdmin(account);
|
||||||
|
} catch (error) {
|
||||||
|
if (!content) return;
|
||||||
|
showStatus(document.querySelector('#account-status'), errorMessage(error), true);
|
||||||
|
const link = element('a', '前往登录'); link.href = '/login'; link.className = 'text-link';
|
||||||
|
document.querySelector('#account-status')!.after(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadAccount();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #fff;
|
||||||
|
--ink: #202a2f;
|
||||||
|
--muted: #64727a;
|
||||||
|
--line: #dce3e7;
|
||||||
|
--soft: #f5f7f8;
|
||||||
|
--accent: #315e75;
|
||||||
|
--error: #a53432;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100svh; display: flex; flex-direction: column; }
|
||||||
|
main { flex: 1; outline: none; }
|
||||||
|
a { color: inherit; text-decoration: none; text-underline-offset: .24em; }
|
||||||
|
a:hover { color: var(--accent); }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; }
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button, select, summary { cursor: pointer; }
|
||||||
|
button:disabled { cursor: wait; opacity: .55; }
|
||||||
|
button, input, textarea, select { touch-action: manipulation; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
::selection { background: #dbe9f0; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1, h2, h3 { overflow-wrap: anywhere; text-wrap: balance; }
|
||||||
|
p { line-height: 1.8; }
|
||||||
|
.skip-link { position: fixed; left: 16px; top: -100px; z-index: 10; padding: 10px 16px; background: var(--ink); color: white; border-radius: 4px; }
|
||||||
|
.skip-link:focus { top: 12px; color: white; }
|
||||||
|
.site-header { padding: 42px 24px 28px; text-align: center; }
|
||||||
|
.wordmark { font-size: 19px; font-weight: 650; letter-spacing: -.035em; }
|
||||||
|
.site-nav { display: flex; justify-content: center; gap: 32px; margin-top: 21px; font-size: 13px; color: var(--muted); }
|
||||||
|
.site-nav a { padding: 5px 0; border-bottom: 1px solid transparent; }
|
||||||
|
.site-nav a[aria-current="page"] { color: var(--ink); border-color: var(--ink); }
|
||||||
|
.site-footer { display: flex; justify-content: center; gap: 12px; padding: 40px 24px 28px; color: var(--muted); font-size: 12px; text-align: center; }
|
||||||
|
.button { display: inline-flex; justify-content: center; align-items: center; min-height: 44px; padding: 11px 22px; border: 1px solid var(--accent); border-radius: 5px; background: var(--accent); color: white; font-size: 14px; font-weight: 500; line-height: 1.4; }
|
||||||
|
.button:hover { background: #25495b; border-color: #25495b; color: white; }
|
||||||
|
.button-quiet { color: var(--ink); background: white; border-color: var(--line); }
|
||||||
|
.button-quiet:hover { background: var(--soft); color: var(--ink); border-color: #a6b5bd; }
|
||||||
|
.text-link { color: var(--accent); font-size: 14px; text-decoration: underline; }
|
||||||
|
.construction { min-height: calc(100svh - 214px); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 45px 24px 92px; text-align: center; }
|
||||||
|
.construction h1 { font-size: clamp(32px, 5.4vw, 66px); line-height: 1.35; letter-spacing: -.05em; font-weight: 500; margin: 0 0 47px; }
|
||||||
|
.construction-progress { width: min(100%, 750px); font-size: 13px; color: var(--muted); }
|
||||||
|
.construction-progress section + section { margin-top: 24px; }
|
||||||
|
.construction-progress h2 { font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 9px; }
|
||||||
|
.construction-progress p { margin-bottom: 0; line-height: 2; text-wrap: pretty; }
|
||||||
|
.home-links { margin-top: 42px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 25px; }
|
||||||
|
.page-shell { width: min(100% - 48px, 820px); margin: 54px auto 65px; }
|
||||||
|
.page-heading { margin-bottom: 34px; }
|
||||||
|
.page-heading h1 { font-size: 34px; letter-spacing: -.045em; font-weight: 550; line-height: 1.35; margin-bottom: 13px; }
|
||||||
|
.page-heading p { font-size: 14px; color: var(--muted); margin-bottom: 0; }
|
||||||
|
.search-form { display: flex; gap: 10px; margin: 0 0 22px; }
|
||||||
|
.search-form input { flex: 1; min-width: 0; margin: 0; }
|
||||||
|
.tag-filter { display: flex; gap: 9px 20px; flex-wrap: wrap; padding-bottom: 26px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
||||||
|
.tag-filter a { padding: 4px 0; }
|
||||||
|
.tag-filter [aria-current="true"] { color: var(--accent); text-decoration: underline; }
|
||||||
|
.article-list { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.article-list > li { padding: 30px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.article-with-cover { display: flex; align-items: center; gap: 30px; }
|
||||||
|
.article-summary { flex: 1; min-width: 0; }
|
||||||
|
.article-meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; line-height: 1.5; color: var(--muted); }
|
||||||
|
.article-list h2 { font-size: 23px; font-weight: 550; letter-spacing: -.025em; line-height: 1.5; margin: 12px 0 10px; }
|
||||||
|
.article-list p { margin-bottom: 14px; font-size: 14px; color: var(--muted); overflow-wrap: anywhere; }
|
||||||
|
.article-tags { display: flex; flex-wrap: wrap; gap: 8px 17px; font-size: 12px; color: var(--accent); }
|
||||||
|
.article-tags a:hover { text-decoration: underline; }
|
||||||
|
.article-cover { flex: 0 0 160px; }
|
||||||
|
.article-cover img { display: block; width: 160px; height: 112px; object-fit: cover; border-radius: 4px; background: var(--soft); }
|
||||||
|
.pagination { display: flex; justify-content: space-between; margin-top: 32px; font-size: 13px; color: var(--muted); }
|
||||||
|
.pagination > * { min-width: 60px; text-align: center; }
|
||||||
|
.empty-state { text-align: center; padding: 70px 15px; }
|
||||||
|
.empty-state h1 { font-size: 30px; }
|
||||||
|
.empty-state h2 { font-size: 20px; font-weight: 500; }
|
||||||
|
.empty-state p { color: var(--muted); font-size: 14px; }
|
||||||
|
.reading-shell { width: min(100% - 48px, 720px); margin: 42px auto 60px; }
|
||||||
|
.back-link { display: inline-block; color: var(--muted); font-size: 13px; margin-bottom: 34px; }
|
||||||
|
.article-heading { padding-bottom: 30px; border-bottom: 1px solid var(--line); }
|
||||||
|
.article-heading h1 { font-size: clamp(28px, 4vw, 42px); font-weight: 550; letter-spacing: -.045em; line-height: 1.4; margin: 15px 0; }
|
||||||
|
.article-description { font-size: 15px; color: var(--muted); margin-bottom: 20px; }
|
||||||
|
.document-nav { display: flex; flex-wrap: wrap; gap: 10px 22px; font-size: 13px; padding: 20px 0 0; color: var(--muted); }
|
||||||
|
.document-nav [aria-current="page"] { color: var(--accent); text-decoration: underline; }
|
||||||
|
.prose { font-size: 16px; line-height: 1.95; overflow-wrap: anywhere; padding-top: 24px; }
|
||||||
|
.prose > :first-child { margin-top: 0; }
|
||||||
|
.prose p { line-height: 1.95; margin-bottom: 1.4em; }
|
||||||
|
.prose h2 { font-size: 25px; margin: 1.8em 0 .8em; font-weight: 550; line-height: 1.5; }
|
||||||
|
.prose h3 { font-size: 20px; margin: 1.7em 0 .7em; font-weight: 550; line-height: 1.5; }
|
||||||
|
.prose h4, .prose h5, .prose h6 { font-size: 17px; margin: 1.6em 0 .7em; }
|
||||||
|
.prose :is(h2,h3,h4,h5,h6) { scroll-margin-top: 24px; }
|
||||||
|
.prose a { color: var(--accent); text-decoration: underline; }
|
||||||
|
.prose img { display: block; max-width: 100%; height: auto; margin: 25px auto; border-radius: 3px; }
|
||||||
|
.prose ul, .prose ol { padding-left: 1.7em; }
|
||||||
|
.prose li { margin: .4em 0; }
|
||||||
|
.prose blockquote { margin: 24px 0; padding: 1px 0 1px 20px; color: var(--muted); border-left: 2px solid #a8bbc5; }
|
||||||
|
.prose blockquote p:last-child { margin-bottom: 0; }
|
||||||
|
pre { font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: 13px; line-height: 1.85; padding: 18px 20px; background: var(--soft); border-radius: 5px; overflow-x: auto; tab-size: 4; }
|
||||||
|
code { font-family: ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: .87em; }
|
||||||
|
.prose :not(pre) > code { background: var(--soft); padding: .2em .4em; border-radius: 3px; }
|
||||||
|
pre code { font-size: inherit; }
|
||||||
|
.prose table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.prose th, .prose td { padding: 9px 14px; border: 1px solid var(--line); text-align: left; }
|
||||||
|
.prose hr { border: 0; border-top: 1px solid var(--line); margin: 32px 0; }
|
||||||
|
.plain-text { white-space: pre-wrap; overflow-wrap: anywhere; background: transparent; font-family: inherit; font-size: 16px; padding: 28px 0; }
|
||||||
|
.article-end { border-top: 1px solid var(--line); margin-top: 44px; padding-top: 24px; }
|
||||||
|
.article-end p { color: var(--muted); font-size: 12px; }
|
||||||
|
.auth-shell { width: min(100% - 48px, 370px); margin: 46px auto 75px; }
|
||||||
|
.auth-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--line); margin-bottom: 28px; font-size: 14px; color: var(--muted); }
|
||||||
|
.auth-tabs a { padding: 0 0 12px; }
|
||||||
|
.auth-tabs [aria-current="page"] { border-bottom: 2px solid var(--accent); color: var(--ink); }
|
||||||
|
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 9px; }
|
||||||
|
input:not([type="checkbox"]), textarea, select { display: block; border: 1px solid #bfcbd2; border-radius: 4px; background: white; color: var(--ink); min-height: 44px; width: 100%; padding: 11px 12px; font-size: 14px; margin-bottom: 21px; }
|
||||||
|
input::placeholder, textarea::placeholder { color: #74838b; }
|
||||||
|
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||||
|
textarea { resize: vertical; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
.field-help { color: var(--muted); font-size: 12px; margin: -11px 0 21px; line-height: 1.8; }
|
||||||
|
.form-status { font-size: 13px; color: var(--accent); min-height: 20px; margin: 12px 0; overflow-wrap: anywhere; }
|
||||||
|
.form-status[data-error="true"] { color: var(--error); }
|
||||||
|
.auth-shell form > .button { width: 100%; }
|
||||||
|
.notice { font-size: 13px; color: var(--muted); background: var(--soft); padding: 12px 15px; border-radius: 4px; }
|
||||||
|
.account-shell { width: min(100% - 48px, 720px); margin: 46px auto 60px; }
|
||||||
|
.account-identity { display: flex; gap: 20px; align-items: center; justify-content: space-between; padding: 0 0 28px; }
|
||||||
|
.account-identity strong { font-weight: 550; font-size: 18px; }
|
||||||
|
.role-label { color: var(--muted); font-size: 12px; margin-left: 14px; }
|
||||||
|
.settings-section { padding: 30px 0; border-top: 1px solid var(--line); }
|
||||||
|
.settings-section h2 { font-size: 20px; font-weight: 550; margin-bottom: 13px; }
|
||||||
|
.settings-section h3 { font-size: 15px; font-weight: 550; margin: 30px 0 15px; }
|
||||||
|
.section-description { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
|
||||||
|
.publishing-steps { padding-left: 21px; font-size: 13px; }
|
||||||
|
.publishing-steps li { padding-left: 3px; margin-bottom: 22px; }
|
||||||
|
.publishing-steps p { margin-bottom: 12px; color: var(--muted); }
|
||||||
|
.settings-section details summary { font-size: 13px; padding: 5px 0; }
|
||||||
|
.settings-section details .field-help { margin-top: 10px; }
|
||||||
|
.admin-user { content-visibility: auto; contain-intrinsic-size: auto 150px; border-bottom: 1px solid var(--line); padding: 17px 0; }
|
||||||
|
.admin-user h4 { margin: 0 0 13px; font-size: 14px; font-weight: 550; }
|
||||||
|
.admin-user-controls { display: grid; grid-template-columns: minmax(100px, 1fr) auto auto; gap: 14px; align-items: center; }
|
||||||
|
.admin-user select { margin: 0; }
|
||||||
|
.admin-user label { margin: 0; display: flex; align-items: center; gap: 6px; font-weight: 400; white-space: nowrap; }
|
||||||
|
.pending-item { font-size: 13px; line-height: 1.8; padding: 12px 0; overflow-wrap: anywhere; }
|
||||||
|
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-header { padding-top: 28px; }
|
||||||
|
.site-nav { margin-top: 16px; }
|
||||||
|
.construction { min-height: calc(100svh - 190px); padding: 40px 20px 75px; }
|
||||||
|
.construction h1 { margin-bottom: 38px; }
|
||||||
|
.construction-progress p { max-width: 320px; margin-inline: auto; line-height: 2.1; }
|
||||||
|
.home-links { margin-top: 34px; gap: 22px; }
|
||||||
|
.page-shell, .reading-shell, .account-shell { width: calc(100% - 40px); margin-top: 32px; }
|
||||||
|
.page-heading h1 { font-size: 29px; }
|
||||||
|
.article-list h2 { font-size: 20px; }
|
||||||
|
.article-with-cover { gap: 17px; }
|
||||||
|
.article-cover { flex-basis: 88px; }
|
||||||
|
.article-cover img { width: 88px; height: 88px; }
|
||||||
|
.article-description { font-size: 14px; }
|
||||||
|
.prose { font-size: 15px; }
|
||||||
|
.prose h2 { font-size: 23px; }
|
||||||
|
.site-footer { padding-top: 30px; }
|
||||||
|
pre { padding: 14px; font-size: 12px; }
|
||||||
|
.admin-user-controls { grid-template-columns: 1fr auto; }
|
||||||
|
.admin-user-controls .button { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { renderMarkdown } from '../src/lib/markdown';
|
||||||
|
import type { Article } from '../src/lib/api';
|
||||||
|
const article = (markdown: string): Article => ({
|
||||||
|
meta: { title: 'Test', slug: 'alice~first', author: 'alice', description: '', tags: [], cover: null, created_at: '', date: '', updated: '', status: 'published' },
|
||||||
|
markdown, format: 'markdown', entry: 'main.md',
|
||||||
|
pages: [{ name: 'main.md', url: '' }, { name: 'chapter.md', url: '' }], assets: [{ name: 'cover.png', url: '' }],
|
||||||
|
});
|
||||||
|
test('rewrites only registered documents and assets, preserves anchors and creates heading IDs', () => {
|
||||||
|
const html = renderMarkdown(article('# Intro\n[Next](./chapter.md#details)\n[Back](main.md)\n'), 'alice~first');
|
||||||
|
assert.match(html, /<h2 id="intro">/);
|
||||||
|
assert.match(html, /href="\/posts\/alice~first\/chapter.md#details"/);
|
||||||
|
assert.match(html, /href="\/posts\/alice~first"/);
|
||||||
|
assert.match(html, /src="\/api\/assets\/alice~first\/cover.png"/);
|
||||||
|
assert.doesNotMatch(renderMarkdown(article(''), 'alice~first'), /<img/);
|
||||||
|
});
|
||||||
|
test('treats raw HTML as text and excludes active links, forged images and handlers', () => {
|
||||||
|
for (const text of ['<script>alert(1)</script>', '<img src=x onerror=alert(1)>', '[x](javascript:alert%281%29)', '[x](data:text/html,evil)', '[x](//evil.example)', '']) {
|
||||||
|
const html = renderMarkdown(article(text), 'alice~first');
|
||||||
|
assert.doesNotMatch(html, /<script|<img[^>]+onerror|href="(?:javascript:|data:|\/\/)|src="https?:/i);
|
||||||
|
}
|
||||||
|
assert.match(renderMarkdown(article('```html\n<script>x</script>\n```'), 'alice~first'), /<script>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[[articles]]
|
||||||
|
id = "welcome"
|
||||||
|
folder = "posts/welcome"
|
||||||
|
title = "第一篇文章"
|
||||||
|
description = "从本地 Markdown 开始。"
|
||||||
|
tags = ["随笔"]
|
||||||
|
public = true
|
||||||
|
# entry = "main.md" # 省略时默认 main.md
|
||||||
|
# cover = "cover.png" # 可选,同文章目录内已验证的图片
|
||||||
@@ -1 +0,0 @@
|
|||||||
welcome posts/welcome index.md
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 第二页
|
||||||
|
|
||||||
|
一篇文章可以包含多个 Markdown 页面。
|
||||||
|
|
||||||
|
[返回入口](main.md)
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
title: "第一篇文章"
|
|
||||||
slug: "welcome"
|
|
||||||
date: "2026-10-15"
|
|
||||||
updated: "2026-10-15"
|
|
||||||
status: "published"
|
|
||||||
tags: ["随笔"]
|
|
||||||
description: "从本地 Markdown 开始。"
|
|
||||||
---
|
|
||||||
|
|
||||||
# 你好,ChuBlog
|
|
||||||
|
|
||||||
在本地编辑这篇文章,通过受限 SSH Git 推送发布。
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn main() {
|
|
||||||
println!("Hello, ChuBlog!");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 你好,ChuBlog
|
||||||
|
|
||||||
|
在本地编辑这篇文章,通过受限 SSH Git 推送发布。
|
||||||
|
|
||||||
|
[阅读下一页](chapter.md)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, ChuBlog!");
|
||||||
|
}
|
||||||
|
```
|
||||||
+6
-1
@@ -16,7 +16,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
respond "ChuBlog backend is running. Frontend is planned separately." 200
|
reverse_proxy web:4321 {
|
||||||
|
transport http {
|
||||||
|
read_timeout 15s
|
||||||
|
write_timeout 15s
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log {
|
log {
|
||||||
output stdout
|
output stdout
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
FROM debian:trixie-slim AS base
|
ARG DEBIAN_IMAGE=debian:trixie-slim
|
||||||
|
FROM ${DEBIAN_IMAGE} AS base
|
||||||
ARG DEBIAN_MIRROR=deb.debian.org
|
ARG DEBIAN_MIRROR=deb.debian.org
|
||||||
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates libgmp10 curl util-linux && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates libgmp10 curl util-linux && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -25,7 +26,7 @@ COPY init-volumes.sh /usr/local/bin/init-volumes
|
|||||||
RUN chmod 755 /usr/local/bin/init-volumes
|
RUN chmod 755 /usr/local/bin/init-volumes
|
||||||
ENTRYPOINT ["/usr/local/bin/init-volumes"]
|
ENTRYPOINT ["/usr/local/bin/init-volumes"]
|
||||||
|
|
||||||
FROM debian:trixie-slim AS database
|
FROM ${DEBIAN_IMAGE} AS database
|
||||||
ARG DEBIAN_MIRROR=deb.debian.org
|
ARG DEBIAN_MIRROR=deb.debian.org
|
||||||
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libgmp10 ca-certificates && rm -rf /var/lib/apt/lists/* && useradd -u 10003 -M -s /usr/sbin/nologin chusql
|
RUN apt-get update && apt-get install -y --no-install-recommends libgmp10 ca-certificates && rm -rf /var/lib/apt/lists/* && useradd -u 10003 -M -s /usr/sbin/nologin chusql
|
||||||
|
|||||||
+31
-6
@@ -6,7 +6,7 @@ x-logging: &logging
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
services:
|
services:
|
||||||
init:
|
init:
|
||||||
build: { context: ., target: init, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
build: { context: ., target: init, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}", DEBIAN_IMAGE: "${REGISTRY_PREFIX:-}debian:trixie-slim" } }
|
||||||
volumes:
|
volumes:
|
||||||
- content:/data
|
- content:/data
|
||||||
- authorization:/auth
|
- authorization:/auth
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
network_mode: none
|
network_mode: none
|
||||||
logging: *logging
|
logging: *logging
|
||||||
api:
|
api:
|
||||||
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}", DEBIAN_IMAGE: "${REGISTRY_PREFIX:-}debian:trixie-slim" } }
|
||||||
depends_on:
|
depends_on:
|
||||||
init: { condition: service_completed_successfully }
|
init: { condition: service_completed_successfully }
|
||||||
environment:
|
environment:
|
||||||
@@ -48,8 +48,32 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
logging: *logging
|
logging: *logging
|
||||||
|
web:
|
||||||
|
build: { context: ../apps/web, args: { NODE_IMAGE: "${REGISTRY_PREFIX:-}node:24-bookworm-slim" } }
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: http://api:3001
|
||||||
|
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set PUBLIC_ORIGIN}
|
||||||
|
SITE_TITLE: ${SITE_TITLE:-ChuBlog}
|
||||||
|
SSH_PORT: ${SSH_PORT:-2222}
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:size=16m,noexec,nosuid"]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
pids_limit: 64
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [backend]
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, node, -e, "fetch('http://127.0.0.1:4321/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
logging: *logging
|
||||||
ssh:
|
ssh:
|
||||||
build: { context: ., target: ssh, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
build: { context: ., target: ssh, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}", DEBIAN_IMAGE: "${REGISTRY_PREFIX:-}debian:trixie-slim" } }
|
||||||
depends_on:
|
depends_on:
|
||||||
init: { condition: service_completed_successfully }
|
init: { condition: service_completed_successfully }
|
||||||
api: { condition: service_healthy }
|
api: { condition: service_healthy }
|
||||||
@@ -72,7 +96,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *logging
|
logging: *logging
|
||||||
db:
|
db:
|
||||||
build: { context: ., target: database, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
build: { context: ., target: database, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}", DEBIAN_IMAGE: "${REGISTRY_PREFIX:-}debian:trixie-slim" } }
|
||||||
depends_on:
|
depends_on:
|
||||||
init: { condition: service_completed_successfully }
|
init: { condition: service_completed_successfully }
|
||||||
environment:
|
environment:
|
||||||
@@ -91,7 +115,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *logging
|
logging: *logging
|
||||||
db-init:
|
db-init:
|
||||||
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}", DEBIAN_IMAGE: "${REGISTRY_PREFIX:-}debian:trixie-slim" } }
|
||||||
profiles: [setup]
|
profiles: [setup]
|
||||||
entrypoint: [chublog-api, provision-db]
|
entrypoint: [chublog-api, provision-db]
|
||||||
environment:
|
environment:
|
||||||
@@ -106,7 +130,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
logging: *logging
|
logging: *logging
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: ${REGISTRY_PREFIX:-}caddy:2
|
||||||
environment:
|
environment:
|
||||||
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
|
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
|
||||||
ports: ["${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-80}:80", "${HTTP_BIND:-0.0.0.0}:${HTTPS_PORT:-443}:443"]
|
ports: ["${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-80}:80", "${HTTP_BIND:-0.0.0.0}:${HTTPS_PORT:-443}:443"]
|
||||||
@@ -120,6 +144,7 @@ services:
|
|||||||
networks: [backend, edge]
|
networks: [backend, edge]
|
||||||
depends_on:
|
depends_on:
|
||||||
api: { condition: service_healthy }
|
api: { condition: service_healthy }
|
||||||
|
web: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *logging
|
logging: *logging
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+14
-10
@@ -15,7 +15,8 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/health` | 公共,API 存活,不代表数据库或发布同步健康 |
|
| GET | `/api/health` | 公共,API 存活,不代表数据库或发布同步健康 |
|
||||||
| GET | `/api/posts` | 公共,`{commit,posts:[metadata]}`,仅已发布,日期倒序 |
|
| GET | `/api/posts` | 公共,`{commit,posts:[metadata]}`,仅已发布,日期倒序 |
|
||||||
| GET | `/api/posts/:slug` | 公共,`{meta,markdown,format,author,article_id,assets:[{name,url}]}`,草稿及不存在均 404 |
|
| GET | `/api/posts/:slug` | 公共,`{meta,markdown,format,author,article_id,entry,pages:[{name,url}],assets:[{name,url}]}`,草稿及不存在均 404 |
|
||||||
|
| GET | `/api/posts/:slug/pages/:name` | 公共,指定页面的正文、元信息、格式、页面列表和资源地址;非公开或不存在均 404 |
|
||||||
| GET | `/api/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 |
|
| GET | `/api/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 |
|
||||||
| GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 |
|
| GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 |
|
||||||
| POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 |
|
| POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 |
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
| PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 |
|
| PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 |
|
||||||
| GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 |
|
| GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 |
|
||||||
| PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role}`;兼容可选的旧 `prefixes` 字段 |
|
| PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role}`;兼容可选的旧 `prefixes` 字段 |
|
||||||
| GET | `/api/admin/status` | 管理员;`{commit,total_posts,drafts,indexed_commit}` |
|
| GET | `/api/admin/status` | 管理员;`{commit,total_posts,drafts,indexed_commit,pending}` |
|
||||||
|
|
||||||
## 权限与账户
|
## 权限与账户
|
||||||
|
|
||||||
@@ -56,13 +57,16 @@
|
|||||||
|
|
||||||
## 文章与资源
|
## 文章与资源
|
||||||
|
|
||||||
- 文章以作者仓库的 `index.txt` 为登记依据,每行三列:`文章ID posts/文件夹 正文文件名`。
|
文章由作者仓库的 `index.toml` 登记,结构、字段和多页样例见[文章发布](publishing.md)。站内 `:slug` 为 `作者名~文章ID`,目录或入口改名不会改变 ID。
|
||||||
- 站内 `:slug` 为 `作者名~文章ID`,例如 `alice~first`;索引 ID 不随文件夹或文件名变化。
|
|
||||||
- `markdown` 保留现有正文源字段(含 Frontmatter);`format` 为 `markdown` 或 `text`,纯文本不得当作 HTML 渲染。
|
|
||||||
- 未登记内容及草稿不通过公共文章/资源接口返回;删除索引项会自动撤回阅读内容并同步删除派生数据库记录。
|
|
||||||
|
|
||||||
## 同步状态
|
`GET /api/posts/:slug` 返回入口正文。`markdown` 是正文源文本,服务端不再把 Frontmatter 解析为元信息,`entry` 是入口文件名,`pages` 和 `assets` 提供该文章可达页面及已导入图片的 URL。`format` 为 `markdown` 或 `text`,阅读端不得将纯文本直接当作 HTML。
|
||||||
|
|
||||||
- `/api/admin/status` 的 `commit` 和 `indexed_commit` 为整个作者集合的版本摘要,不是单个作者的 Git HEAD。
|
`meta` 包含 `title/description/tags/cover/slug/author/created_at/date/updated/status`。`cover` 是文件名或 null,对应 `assets` 中的资源。`author` 由服务端认证身份填写;`created_at` 是首次导入时间,`date` 是首次公开时间,未公开时为空字符串;`updated` 是元信息、可达页面或有效图片实际变化的时间。时间使用 UTC RFC 3339 字符串,不接受作者在索引或 Git 提交中指定。
|
||||||
- 作者各自的源提交记录在数据库 `blog_articles.git_commit` 和私有集合快照中。
|
|
||||||
- 数据库失败时 Git 接收与阅读仍可继续,索引在恢复后自动重建;站长审核暂未启用。
|
页面链接如 `chapter.md#part` 由阅读端根据 `pages` 映射到页面 URL,并保留锚点。非公开内容、未登记目录、未链接页面与未引用图片不会由公共接口返回。移除索引条目或入口缺失时,页面和资源入口同时撤回。
|
||||||
|
|
||||||
|
## 同步与暂存状态
|
||||||
|
|
||||||
|
`/api/admin/status` 的 `commit` 和 `indexed_commit` 为整个生效作者集合的版本摘要,不是单个作者的 Git HEAD。`pending` 的格式为 `{作者名: {commit, reason}}`,仅管理员可读。TOML 无效时保留原生效版本及其数据库记录,暂存状态跨进程重启保存;修复后的正常推送会替换暂存状态并重新导入。
|
||||||
|
|
||||||
|
作者的源提交记录在数据库 `blog_articles.git_commit` 和私有集合快照中。数据库失败时 Git 接收与已导入文章阅读仍可继续,恢复后自动重建。站长审核暂未启用。
|
||||||
+18
-2
@@ -11,6 +11,22 @@
|
|||||||
scripts/deploy.sh
|
scripts/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 服务与路由
|
||||||
|
|
||||||
|
Compose 编排 Astro 前端 `web`、Rust API `api`、文章 SSH 接收 `ssh`、ChuSQL 数据库 `db` 和 Caddy。`init` 初始化持久卷,`db-init` 用于首次数据库授权。
|
||||||
|
|
||||||
|
`web` 是博客前端,不是 ChuSQL Web。数据库只构建 `chusql-server`、`csql-bootstrap` 与存储库,不构建或启动 `chusql-web`、`csql-web` 或数据库管理网站。
|
||||||
|
|
||||||
|
Caddy 将 `/api/*` 转发到 `api:3001`,其余请求转发到 `web:4321`。Node、API 和数据库只加入内部网络,不映射宿主端口;公网入口为 Caddy 的 HTTP/HTTPS 和文章 SSH。前端容器使用非 root 用户、只读根文件系统和独立资源上限。
|
||||||
|
|
||||||
|
## 腾讯云镜像
|
||||||
|
|
||||||
|
`.env.example` 默认配置 `REGISTRY_PREFIX=mirror.ccs.tencentyun.com/library/`,为 Debian、Node 与 Caddy 使用腾讯云内网 Docker 镜像。该地址仅供腾讯云内网访问,见[腾讯云官方说明](https://cloud.tencent.com/document/practice/1207/45596)。
|
||||||
|
|
||||||
|
在腾讯云内网部署时保留该值;其他环境将 `REGISTRY_PREFIX` 留空以使用原始镜像名,或改为可访问且受信的镜像前缀。项目不修改宿主机 Docker 配置、不关闭 TLS,也不重启 Docker 守护进程。
|
||||||
|
|
||||||
|
镜像注册表与 Debian 软件包源是不同配置。`REGISTRY_PREFIX` 控制基础镜像和 Caddy,`DEBIAN_MIRROR` 控制容器构建时的 apt 源。
|
||||||
|
|
||||||
## 首次部署
|
## 首次部署
|
||||||
|
|
||||||
先按[开发与依赖](development.md)初始化子模块,再执行以下同架构构建步骤。使用 `scripts/deploy.sh` 时,也须先准备 `.env`。
|
先按[开发与依赖](development.md)初始化子模块,再执行以下同架构构建步骤。使用 `scripts/deploy.sh` 时,也须先准备 `.env`。
|
||||||
@@ -22,7 +38,7 @@ cp .env.example .env
|
|||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。
|
# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。
|
||||||
./deploy/bin/chublog-api hash-password
|
./deploy/bin/chublog-api hash-password
|
||||||
# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名及两份不同的随机 DB 口令。
|
# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名、镜像前缀及两份不同的随机 DB 口令。
|
||||||
docker compose --env-file .env -f deploy/compose.yaml up -d --build
|
docker compose --env-file .env -f deploy/compose.yaml up -d --build
|
||||||
# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。
|
# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。
|
||||||
docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
|
docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
|
||||||
@@ -42,7 +58,7 @@ docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
|
|||||||
|
|
||||||
## 管理员初始化
|
## 管理员初始化
|
||||||
|
|
||||||
首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。
|
浏览器打开配置的站点即可看到前端。`SITE_TITLE` 控制页眉名称。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。
|
||||||
|
|
||||||
## 域名与网络
|
## 域名与网络
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -55,16 +55,20 @@ cargo fmt --check
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
前端开发和构建见 [Astro 前端](frontend.md)。
|
||||||
|
|
||||||
## 联调
|
## 联调
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/prepare-deploy.py --build-database
|
python3 scripts/prepare-deploy.py --build-database
|
||||||
python3 scripts/smoke.py
|
python3 scripts/smoke.py
|
||||||
python3 scripts/smoke.py --key-type rsa
|
python3 scripts/smoke.py --key-type rsa
|
||||||
|
# 安装 Python Playwright 和 Chromium 后,可追加前端真实浏览器验证。
|
||||||
|
python3 scripts/smoke.py --key-type rsa --browser-test
|
||||||
```
|
```
|
||||||
|
|
||||||
- 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。
|
- 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。
|
||||||
- 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。
|
- 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、TOML 暂存与修复、多页链接、系统时间、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。
|
||||||
- 该测试会拉取基础镜像,需要 Docker 可用。
|
- 该测试会拉取基础镜像,需要 Docker 可用。
|
||||||
|
|
||||||
## 历史清理后的协作
|
## 历史清理后的协作
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Astro 前端
|
||||||
|
|
||||||
|
[返回项目首页](../README.md)
|
||||||
|
|
||||||
|
前端位于 `apps/web`,使用 Astro、TypeScript 和 Node 服务端渲染。首页按要求展示居中的“个人博客正在施工中”,下方居中列出已完成和未完成部分。
|
||||||
|
|
||||||
|
## 页面
|
||||||
|
|
||||||
|
| 地址 | 功能 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | 施工说明、进度与导航,内容全部居中 |
|
||||||
|
| `/articles` | 文章列表、标题/简介/作者/标签搜索、标签筛选和分页 |
|
||||||
|
| `/posts/作者名~文章ID` | 阅读入口文档、图片和文章元信息 |
|
||||||
|
| `/posts/作者名~文章ID/页面名.md` | 阅读同文章的其他可达页面,保留相对链接和锚点 |
|
||||||
|
| `/login`、`/login?mode=register` | 登录、注册与错误反馈 |
|
||||||
|
| `/account` | 公钥登记、密码修改、退出和 Git 发布说明;管理员可授权账户并查看待修复索引 |
|
||||||
|
|
||||||
|
未实现的评论、互动、内部文章阅读、审核和热度排序不提供占位操作按钮。文章写入继续通过 Git,不提供在线编辑。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
需要 Node.js 22.22 或更高版本,Docker 前端镜像使用 Node.js 24。先启动 API,并将其 `PUBLIC_ORIGIN` 设为浏览器使用的地址,例如 `http://127.0.0.1:4321`。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/web
|
||||||
|
npm ci
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 `http://127.0.0.1:4321`。`API_BASE_URL` 默认 `http://127.0.0.1:3001`,开发服务器将 `/api` 请求转发到该地址。`PUBLIC_ORIGIN` 用于显示正确的服务器 SSH 主机名,应与后端配置及浏览器地址一致。`SSH_PORT` 控制发布说明中的端口。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
构建同时检查 Astro 和 TypeScript。生产运行 `npm start` 只启动页面服务,API 同源转发由 Caddy 提供,见[部署说明](deployment.md)。
|
||||||
|
|
||||||
|
## 安全与访问
|
||||||
|
|
||||||
|
浏览器使用后端 HttpOnly Cookie,CSRF 只保存在内存,不写入 localStorage。修改请求携带当前会话的 CSRF,Origin 由浏览器发送;后端继续执行全部权限检查。
|
||||||
|
|
||||||
|
Markdown 禁用原始 HTML,并在渲染后清理输出。文章内链接只映射到 API 返回的同文章页面和图片;普通外部链接仍受协议限制。纯文本通过转义输出显示。页面设置 CSP、禁用缓存和内容类型嗅探,不加载第三方字体、脚本或跟踪服务。
|
||||||
|
|
||||||
|
文章服务故障返回可重试提示,不填充示例数据。注册被关闭、会话失效和限流错误直接显示后端反馈。
|
||||||
|
|
||||||
|
## 浏览器检查
|
||||||
|
|
||||||
|
浏览器联调需要 Python Playwright 及 Chromium:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m pip install playwright
|
||||||
|
python3 -m playwright install chromium
|
||||||
|
python3 scripts/smoke.py --key-type rsa --browser-test
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令使用隔离的 Docker 项目,验证真实注册登录、密码修改、公钥保存、站长授权、多页阅读及手机布局,测试后清理自己创建的容器与卷。截图默认放在 `/tmp/chublog-web-screenshots`。系统缺少浏览器库时,按 Playwright 安装说明准备运行环境。
|
||||||
|
|
||||||
|
## 项目技能
|
||||||
|
|
||||||
|
按维护者要求下载到本项目 `.agents/skills/`,作为开发参考,不是运行时依赖:
|
||||||
|
|
||||||
|
- [frontend-design](../.agents/skills/frontend-design/SKILL.md),来源为 [Anthropic skills](https://github.com/anthropics/skills/tree/main/skills/frontend-design)。
|
||||||
|
- [webapp-testing](../.agents/skills/webapp-testing/SKILL.md),来源为 [Anthropic skills](https://github.com/anthropics/skills/tree/main/skills/webapp-testing)。
|
||||||
|
- [web-design-guidelines](../.agents/skills/web-design-guidelines/SKILL.md),来源为 [Vercel agent skills](https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines)。
|
||||||
|
|
||||||
|
后续会话可继续发现这些技能。本轮已直接读取,按项目要求采用居中施工页、克制排版、键盘焦点、表单标签和真实浏览器检查。
|
||||||
+9
-3
@@ -17,7 +17,7 @@
|
|||||||
## 数据库故障与索引重建
|
## 数据库故障与索引重建
|
||||||
|
|
||||||
- 数据库索引失败不会修改作者 Git 或阻塞已导入文章的 HTTP 阅读;新集合会触发同步,失败至少间隔 30 秒重试,并定期检查索引是否丢失。
|
- 数据库索引失败不会修改作者 Git 或阻塞已导入文章的 HTTP 阅读;新集合会触发同步,失败至少间隔 30 秒重试,并定期检查索引是否丢失。
|
||||||
- `blog_articles` 保存全部有效登记文章(含草稿)的作者、本地 ID、正文路径、格式、源提交和元信息;`blog_posts/blog_tags` 保留公开检索索引。
|
- `blog_articles` 保存全部有效登记文章(含草稿)的作者、本地 ID、正文路径、格式、源提交、封面和系统时间;`blog_pages` 保存有效页面路径,`blog_article_tags` 保存全部文章标签;`blog_posts/blog_tags` 保留公开检索索引。
|
||||||
- 恢复数据库后可自动重建;离线命令为 `chublog-api rebuild-index`(必须停止同一数据卷上的 API 进程后运行,防止并发),或等待在线自动重试。
|
- 恢复数据库后可自动重建;离线命令为 `chublog-api rebuild-index`(必须停止同一数据卷上的 API 进程后运行,防止并发),或等待在线自动重试。
|
||||||
- `indexed_commit:null` 表示尚无已确认可用的索引,旧值表示尚未追平当前内容。
|
- `indexed_commit:null` 表示尚无已确认可用的索引,旧值表示尚未追平当前内容。
|
||||||
- 重建上述派生表使用 ChuSQL 事务;数据库不可用时保留阅读快照,恢复后重建。
|
- 重建上述派生表使用 ChuSQL 事务;数据库不可用时保留阅读快照,恢复后重建。
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
- 接收 Git 可额外镜像备份。
|
- 接收 Git 可额外镜像备份。
|
||||||
- 恢复时先停止写入,从同一备份恢复各卷,再启动;不要只恢复 ChuSQL,也不要直接向正式仓库推送。
|
- 恢复时先停止写入,从同一备份恢复各卷,再启动;不要只恢复 ChuSQL,也不要直接向正式仓库推送。
|
||||||
- 应用启动加载私有集合快照,并继续从各作者 Git 校验导入。
|
- 应用启动加载私有集合快照,并继续从各作者 Git 校验导入。
|
||||||
- 丢失派生快照时,可在停机并保留备份后移走损坏的 `collection.json`,由各作者接收仓库重新导入;不要丢失作者仓库或账户授权。
|
- `collection.json` 还保存生效版本、暂存状态与服务端生成的时间,必须备份。丢失时可从有效作者仓库重新导入正文,但无法还原原始系统时间;若作者当前索引损坏,还需要备份中的上一个生效版本。不要将它当作可随意丢弃的缓存。
|
||||||
- 进程重启会注销所有会话。
|
- 进程重启会注销所有会话。
|
||||||
|
|
||||||
本实现对文件写入执行原子替换与 fsync,Git 引用采用受控更新;磁盘故障、主机断电和备份恢复仍需真实环境演练。不要对含正式数据的项目执行 `docker compose down -v`。
|
本实现对文件写入执行原子替换与 fsync,Git 引用采用受控更新;磁盘故障、主机断电和备份恢复仍需真实环境演练。不要对含正式数据的项目执行 `docker compose down -v`。
|
||||||
@@ -40,9 +40,15 @@
|
|||||||
1. 从旧共享仓库导出各作者最新的文章文件(含草稿),按原发布前缀及管理员确认的所有权分给作者;不要复制 `.git` 或旧提交历史。
|
1. 从旧共享仓库导出各作者最新的文章文件(含草稿),按原发布前缀及管理员确认的所有权分给作者;不要复制 `.git` 或旧提交历史。
|
||||||
2. 管理员离线将 `/auth/publishers.json` 的 `repository` 改为 `/srv/ingress/authors`(或自定义 `INGRESS_DIR`),保留用户和公钥授权。新版本 init 服务会创建该根目录。
|
2. 管理员离线将 `/auth/publishers.json` 的 `repository` 改为 `/srv/ingress/authors`(或自定义 `INGRESS_DIR`),保留用户和公钥授权。新版本 init 服务会创建该根目录。
|
||||||
3. 将旧 API 数据卷根目录的 `content.git` 移入只对管理员可见的备份位置;旧 `published.json` 同样备份。旧 SSH 共享仓库不再供服务使用,应移出接收卷离线保管。不要删除备份或清空用户数据。
|
3. 将旧 API 数据卷根目录的 `content.git` 移入只对管理员可见的备份位置;旧 `published.json` 同样备份。旧 SSH 共享仓库不再供服务使用,应移出接收卷离线保管。不要删除备份或清空用户数据。
|
||||||
4. 启动新服务。每位作者重新 clone 自己的空仓库,复制自己的导出文件,创建三列 `index.txt`,整理成一个首次提交并推送。旧 `slug` 如保留,须与索引 ID 一致。
|
4. 启动新服务。每位作者重新 clone 自己的空仓库,复制自己的导出文件,将原 Frontmatter 中的标题、简介和标签迁入 `index.toml`,将原发布状态映射到 `public`;为每篇文章指定 ID、目录和入口,移除正文 Frontmatter,整理成一个首次提交并推送。作者和时间由服务端重新记录。
|
||||||
5. 核对草稿隔离、所有文章、数据库索引和新的 `作者名~ID` URL 后再恢复对外服务。旧版本已被其他人克隆的内容无法收回。
|
5. 核对草稿隔离、所有文章、数据库索引和新的 `作者名~ID` URL 后再恢复对外服务。旧版本已被其他人克隆的内容无法收回。
|
||||||
|
|
||||||
- 已有授权配置仍指向旧仓库时,新版本拒绝启动;不会自动重新分发旧历史。
|
- 已有授权配置仍指向旧仓库时,新版本拒绝启动;不会自动重新分发旧历史。
|
||||||
- 原链接到新 ID 的重定向尚未实现,迁移时需另行安排。
|
- 原链接到新 ID 的重定向尚未实现,迁移时需另行安排。
|
||||||
- 若自定义 `INGRESS_DIR`,需同步调整镜像内 `deploy/gitconfig` 的受信目录范围;默认仅信任 `/srv/ingress/authors/*`,不使用全局 `safe.directory=*`。
|
- 若自定义 `INGRESS_DIR`,需同步调整镜像内 `deploy/gitconfig` 的受信目录范围;默认仅信任 `/srv/ingress/authors/*`,不使用全局 `safe.directory=*`。
|
||||||
|
|
||||||
|
## 从个人仓库的文本索引迁移
|
||||||
|
|
||||||
|
停写并备份作者仓库和 `collection.json`,然后把 `index.txt` 改为根目录 `index.toml`。保留原文章 ID 和目录,从正文 Frontmatter 迁移允许的元信息,正文入口不叫 `main.md` 时显式填写 `entry`。删除旧 `index.txt` 和正文 Frontmatter 后,作为一个提交推送。沿用原私有集合快照可保留已有系统时间;旧正文中的日期不会被新版本采纳。
|
||||||
|
|
||||||
|
不要提交两种索引并存的仓库。仅根目录的 `index.toml` 在白名单内,其他 TOML 配置仍被拒绝。派生的 `blog_articles` 表若缺少新列,会由索引重建程序重建;该表不得混存评论、授权、审核或站长管理数据。
|
||||||
+100
-67
@@ -16,92 +16,125 @@
|
|||||||
- 设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。
|
- 设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。
|
||||||
- 禁止管理员停用自己或取消自己的管理角色。
|
- 禁止管理员停用自己或取消自己的管理角色。
|
||||||
|
|
||||||
## 本地 Git 工作流
|
## 克隆个人文章仓库
|
||||||
|
|
||||||
- 先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。
|
先通过可信渠道核对服务器主机密钥指纹,再在本机 `~/.ssh/config` 配置连接:
|
||||||
- 主机密钥保存在独立持久卷,重建容器不会更换。
|
|
||||||
- 所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。
|
|
||||||
|
|
||||||
```sh
|
```sshconfig
|
||||||
# ~/.ssh/config
|
|
||||||
Host chublog
|
Host chublog
|
||||||
HostName blog.example.com
|
HostName blog.example.com
|
||||||
Port 2222
|
Port 2222
|
||||||
User git
|
User git
|
||||||
IdentityFile ~/.ssh/id_ed25519
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
|
||||||
# 无论作者是谁,别名都是 content.git;服务端按认证身份映射个人仓库。
|
将域名、端口和私钥路径替换为部署者提供的值。主机密钥检查应保持开启。服务器根据公钥识别作者,不使用 Git 的 author/email 判定身份。
|
||||||
# 首次 clone 自动创建服务器裸仓库,作者无须自己创建裸仓库。
|
|
||||||
|
```sh
|
||||||
git clone chublog:content.git my-articles
|
git clone chublog:content.git my-articles
|
||||||
cd my-articles
|
cd my-articles
|
||||||
# 仓库已有内容时,每轮编辑前先 git pull --ff-only。
|
```
|
||||||
# 创建 index.txt 和正文,格式见下文。
|
|
||||||
git add index.txt posts/first
|
每位作者都使用 `content.git` 别名。服务器将别名映射到该作者的独立仓库,并在首次访问时创建裸仓库,作者无需服务器 shell。仓库已有提交时,每轮编辑前先执行 `git pull --ff-only`。
|
||||||
|
|
||||||
|
## 创建 TOML 索引
|
||||||
|
|
||||||
|
在仓库根目录创建 UTF-8 文件 `index.toml`,每篇文章使用一个 `[[articles]]` 表:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[articles]]
|
||||||
|
id = "first"
|
||||||
|
folder = "posts/first"
|
||||||
|
title = "第一篇文章"
|
||||||
|
description = "文章简介"
|
||||||
|
tags = ["Rust", "随笔"]
|
||||||
|
public = true
|
||||||
|
# cover = "cover.png"
|
||||||
|
# entry = "main.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
`id` 和 `folder` 用于标识文章及其目录。文章元信息仅接受以下字段:
|
||||||
|
|
||||||
|
| 字段 | 含义与默认值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `title` | 必填标题,最多 240 字节 |
|
||||||
|
| `description` | 简介,省略时为空,最多 1000 字节 |
|
||||||
|
| `tags` | 标签,默认空数组,最多 12 项,每项最多 80 字节 |
|
||||||
|
| `cover` | 可选封面,填写同文章目录中的图片文件名 |
|
||||||
|
| `public` | 是否公开,默认 `false` |
|
||||||
|
| `entry` | 入口文档名,默认 `main.md` |
|
||||||
|
|
||||||
|
作者身份由 SSH 公钥绑定的账户确定。创建时间、首次公开时间和修改时间由服务端生成,以 UTC 时间保存。索引不接受 `author`、`date`、`updated`、精华或权限等额外字段。正文不再使用 Frontmatter。
|
||||||
|
|
||||||
|
文章 ID 在作者内唯一,允许 1 到 40 位小写字母、数字和中间短横线。目录为 `posts/<目录名>`,目录名最多 80 字节并遵循 slug 规则。ID 和目录名不能使用保留名称,例如 `admin` 或 `api`。同一目录不能登记多次。
|
||||||
|
|
||||||
|
文件名只允许 ASCII 字母、数字、短横线、下划线和点,最多 100 字节,不能以点开头或含 `..`。入口允许 `.md`、`.markdown` 和 `.txt`,扩展名须为小写。索引最多 64 KiB、1000 篇文章。要撤回全部文章,明确写入 `articles = []`;空文件缺少文章字段,会暂存并保留原文章。
|
||||||
|
|
||||||
|
## 编写多页文章
|
||||||
|
|
||||||
|
示例目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
index.toml
|
||||||
|
posts/
|
||||||
|
└── first/
|
||||||
|
├── main.md
|
||||||
|
├── chapter.md
|
||||||
|
└── cover.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.md` 内容:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 第一篇文章
|
||||||
|
|
||||||
|
[阅读第二页](chapter.md#details)
|
||||||
|
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
`chapter.md` 内容:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 第二页
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
[返回入口](./main.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端从入口沿文章内相对链接收集页面,允许循环链接,每篇最多 64 个可达页面。链接目标必须是同目录的文本文件,可以带 `#锚点`,不支持子目录、`../`、编码路径和查询参数。相对链接缺少目标文件时,整份索引暂存,原文章不更新。普通 HTTP、HTTPS、mailto 和站内绝对链接继续受安全检查。
|
||||||
|
|
||||||
|
未登记目录、从入口无法到达的文档,以及未被页面引用的图片不进入阅读快照或数据库。封面即使未在正文引用,也作为文章资源导入。所有上传文件仍受白名单、安全检查和仓库配额约束。`.txt` 页面通过 `format: text` 返回,阅读端须作为纯文本显示。
|
||||||
|
|
||||||
|
参考[索引样例](../content/index.toml)、[入口样例](../content/posts/welcome/main.md)和[第二页样例](../content/posts/welcome/chapter.md)。
|
||||||
|
|
||||||
|
## 提交并验证发布
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add index.toml posts/first
|
||||||
git commit -m 'Publish first article'
|
git commit -m 'Publish first article'
|
||||||
git push -u origin main
|
git push -u origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文章目录与清单
|
服务端会校验提交并自动导入。站内文章 ID 为 `作者名~文章ID`,例如 `/api/posts/alice~first`。文章 API 返回入口正文、页面列表和资源地址;页面通过 `/api/posts/alice~first/pages/chapter.md` 读取。前端需将文档相对链接映射到返回的页面 URL,并保留锚点。
|
||||||
|
|
||||||
仓库结构示例:
|
| 提交内容 | 结果 |
|
||||||
|
|
||||||
```text
|
|
||||||
index.txt
|
|
||||||
posts/
|
|
||||||
└── first/
|
|
||||||
├── article.md
|
|
||||||
└── cover.png
|
|
||||||
```
|
|
||||||
|
|
||||||
`index.txt` 为 UTF-8 三列文本,以空格或 Tab 分隔,无表头,允许空行:
|
|
||||||
|
|
||||||
```text
|
|
||||||
first posts/first article.md
|
|
||||||
notes posts/my-notes notes.markdown
|
|
||||||
```
|
|
||||||
|
|
||||||
- 三列依次为**文章 ID、相对文件夹、正文文件名**。
|
|
||||||
- ID 在作者内唯一,支持 1–40 位小写字母、数字及中间短横线,另须避开保留名称。
|
|
||||||
- 目录必须是 `posts/<目录名>`,目录名遵循 slug 规则,最多 80 字节;文件名只允许 ASCII 字母、数字、短横线、下划线与点,不能以点开头或含 `..`。
|
|
||||||
- 同一目录不能重复登记;正文可以任意命名,扩展名为 `.md`、`.markdown` 或 `.txt`。
|
|
||||||
- 索引所指目录和正文必须存在,路径穿越和子目录正文被拒绝。
|
|
||||||
- 索引上限 64 KiB、1000 项。
|
|
||||||
|
|
||||||
## 正文与图片
|
|
||||||
|
|
||||||
- 正文沿用 YAML Frontmatter(参考[正文样例](../content/posts/welcome/index.md)),包含 `title/date/updated/status/tags/description`;`slug` 可省略,如填写则必须等于索引 ID。
|
|
||||||
- `.txt` 正文也需要 Frontmatter,API 通过 `format: text` 表明正文应作为纯文本展示;其他正文为 `format: markdown`。
|
|
||||||
- 图片置于同一目录,通过 `` 引用。
|
|
||||||
|
|
||||||
## 新增、修改与撤回
|
|
||||||
|
|
||||||
| 作者操作 | 系统处理 |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 新建正文并在索引新增一行 | 校验后导入文章,在 ChuSQL 创建作者、ID、正文路径、状态等元信息 |
|
| 有效索引、新增文章且入口存在 | 导入文章并创建数据库元信息与页面记录 |
|
||||||
| 修改正文或索引中的目录/文件名,保留 ID | 更新同一文章;站内 ID 不随目录移动而变化 |
|
| 有效索引、修改正文或元信息 | 更新文章,保留首次公开时间;内容实际变化时更新修改时间 |
|
||||||
| 删除索引中的一行 | 移除当前阅读内容、图片入口及对应数据库元信息,无需先删除本地正文 |
|
| 有效索引、删除文章表 | 撤回文章、页面、资源入口及数据库派生记录 |
|
||||||
| 把索引清空 | 移除该作者所有当前文章,不影响其他作者 |
|
| 有效索引、入口文件或目录缺失 | 将该文章视为不存在,撤回旧版本;其他有效文章仍可导入 |
|
||||||
|
| TOML 缺失、语法错误、未知字段或元信息无效 | Git 提交暂存,不修改该作者原有文章,等待修复后的下一次推送 |
|
||||||
|
| 脚本、非法文件类型、危险路径或内容超限 | 拒绝整个推送,远端分支不前进 |
|
||||||
|
|
||||||
- 未被索引选中的正文及目录视为无效内容,不导入阅读快照或数据库;图片只有被选中正文引用才作为公开资源导入;仍计算仓库容量并执行安全校验。
|
`public = false` 的内容不会由公共文章、页面或资源接口返回。登录后查看内部文章的功能仍在待办中。删除和撤回不擦除个人 Git 历史,也不收回作者已克隆的副本。
|
||||||
- 删除正文但保留其索引项属于错误,整次推送会被拒绝。
|
|
||||||
- `status: draft` 可保留登记但不公开;物理删除文件和撤回文章均不会擦除 Git 历史。
|
|
||||||
|
|
||||||
## 提交与并发限制
|
## 提交限制与状态查询
|
||||||
|
|
||||||
- MVP 每次只接受**一个直接继承个人远端 main 的提交**,首次仅允许一个根提交。
|
每次推送只接受一个直接继承个人远端 `main` 的提交,首次必须是根提交。禁止 merge、force push、删除 `main`、标签和其他分支。多次本地提交须先整理为一次提交,再变基到最新远端。同一作者在多台电脑操作仍需处理冲突,不同作者使用不同仓库锁。
|
||||||
- 禁止 merge、force push、删除 main、标签及其他分支;多次本地提交应先整理为一次提交,再变基到最新远端。
|
|
||||||
- 同一作者在多台电脑操作仍需处理并发冲突,不同作者互不争用同一个仓库锁。
|
|
||||||
|
|
||||||
## 作者隔离与文章地址
|
管理员通过 `GET /api/admin/status` 查看状态。`commit` 是当前生效作者提交映射的集合摘要,`indexed_commit` 是数据库已同步的集合版本。`pending` 按作者返回暂存提交和索引错误。暂存提交不会替换生效版本,API 重启后仍保留原文章和暂存状态。
|
||||||
|
|
||||||
- 站内文章 ID 为 `作者名~文章ID`,例如 Alice 的 `first` 对应 `/api/posts/alice~first`;不同作者可以使用相同的本地 ID。
|
同步通常在下一轮后台处理时完成。数据库失败至少间隔 30 秒重试,作者较多或索引重建时可能延迟。Git 接受不代表文章已公开或数据库已同步。站长审核尚未启用,计划见 [TODO](../TODO.md)。
|
||||||
- 服务端分别保存每位作者的 Git 对象与历史,SSH 不能指定别人的仓库路径;作者无法通过 clone 或按提交哈希 fetch 读取其他作者草稿。
|
|
||||||
- 作者自己已克隆的历史仍可在本地保留,不能靠撤回收回副本。
|
|
||||||
|
|
||||||
## 发布状态
|
|
||||||
|
|
||||||
- API 自动同步,一般约 2 秒,数据库重建、失败重试或较多作者可能延迟。
|
|
||||||
- `GET /api/admin/status` 的 `commit` 是作者提交映射的集合摘要,不是任一作者 Git 提交;`indexed_commit` 表示数据库已同步的集合版本。
|
|
||||||
- Git 接受表示接收校验完成,不保证数据库已同步。
|
|
||||||
- 当前不做站长内容审核,审核计划见 [TODO](../TODO.md)。
|
|
||||||
+7
-2
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
## 文件白名单
|
## 文件白名单
|
||||||
|
|
||||||
- 仅允许根目录 `index.txt` 和 `posts/<目录>/<文件>` 内的 `.txt/.md/.markdown/.png/.jpg/.jpeg/.webp` 普通文件;扩展名使用小写。
|
- 仅允许根目录 `index.toml`(唯一允许的 TOML 文件) 和 `posts/<目录>/<文件>` 内的 `.txt/.md/.markdown/.png/.jpg/.jpeg/.webp` 普通文件;扩展名使用小写。
|
||||||
- 拒绝证书扩展名、隐藏文件、`.gitignore`、脚本、配置、符号链接、可执行位、子模块和其他类型。
|
- 拒绝证书扩展名、隐藏文件、`.gitignore`、脚本、配置、符号链接、可执行位、子模块和其他类型。
|
||||||
- 文本中的 PEM 证书/密钥标记也会拒绝。
|
- 文本中的 PEM 证书/密钥标记也会拒绝。
|
||||||
- Git 接收层能直接拦截,因此非白名单内容使整次推送失败,不静默改变作者提交。
|
- Git 接收层能直接拦截,因此非白名单内容使整次推送失败,不静默改变作者提交。
|
||||||
|
|
||||||
## 容量配额
|
## 容量配额
|
||||||
|
|
||||||
- 每个文本文件上限 256 KiB;图片单张 2 MiB、每目录最多 12 张、尺寸 4096×4096;每个目录合计 6 MiB。
|
- 根索引上限 64 KiB,每个文本文件上限 256 KiB;图片单张 2 MiB、每目录最多 12 张、尺寸 4096×4096;每个目录合计 6 MiB。
|
||||||
- 每位作者最多 1000 个目录、14001 个文件、原始文件合计 64 MiB;全站导入快照另限 64 MiB、1000 篇,超出时等待管理员处理。
|
- 每位作者最多 1000 个目录、14001 个文件、原始文件合计 64 MiB;全站导入快照另限 64 MiB、1000 篇,超出时等待管理员处理。
|
||||||
|
|
||||||
## Git 接收限额
|
## Git 接收限额
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
## 正文解析
|
## 正文解析
|
||||||
|
|
||||||
|
- 同一文章的相对链接仅可引用同目录已验证的文本或图片,禁止跨目录读取;只有从入口可达的最多 64 个页面、页面图片和封面可被导入。
|
||||||
- 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。
|
- 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。
|
||||||
|
|
||||||
## SSH 命令与密钥
|
## SSH 命令与密钥
|
||||||
@@ -48,3 +49,7 @@
|
|||||||
|
|
||||||
- 服务器只返回 JSON Markdown 与图片;后续前端必须把元数据当文本输出,并继续禁用原始 HTML、MDX和危险链接,不能将接口字段直接作为 HTML 执行。
|
- 服务器只返回 JSON Markdown 与图片;后续前端必须把元数据当文本输出,并继续禁用原始 HTML、MDX和危险链接,不能将接口字段直接作为 HTML 执行。
|
||||||
- 隔离不能替代 Git/OpenSSH/解码库的持续安全更新。
|
- 隔离不能替代 Git/OpenSSH/解码库的持续安全更新。
|
||||||
|
|
||||||
|
## 索引暂存与生效版本
|
||||||
|
|
||||||
|
TOML 的语法或元信息错误只更新作者接收仓库中的暂存提交,不替换现有文章或时间记录。文件类型、模式、配额和正文安全检查仍会执行,不能通过破坏 TOML 绕过检查。有效索引中的入口不存在时,该文章不再生效。非公开文章的所有页面和资源均返回 404,防止绕过入口直读。
|
||||||
+63
-15
@@ -10,6 +10,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -18,6 +19,7 @@ import urllib.request
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
ORIGIN = 'http://localhost:18080'
|
ORIGIN = 'http://localhost:18080'
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--browser-test', action='store_true', help='also run Python Playwright frontend journeys')
|
||||||
parser.add_argument('--key-type', choices=['ed25519', 'rsa'], default='ed25519')
|
parser.add_argument('--key-type', choices=['ed25519', 'rsa'], default='ed25519')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
|
|||||||
envfile = tmp / '.env'
|
envfile = tmp / '.env'
|
||||||
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n"
|
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n"
|
||||||
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
|
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
|
||||||
|
f"REGISTRY_PREFIX={os.environ.get('REGISTRY_PREFIX', '')}\n"
|
||||||
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
|
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
|
||||||
envfile.chmod(0o600)
|
envfile.chmod(0o600)
|
||||||
project = 'chublog-test-' + secrets.token_hex(4)
|
project = 'chublog-test-' + secrets.token_hex(4)
|
||||||
@@ -82,7 +85,7 @@ printf '%s\n' "{\"method\":\"login\",\"user\":\"root\",\"password\":\"${CHUSQL_R
|
|||||||
read -r reply <&3
|
read -r reply <&3
|
||||||
printf '%s\n' '{"method":"query","sql":"USE chublog"}' >&3
|
printf '%s\n' '{"method":"query","sql":"USE chublog"}' >&3
|
||||||
read -r reply <&3
|
read -r reply <&3
|
||||||
printf '%s\n' '{"method":"query","sql":"SELECT id, author, article_id, path FROM blog_articles"}' >&3
|
printf '%s\n' '{"method":"query","sql":"SELECT id, author, article_id, path, created_at, published_at, updated_at, entry, cover FROM blog_articles"}' >&3
|
||||||
read -r reply <&3
|
read -r reply <&3
|
||||||
printf '%s\n' "$reply"
|
printf '%s\n' "$reply"
|
||||||
'''
|
'''
|
||||||
@@ -110,12 +113,15 @@ printf '%s\n' "$reply"
|
|||||||
run(['git', '-C', str(local), 'config', 'user.name', 'Alice'])
|
run(['git', '-C', str(local), 'config', 'user.name', 'Alice'])
|
||||||
run(['git', '-C', str(local), 'config', 'user.email', 'alice@example.invalid'])
|
run(['git', '-C', str(local), 'config', 'user.email', 'alice@example.invalid'])
|
||||||
run(['git', '-C', str(local), 'remote', 'add', 'origin', 'git@localhost:content.git'])
|
run(['git', '-C', str(local), 'remote', 'add', 'origin', 'git@localhost:content.git'])
|
||||||
article = local / 'posts/alice-first/index.md'
|
article = local / 'posts/alice-first/main.md'
|
||||||
article.parent.mkdir(parents=True)
|
article.parent.mkdir(parents=True)
|
||||||
original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"')
|
original = (ROOT / 'content/posts/welcome/main.md').read_text()
|
||||||
article.write_text(original)
|
article.write_text(original)
|
||||||
manifest = local / 'index.txt'
|
manifest = local / 'index.toml'
|
||||||
manifest.write_text('alice-first posts/alice-first index.md\n')
|
original_manifest = (ROOT / 'content/index.toml').read_text().replace('id = "welcome"', 'id = "alice-first"').replace('posts/welcome', 'posts/alice-first')
|
||||||
|
manifest.write_text(original_manifest)
|
||||||
|
chapter = article.parent / 'chapter.md'
|
||||||
|
chapter.write_text((ROOT / 'content/posts/welcome/chapter.md').read_text())
|
||||||
|
|
||||||
def commit():
|
def commit():
|
||||||
run(['git', '-C', str(local), 'add', '.'])
|
run(['git', '-C', str(local), 'add', '.'])
|
||||||
@@ -128,24 +134,56 @@ printf '%s\n' "$reply"
|
|||||||
|
|
||||||
commit()
|
commit()
|
||||||
push(False)
|
push(False)
|
||||||
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher', 'prefixes': ['alice-']})
|
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher'})
|
||||||
push()
|
push()
|
||||||
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
|
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
|
||||||
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
|
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
|
||||||
assert database_articles() == [['alice~alice-first', 'alice', 'alice-first', 'posts/alice-first/index.md']]
|
metadata_rows = database_articles()
|
||||||
|
assert len(metadata_rows) == 1
|
||||||
|
assert metadata_rows[0][:4] == ['alice~alice-first', 'alice', 'alice-first', 'posts/alice-first/main.md']
|
||||||
print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL metadata')
|
print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL metadata')
|
||||||
|
first_article = Client().call('GET', '/api/posts/alice~alice-first')
|
||||||
|
assert first_article['author'] == 'alice'
|
||||||
|
assert {p['name'] for p in first_article['pages']} == {'main.md', 'chapter.md'}
|
||||||
|
assert Client().call('GET', '/api/posts/alice~alice-first/pages/chapter.md')['markdown'] == chapter.read_text()
|
||||||
|
original_dates = first_article['meta']
|
||||||
|
assert metadata_rows[0][4:7] == [original_dates['created_at'], original_dates['date'], original_dates['updated']]
|
||||||
|
assert metadata_rows[0][7:] == ['main.md', '']
|
||||||
|
# Malformed TOML is staged and survives restart without replacing the live version.
|
||||||
|
manifest.write_text('[[articles')
|
||||||
|
article.write_text(original + '\nPending change\n')
|
||||||
|
commit()
|
||||||
|
push()
|
||||||
|
wait_for(lambda: 'alice' in admin.call('GET', '/api/admin/status')['pending'])
|
||||||
|
assert Client().call('GET', '/api/posts/alice~alice-first')['markdown'] == original
|
||||||
|
run(compose + ['restart', 'api'])
|
||||||
|
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['markdown'] == original)
|
||||||
|
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
|
||||||
|
assert 'alice' in admin.call('GET', '/api/admin/status')['pending']
|
||||||
|
manifest.write_text(original_manifest)
|
||||||
|
commit()
|
||||||
|
push()
|
||||||
|
wait_for(lambda: 'Pending change' in Client().call('GET', '/api/posts/alice~alice-first')['markdown'])
|
||||||
|
updated = Client().call('GET', '/api/posts/alice~alice-first')['meta']
|
||||||
|
assert updated['date'] == original_dates['date']
|
||||||
|
assert updated['created_at'] == original_dates['created_at']
|
||||||
|
assert updated['updated'] > original_dates['updated']
|
||||||
|
print('PASS: linked Markdown pages, staged broken TOML across restart, repaired import and server timestamps')
|
||||||
|
article.write_text(original)
|
||||||
|
commit()
|
||||||
|
push()
|
||||||
ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost']
|
ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost']
|
||||||
result = subprocess.run(ssh + ['id'], capture_output=True)
|
result = subprocess.run(ssh + ['id'], capture_output=True)
|
||||||
assert result.returncode != 0, 'SSH shell command unexpectedly succeeded'
|
assert result.returncode != 0, 'SSH shell command unexpectedly succeeded'
|
||||||
# Invalid commits must not advance the remote branch. Reset only this disposable local test clone.
|
# Invalid commits must not advance the remote branch. Reset only this disposable local test clone.
|
||||||
for kind in ['script', 'symlink', 'manifest', 'certificate', 'gitignore', 'oversize', 'pack']:
|
for kind in ['script', 'symlink', 'traversal', 'certificate', 'gitignore', 'oversize', 'pack']:
|
||||||
if kind == 'script':
|
if kind == 'script':
|
||||||
article.write_text(original + '\n<script>alert(1)</script>\n')
|
article.write_text(original + '\n<script>alert(1)</script>\n')
|
||||||
elif kind == 'symlink':
|
elif kind == 'symlink':
|
||||||
article.unlink()
|
article.unlink()
|
||||||
article.symlink_to('/etc/passwd')
|
article.symlink_to('/etc/passwd')
|
||||||
elif kind == 'manifest':
|
elif kind == 'traversal':
|
||||||
manifest.write_text('alice-first posts/missing index.md\n')
|
article.write_text(original + '\n[escape](../other/main.md)\n')
|
||||||
elif kind == 'certificate':
|
elif kind == 'certificate':
|
||||||
(article.parent / 'certificate.txt').write_text('-----BEGIN CERTIFICATE-----\ninvalid\n')
|
(article.parent / 'certificate.txt').write_text('-----BEGIN CERTIFICATE-----\ninvalid\n')
|
||||||
elif kind == 'gitignore':
|
elif kind == 'gitignore':
|
||||||
@@ -159,7 +197,7 @@ printf '%s\n' "$reply"
|
|||||||
run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True)
|
run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True)
|
||||||
push(False, 'main:refs/heads/other')
|
push(False, 'main:refs/heads/other')
|
||||||
push(False, '--delete', 'main')
|
push(False, '--delete', 'main')
|
||||||
print('PASS: arbitrary command, HTML, symlink, invalid index, disguised certificate, gitignore, oversize, branch and deletion rejected')
|
print('PASS: arbitrary command, HTML, symlink, cross-article link, disguised certificate, gitignore, oversize, branch and deletion rejected')
|
||||||
# A second authorized key maps the same remote alias to a separate object store.
|
# A second authorized key maps the same remote alias to a separate object store.
|
||||||
bob = Client()
|
bob = Client()
|
||||||
bob.call('POST', '/api/auth/register', {'username': 'bob', 'password': password})
|
bob.call('POST', '/api/auth/register', {'username': 'bob', 'password': password})
|
||||||
@@ -171,7 +209,7 @@ printf '%s\n' "$reply"
|
|||||||
bob_env['GIT_SSH_COMMAND'] = env['GIT_SSH_COMMAND'].replace(str(tmp / 'key'), str(tmp / 'bob-key'))
|
bob_env['GIT_SSH_COMMAND'] = env['GIT_SSH_COMMAND'].replace(str(tmp / 'key'), str(tmp / 'bob-key'))
|
||||||
bob_local = tmp / 'bob-articles'
|
bob_local = tmp / 'bob-articles'
|
||||||
run(['git', 'clone', 'git@localhost:content.git', str(bob_local)], env=bob_env, capture_output=True)
|
run(['git', 'clone', 'git@localhost:content.git', str(bob_local)], env=bob_env, capture_output=True)
|
||||||
assert not (bob_local / 'posts/alice-first/index.md').exists(), 'Alice content leaked into Bob clone'
|
assert not (bob_local / 'posts/alice-first/main.md').exists(), 'Alice content leaked into Bob clone'
|
||||||
alice_commit = run(['git', '-C', str(local), 'rev-parse', 'HEAD'], capture_output=True).stdout.strip()
|
alice_commit = run(['git', '-C', str(local), 'rev-parse', 'HEAD'], capture_output=True).stdout.strip()
|
||||||
result = subprocess.run(['git', '-C', str(bob_local), 'fetch', 'origin', alice_commit], env=bob_env, capture_output=True)
|
result = subprocess.run(['git', '-C', str(bob_local), 'fetch', 'origin', alice_commit], env=bob_env, capture_output=True)
|
||||||
assert result.returncode != 0, 'Bob fetched Alice commit by hash'
|
assert result.returncode != 0, 'Bob fetched Alice commit by hash'
|
||||||
@@ -179,7 +217,7 @@ printf '%s\n' "$reply"
|
|||||||
assert result.returncode != 0, 'Bob selected another repository path'
|
assert result.returncode != 0, 'Bob selected another repository path'
|
||||||
print('PASS: separate author clone, inaccessible foreign commit and forbidden repository selection')
|
print('PASS: separate author clone, inaccessible foreign commit and forbidden repository selection')
|
||||||
# Removing only an index entry deletes the live article, while retaining its files.
|
# Removing only an index entry deletes the live article, while retaining its files.
|
||||||
manifest.write_text('')
|
manifest.write_text('articles = []\n')
|
||||||
commit()
|
commit()
|
||||||
push()
|
push()
|
||||||
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
|
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
|
||||||
@@ -187,12 +225,20 @@ printf '%s\n' "$reply"
|
|||||||
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
|
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
|
||||||
assert database_articles() == [], 'Index removal did not delete database metadata'
|
assert database_articles() == [], 'Index removal did not delete database metadata'
|
||||||
print('PASS: index-only deletion removes HTTP article and database metadata')
|
print('PASS: index-only deletion removes HTTP article and database metadata')
|
||||||
manifest.write_text('alice-first posts/alice-first index.md\n')
|
manifest.write_text(original_manifest)
|
||||||
article.write_text(original.replace('status: "published"', 'status: "draft"'))
|
# A valid index whose entry disappeared denotes no article, not a staged update.
|
||||||
|
article.unlink()
|
||||||
|
commit()
|
||||||
|
push()
|
||||||
|
wait_for(lambda: not admin.call('GET', '/api/admin/status')['pending'])
|
||||||
|
assert Client().call('GET', '/api/stats')['published_posts'] == 0
|
||||||
|
article.write_text(original)
|
||||||
|
manifest.write_text(original_manifest.replace('public = true', 'public = false'))
|
||||||
commit()
|
commit()
|
||||||
push()
|
push()
|
||||||
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
|
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
|
||||||
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
|
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
|
||||||
|
manifest.write_text(original_manifest)
|
||||||
article.write_text(original)
|
article.write_text(original)
|
||||||
commit()
|
commit()
|
||||||
push()
|
push()
|
||||||
@@ -209,6 +255,8 @@ printf '%s\n' "$reply"
|
|||||||
run(compose + ['start', 'db'])
|
run(compose + ['start', 'db'])
|
||||||
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
|
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
|
||||||
print('PASS: draft privacy, database outage, API restart recovery, revocation, automatic index recovery')
|
print('PASS: draft privacy, database outage, API restart recovery, revocation, automatic index recovery')
|
||||||
|
if args.browser_test:
|
||||||
|
run([sys.executable, str(ROOT / 'scripts/test-web.py'), '--origin', ORIGIN], input=password)
|
||||||
except Exception:
|
except Exception:
|
||||||
run(compose + ['logs', '--tail', '40'])
|
run(compose + ['logs', '--tail', '40'])
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the deployed Astro frontend against the real API. Password is read from stdin.
|
||||||
|
Requires Python Playwright and Chromium. Invoked optionally by smoke.py --browser-test.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--origin', required=True)
|
||||||
|
parser.add_argument('--screenshots', type=Path, default=Path('/tmp/chublog-web-screenshots'))
|
||||||
|
args = parser.parse_args()
|
||||||
|
password = sys.stdin.read().strip()
|
||||||
|
assert password, 'Provide the temporary test password on stdin'
|
||||||
|
args.screenshots.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(viewport={'width': 1440, 'height': 1000}, locale='zh-CN')
|
||||||
|
page = context.new_page()
|
||||||
|
failures = []
|
||||||
|
page.on('pageerror', lambda error: failures.append(str(error)))
|
||||||
|
page.on('console', lambda message: failures.append(message.text) if 'Content Security Policy' in message.text or 'Refused to' in message.text else None)
|
||||||
|
page.goto(args.origin)
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
expect(page.get_by_role('heading', name='个人博客正在施工中')).to_be_visible()
|
||||||
|
for selector in ['.construction', '.construction h1', '.construction-progress h2', '.construction-progress p', '.site-header', '.site-footer']:
|
||||||
|
assert page.locator(selector).first.evaluate('(e) => getComputedStyle(e).textAlign') == 'center', selector
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
page.screenshot(path=str(args.screenshots / 'home-desktop.png'), full_page=True)
|
||||||
|
page.keyboard.press('Tab')
|
||||||
|
expect(page.get_by_role('link', name='跳到正文')).to_be_focused()
|
||||||
|
page.get_by_role('link', name='阅读文章').click()
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
expect(page.get_by_role('heading', name='第一篇文章')).to_be_visible()
|
||||||
|
page.get_by_label('搜索文章').fill('没有这样的标题')
|
||||||
|
page.get_by_role('button', name='搜索', exact=True).click()
|
||||||
|
expect(page.get_by_role('heading', name='没有匹配的文章')).to_be_visible()
|
||||||
|
page.get_by_role('link', name='查看全部文章').click()
|
||||||
|
page.get_by_role('heading', name='第一篇文章').get_by_role('link').click()
|
||||||
|
expect(page.locator('.prose')).to_contain_text('你好,ChuBlog')
|
||||||
|
page.screenshot(path=str(args.screenshots / 'article-desktop.png'), full_page=True)
|
||||||
|
page.locator('.prose').get_by_role('link', name='阅读下一页').click()
|
||||||
|
expect(page.locator('.prose')).to_contain_text('第二页')
|
||||||
|
page.locator('.prose').get_by_role('link', name='返回入口').click()
|
||||||
|
expect(page.locator('.prose')).to_contain_text('你好,ChuBlog')
|
||||||
|
page.goto(args.origin + '/posts/alice~missing')
|
||||||
|
expect(page.get_by_role('heading', name='文章不存在')).to_be_visible()
|
||||||
|
|
||||||
|
def login(target, username, secret):
|
||||||
|
target.goto(args.origin + '/login')
|
||||||
|
target.get_by_label('用户名', exact=True).fill(username)
|
||||||
|
target.get_by_label('密码', exact=True).fill(secret)
|
||||||
|
target.get_by_role('button', name='登录', exact=True).click()
|
||||||
|
expect(target.locator('#account-name')).to_have_text(username)
|
||||||
|
|
||||||
|
login(page, 'alice', password)
|
||||||
|
expect(page.locator('#account-role')).to_have_text('普通用户')
|
||||||
|
page.get_by_role('button', name='保存公钥').click()
|
||||||
|
expect(page.locator('#key-form .form-status')).to_have_text('公钥已保存。发布仍需站长授权。')
|
||||||
|
page.screenshot(path=str(args.screenshots / 'account-desktop.png'), full_page=True)
|
||||||
|
page.get_by_role('button', name='退出登录').click()
|
||||||
|
expect(page.get_by_role('heading', name='个人博客正在施工中')).to_be_visible()
|
||||||
|
|
||||||
|
# Registration, persistence, password change and subsequent login use real backend sessions.
|
||||||
|
page.goto(args.origin + '/login?mode=register')
|
||||||
|
page.get_by_label('用户名', exact=True).fill('web-reader')
|
||||||
|
page.get_by_label('密码', exact=True).fill(password)
|
||||||
|
page.get_by_label('确认密码', exact=True).fill(password)
|
||||||
|
page.get_by_role('button', name='创建账户').click()
|
||||||
|
expect(page.locator('#account-name')).to_have_text('web-reader')
|
||||||
|
page.get_by_label('原密码', exact=True).fill(password)
|
||||||
|
new_password = password + '-changed'
|
||||||
|
page.get_by_label('新密码', exact=True).fill(new_password)
|
||||||
|
page.get_by_role('button', name='修改密码', exact=True).click()
|
||||||
|
expect(page.locator('.notice')).to_have_text('密码已修改,请重新登录。')
|
||||||
|
login(page, 'web-reader', new_password)
|
||||||
|
|
||||||
|
admin_context = browser.new_context(viewport={'width': 1440, 'height': 1000}, locale='zh-CN')
|
||||||
|
admin = admin_context.new_page()
|
||||||
|
admin.on('pageerror', lambda error: failures.append(str(error)))
|
||||||
|
login(admin, 'admin', password)
|
||||||
|
expect(admin.get_by_role('heading', name='发布授权')).to_be_visible()
|
||||||
|
alice_form = admin.locator('.admin-user').filter(has=admin.get_by_role('heading', name='alice', exact=True))
|
||||||
|
alice_form.get_by_label('alice 的角色').select_option('publisher')
|
||||||
|
alice_form.get_by_role('button', name='保存授权').click()
|
||||||
|
expect(alice_form.get_by_role('status')).to_have_text('授权已保存。')
|
||||||
|
admin_context.close()
|
||||||
|
|
||||||
|
page.set_viewport_size({'width': 390, 'height': 844})
|
||||||
|
page.goto(args.origin)
|
||||||
|
page.wait_for_load_state('networkidle')
|
||||||
|
expect(page.get_by_role('heading', name='个人博客正在施工中')).to_be_visible()
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
page.screenshot(path=str(args.screenshots / 'home-mobile.png'), full_page=True)
|
||||||
|
page.goto(args.origin + '/posts/alice~alice-first')
|
||||||
|
expect(page.locator('.prose')).to_contain_text('你好,ChuBlog')
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||||
|
page.screenshot(path=str(args.screenshots / 'article-mobile.png'), full_page=True)
|
||||||
|
assert not failures, failures
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
print('PASS: centered responsive homepage, search, multi-page navigation, registration/login, SSH key, password and admin grant UI; no script/CSP errors')
|
||||||
Reference in new issue
Block a user