Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d4e7392
fix: pin immutable setup-uv release
seonghobae Jul 19, 2026
a004e74
build: pin GitHub Actions by commit
seonghobae Jul 19, 2026
e126e29
security: harden CalDAV transport and image
seonghobae Jul 19, 2026
2a0ca0a
build: install hash-locked runtime dependencies
seonghobae Jul 19, 2026
5adc875
docs: correct research journal citation
seonghobae Jul 19, 2026
d996011
docs: link private vulnerability reporting
seonghobae Jul 19, 2026
7f2e498
docs: design Korean lunar and private calendar flow
seonghobae Jul 19, 2026
548c605
docs: plan Korean lunar privacy follow-up
seonghobae Jul 19, 2026
dcb43d6
feat: normalize Korean lunar birth dates
seonghobae Jul 19, 2026
43b6760
feat: accept solar and Korean lunar profiles
seonghobae Jul 19, 2026
dd63c9e
feat: explain calendar rules in Korean
seonghobae Jul 19, 2026
b004faf
feat: search from the current local date
seonghobae Jul 19, 2026
628830f
feat: minimize CalDAV event disclosure
seonghobae Jul 19, 2026
ccdb0aa
privacy: keep acceptance fixture out of public artifacts
seonghobae Jul 19, 2026
876f03d
feat: explain preview matches in Korean
seonghobae Jul 19, 2026
e653243
docs: record lunar and CalDAV privacy decisions
seonghobae Jul 19, 2026
5116908
test: allow long CalDAV acceptance syncs
seonghobae Jul 19, 2026
1b485de
fix: make schema migration statically auditable
seonghobae Jul 19, 2026
e351939
feat: resolve birth cities and calendar visibility
seonghobae Jul 19, 2026
4811f56
build: vendor deterministic lunar calendar source
seonghobae Jul 19, 2026
96fa833
fix: package vendored lunar calendar in container
seonghobae Jul 19, 2026
a565ea1
fix: keep rule defaults privacy-neutral
seonghobae Jul 20, 2026
f710749
fix(calendar): exclude elapsed default windows
seonghobae Jul 22, 2026
13a45ff
fix(review): harden runtime error paths
seonghobae Jul 22, 2026
06d0448
fix(api): report invalid calendar metadata
seonghobae Jul 22, 2026
e5d589b
fix(ui): preserve manual birthplace selection
seonghobae Jul 22, 2026
422cbc8
docs(research): link lunar conversion license
seonghobae Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v8
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.28"
enable-cache: true
Expand All @@ -28,5 +28,5 @@ jobs:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- run: docker build --tag saju-caldav:ci .
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
FROM python:3.12-slim
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HOME=/tmp

WORKDIR /srv/saju-caldav
COPY pyproject.toml uv.lock README.md ./
COPY pyproject.toml uv.lock requirements.lock README.md ./
COPY app ./app
COPY lunar_python ./lunar_python
COPY radicale ./radicale
COPY scripts ./scripts
RUN python -m pip install --no-cache-dir . \
RUN python -m pip install --no-cache-dir --require-hashes -r requirements.lock \
&& mkdir -p /data/app /data/radicale \
&& chown -R 10001:10001 /srv/saju-caldav /data

Expand Down
46 changes: 22 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,21 @@
# 時曆 · Saju CalDAV
# 사주 캘린더 · CalDAV

양력 출생 시각에서 사주 네 기둥을 계산하고, 간지 조건이 맞는 날짜와 시각을
개인 CalDAV 캘린더로 발행하는 단일 운영자용 서비스입니다.
양력 또는 한국 음력 출생 시각에서 사주 네 기둥을 계산하고, 사용자가 만든
조건과 맞는 현재 이후의 실제 날짜·시각을 개인 CalDAV 캘린더로 발행하는
단일 운영자용 서비스입니다.

> 문화·역법 참고 도구입니다. 운세의 과학적 타당성이나 미래 사건과의
> 인과관계를 주장하지 않습니다.

## 검증 기준

양력 `1990-06-15 08:30`, 여성, `Asia/Seoul`, 표준시 입력은 다음과 같이
고정 검증됩니다.

| 년주 | 월주 | 일주 | 시주 |
| --- | --- | --- | --- |
| 庚午 | 壬午 | 辛亥 | 壬辰 |

- 일지: `亥水`
- 시간: `壬水`
- 같은 사람의 일지와 같은 `亥`일이면서 시간이 `壬`인 조건의 해당일
미리보기: `1990-06-15 07:00–09:00 KST`

이 기준은 `lunar-python`과 독립 구현 `sxtwl`로 교차 확인했고, 저장소의
회귀 테스트로 고정했습니다.

## 기능

- 출생 프로필별 년주·월주·일주·시주 계산
- 표준시 기본, 출생지 경도를 사용한 진태양시 보정 선택
- 양력·한국 음력·윤달 출생 입력과 계산용 양력 시각의 분리 보존
- 출생 프로필별 년주·월주·일주·시주 계산과 한글 뜻 우선 설명
- 출생 도시에서 IANA 시간대를 자동 선택하고 좌표 입력 없이 표준시 계산
- 선택한 도시의 서버 내부 경도를 사용하는 진태양시 보정 선택
- 일주·시주의 천간, 지지, 오행을 조합하는 허용 목록 기반 규칙
- 날짜 범위 미리보기와 결정적 RFC 5545 iCalendar 이벤트 생성
- 프로필 시간대의 오늘부터 1년간 조건 일치 시각 미리보기
- 비공개·제한 공개·공개 표시를 고르는 RFC 5545 iCalendar 이벤트
- Radicale을 통한 RFC 4791 CalDAV 캘린더 발행
- HTTP Basic으로 보호되는 한국어 운영자 웹 콘솔
- SQLite 메타데이터와 소유자 전용 CalDAV 저장소
Expand Down Expand Up @@ -66,6 +53,13 @@ uv run pytest -q
uv run ruff check .
```

공개 저장소에 둘 수 없는 실제 회귀값은 환경 변수로만 주입해 로컬에서
검증합니다. 이 명령은 입력값을 출력하지 않습니다.

```bash
uv run python scripts/private_regression.py
```

실행 중인 전체 스택은 실제 프로필 생성부터 CalDAV 읽기까지 검증할 수 있습니다.

```bash
Expand All @@ -83,9 +77,13 @@ uv run python scripts/acceptance_smoke.py
- [Figma 운영자 콘솔](https://www.figma.com/design/P4wdj2MnYwItYch3zdGIWt)
- [제품 설계](docs/superpowers/specs/2026-07-19-saju-caldav-design.md)
- [구현 계획](docs/superpowers/plans/2026-07-19-saju-caldav.md)
- [제3자 소프트웨어 고지](THIRD_PARTY_NOTICES.md)

## 개인정보

출생 정보는 민감한 개인정보로 취급합니다. 실제 데이터베이스, `.env`, CalDAV
컬렉션, 운영 로그는 저장소나 공개 CI로 보내지 않습니다. 공개 테스트에는 오직
사용자가 명시한 회귀 검증용 합성 데이터만 포함합니다.
서비스와 무관한 합성 데이터만 포함합니다. 실제 회귀 입력과 기대값은 환경
변수로만 전달하며, CalDAV 이벤트에도 사주 원문이나 규칙 값을 기록하지 않습니다.
`PUBLIC`을 선택해도 출생 정보와 규칙은 이벤트에 들어가지 않으며, Radicale의
계정 인증과 소유자 전용 접근 제어는 그대로 유지됩니다.
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Security policy

## Supported versions

Security fixes are applied to the current default branch. This project is not
yet publishing versioned release lines.

## Reporting a vulnerability

Please report suspected vulnerabilities through the repository's
[private GitHub security-advisory channel](https://github.com/ContextualWisdomLab/saju-caldav/security/advisories/new).
Do not open a public issue with credentials, birth data, calendar contents,
deployment addresses, or exploit details.

Include the affected commit, a minimal reproduction, expected impact, and any
suggested mitigation. Maintainers will acknowledge a complete report within
seven days and will coordinate disclosure after a fix is available.

## Sensitive data

Birth information and generated calendars can be personally sensitive. Use
synthetic data for public reports and remove CalDAV credentials from logs and
attachments.
29 changes: 29 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 제3자 소프트웨어 고지

## lunar-python 1.4.8

- 프로젝트: <https://github.com/6tail/lunar-python>
- 배포물: <https://pypi.org/project/lunar-python/1.4.8/>
- 원본 소스 아카이브 SHA-256:
`3aa11cc73c25e70ddf0ba5bdac7398c03acc9491a3aa512a91c9642973b669d6`
- 라이선스: MIT
- 저장 위치: `lunar_python/`
- 라이선스 전문: `licenses/lunar-python-MIT.txt`

이 저장소는 정확한 버전의 순수 Python 소스를 포함합니다. 이를 통해 운영 이미지와
네트워크가 차단된 검증 환경에서 제3자 소스 배포물의 설치 스크립트를 실행하지 않고도
동일한 역법 계산 코드를 사용합니다.
Comment thread
seonghobae marked this conversation as resolved.
Resolved

## korean-lunar-calendar 0.4.0

- 프로젝트: <https://github.com/usingsky/korean_lunar_calendar_py>
- 배포물: <https://pypi.org/project/korean-lunar-calendar/0.4.0/>
- 소스 배포물 SHA-256:
`be56f27bc0594fdbbdf7bbe00f504a9f929a31e311bd7d9bb93561b645afade7`
- 범용 Python wheel SHA-256:
`c042e20de0bb702add6bec8d0f6da1ea8d3b170838e63846f70420cf341fe4e7`
- 라이선스: MIT
- 라이선스 전문: `licenses/korean-lunar-calendar-MIT.txt`

이 패키지는 한국천문연구원 기준의 한국 음력과 그레고리력 사이 변환에 사용하는
고정 버전 런타임 의존성입니다. 배포물 해시는 잠금 파일의 설치 허용 목록과 같습니다.
49 changes: 49 additions & 0 deletions app/birth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Normalize solar and Korean lunar birth input to Gregorian local time."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Literal

from korean_lunar_calendar import KoreanLunarCalendar


@dataclass(frozen=True, slots=True)
class BirthInput:
calendar: Literal["solar", "lunar"]
year: int
month: int
day: int
at: time
is_leap_month: bool = False


def normalize_birth(value: BirthInput) -> datetime:
"""Return a naive Gregorian wall time for a validated birth input."""

if value.calendar == "solar":
if value.is_leap_month:
raise ValueError("양력에는 윤달을 지정할 수 없습니다")
try:
solar_date = date(value.year, value.month, value.day)
except ValueError as error:
raise ValueError("유효하지 않은 양력 날짜입니다") from error
return datetime.combine(solar_date, value.at)

converter = KoreanLunarCalendar()
if not converter.setLunarDate(
value.year,
value.month,
value.day,
value.is_leap_month,
):
raise ValueError("지원하지 않거나 존재하지 않는 한국 음력·윤달 날짜입니다")
return datetime(
converter.solarYear,
converter.solarMonth,
converter.solarDay,
value.at.hour,
value.at.minute,
value.at.second,
)
107 changes: 61 additions & 46 deletions app/caldav.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

from __future__ import annotations

import base64
import hashlib
from dataclasses import dataclass
from datetime import UTC
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from urllib.parse import quote, urlsplit
from xml.sax.saxutils import escape

import httpx
from icalendar import Calendar, Event

from app.events import MatchingWindow

VISIBILITY_CLASSES = {
"private": "PRIVATE",
"confidential": "CONFIDENTIAL",
"public": "PUBLIC",
}


def event_uid(calendar_id: str, window: MatchingWindow) -> str:
identity = f"{calendar_id}|{window.start.isoformat()}|{window.end.isoformat()}|v1"
Expand All @@ -24,8 +28,17 @@ def event_uid(calendar_id: str, window: MatchingWindow) -> str:
def build_icalendar(
calendar_id: str,
calendar_name: str,
visibility: str,
window: MatchingWindow,
) -> bytes:
try:
ical_class = VISIBILITY_CLASSES[visibility]
except KeyError as error:
allowed = ", ".join(VISIBILITY_CLASSES)
raise ValueError(
f"지원하지 않는 캘린더 공개 수준: {visibility!r}; "
f"사용할 수 있는 값: {allowed}"
) from error
calendar = Calendar()
calendar.add("prodid", "-//ContextualWisdomLab//Saju CalDAV//KO")
calendar.add("version", "2.0")
Expand All @@ -37,28 +50,10 @@ def build_icalendar(
event.add("dtstamp", window.start.astimezone(UTC))
event.add("dtstart", window.start)
event.add("dtend", window.end)
event.add(
"summary",
f"{window.chart.day.ganzhi}일 · {window.chart.hour.ganzhi}시 — {calendar_name}",
)
event.add(
"description",
"\n".join(
(
f"규칙 캘린더: {calendar_name}",
f"일주: {window.chart.day.ganzhi}",
f"일지: {window.chart.day.branch}{window.chart.day.branch_element}",
f"시주: {window.chart.hour.ganzhi}",
f"시간: {window.chart.hour.stem}{window.chart.hour.stem_element}",
"문화·역법 참고용이며 운세의 과학적 타당성을 주장하지 않습니다.",
)
),
)
event.add("summary", calendar_name)
event.add("description", "사용자가 설정한 맞춤 시간입니다.")
event.add("transp", "TRANSPARENT")
event.add("class", "PRIVATE")
event.add("categories", ["SAJU", "GANZHI"])
event.add("x-saju-day-pillar", window.chart.day.ganzhi)
event.add("x-saju-hour-pillar", window.chart.hour.ganzhi)
event.add("class", ical_class)
calendar.add_component(event)
return calendar.to_ical()

Expand All @@ -71,7 +66,21 @@ class SyncResult:

class CalDavPublisher:
def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None:
self.base_url = base_url.rstrip("/")
normalized_url = base_url.rstrip("/")
parsed = urlsplit(normalized_url)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"CalDAV base URL must be an http or https URL without credentials, "
"query, or fragment"
)
self.base_url = normalized_url
Comment thread
seonghobae marked this conversation as resolved.
Resolved
self.username = username
self.password = password
self.timeout = timeout
Expand All @@ -84,26 +93,31 @@ def _request(
content_type: str,
accepted: set[int],
) -> int:
token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
request = Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Basic {token}",
"Content-Type": content_type,
"Accept": "application/xml, text/calendar",
},
)
try:
with urlopen(request, timeout=self.timeout) as response: # noqa: S310
status = response.status
except HTTPError as error:
if error.code in accepted:
return error.code
raise RuntimeError(f"CalDAV {method} failed with HTTP {error.code}") from error
except URLError as error:
raise RuntimeError(f"CalDAV {method} connection failed: {error.reason}") from error
response = httpx.request(
method,
url,
content=data,
headers={
"Content-Type": content_type,
"Accept": "application/xml, text/calendar",
},
auth=(self.username, self.password),
timeout=self.timeout,
follow_redirects=False,
)
except httpx.RequestError as error:
summary = " ".join(str(error).split())
for sensitive in (self.username, self.password):
if sensitive:
summary = summary.replace(sensitive, "<redacted>")
reason = type(error).__name__
if summary:
reason = f"{reason}: {summary[:240]}"
raise RuntimeError(
f"CalDAV {method} connection failed ({reason})"
) from error
status = response.status_code
if status not in accepted:
raise RuntimeError(f"CalDAV {method} returned unexpected HTTP {status}")
return status
Expand All @@ -113,6 +127,7 @@ def sync(
calendar_id: str,
slug: str,
calendar_name: str,
visibility: str,
windows: list[MatchingWindow],
) -> SyncResult:
user_path = quote(self.username, safe="")
Expand All @@ -138,7 +153,7 @@ def sync(
self._request(
"PUT",
f"{collection_url}{uid}.ics",
build_icalendar(calendar_id, calendar_name, window),
build_icalendar(calendar_id, calendar_name, visibility, window),
"text/calendar; charset=utf-8",
{200, 201, 204},
)
Expand Down
Loading
Loading