moina
요약. moina: 자율 개선 회차 21회, 릴리즈 9건. 최근 릴리즈 v0.1.27 (자산 1개). 건강 D
- 21회차
- 1프로젝트
- 8배포 준비 완료
- 2릴리즈 진행 중
- 4병합 완료
- 2검토 대기
- 3검증 실패
- 1변경 없음
- 1실행 오류
- $58.40비용
- 2시간 11분에이전트 시간
현황
- 저장소
- https://github.com/hkjang/moina
- 마지막 회차
- 2026-09-12 19:44 KST — ❌ 실패 CI failed, PR open PR #19
- 최근 릴리즈
- v0.1.27 — released · 자산 1개 (이전 v0.1.26: 1개) 전체 릴리즈 →
회차 이력
| 일시 | 프로젝트 | 결과 |
|---|---|---|
| 2026-09-12 19:44 | moina | 검증 실패 CI failed, PR open PR #19 |
| 2026-09-11 06:14 | moina | 검토 대기 review held, PR open PR #18 |
| 2026-09-10 20:48 | moina | 검토 대기 review held, PR open PR #17 |
| 2026-09-10 20:41 | moina | 실행 오류 hold: budget |
| 2026-09-10 14:22 | moina | 배포 준비 완료 merged PR #16, released v0.1.27 |
| 2026-09-10 00:15 | moina | 검증 실패 CI failed, PR open PR #13 |
| 2026-09-09 09:10 | moina | 배포 준비 완료 merged PR #12, released v0.1.26 |
| 2026-09-09 04:34 | moina | 배포 준비 완료 release-only, released v0.1.25 |
| 2026-09-09 02:12 | moina | 검증 실패 CI failed, PR open PR #11 |
| 2026-09-08 21:26 | moina | 릴리즈 진행 중 merged PR #10, release tag held (failed) |
| 2026-09-07 09:49 | moina | 병합 완료 release-only, release skipped |
| 2026-09-07 02:36 | moina | 병합 완료 merged PR #9, release blocked (secrets) |
| 2026-09-06 16:26 | moina | 병합 완료 merged PR #8, release blocked (secrets) |
| 2026-09-05 05:15 | moina | 릴리즈 진행 중 merged PR #7, released v0.1.21, ASSETS MISSING |
| 2026-09-04 09:27 | moina | 병합 완료 merged PR #6, release missing |
| 2026-09-03 23:34 | moina | 변경 없음 no change |
| 2026-09-03 15:48 | moina | 배포 준비 완료 merged PR #5, released v0.1.20 |
| 2026-09-03 09:09 | moina | 배포 준비 완료 merged PR #4, released v0.1.19 |
| 2026-09-03 03:22 | moina | 배포 준비 완료 merged PR #3, released v0.1.18 |
| 2026-09-02 21:08 | moina | 배포 준비 완료 merged PR #2, released v0.1.17 |
| 2026-09-02 14:48 | moina | 배포 준비 완료 merged PR #1, released v0.1.16 |
비용·사용량
| 시각 | 프로젝트 | 단계 | 시간 | 턴 | 비용 | 토큰 입력/출력 | 종료 |
|---|---|---|---|---|---|---|---|
| 19:42 | moina | review | 4분 | 20 | $1.79 | 1.3M / 14K | success |
| 19:38 | moina | 개선 | 25분 | 85 | $11.68 | 14.3M / 87K | success |
| 06:14 | moina | review | 5분 | 42 | $2.71 | 2.8M / 18K | success |
| 06:09 | moina | 개선 | 20분 | 123 | $11.81 | 15.9M / 64K | success |
| 20:48 | moina | review | 2분 | 12 | $0.63 | 367K / 8K | success |
| 20:45 | moina | 개선 | 4분 | 29 | $1.74 | 1.5M / 17K | success |
| 14:07 | moina | 릴리즈 | 3분 | 28 | $1.19 | 1.1M / 8K | success |
| 14:01 | moina | review | 5분 | 27 | $1.34 | 878K / 17K | success |
| 13:56 | moina | 개선 | 6분 | 45 | $2.37 | 2.3M / 21K | success |
| 00:10 | moina | review | 2분 | 13 | $0.62 | 296K / 9K | success |
| 00:08 | moina | 개선 | 7분 | 47 | $2.88 | 2.8M / 26K | success |
| 08:56 | moina | 릴리즈 | 3분 | 24 | $1.13 | 822K / 8K | success |
| 08:47 | moina | review | 2분 | 8 | $0.47 | 190K / 6K | success |
| 08:45 | moina | 개선 | 4분 | 32 | $1.65 | 1.5M / 14K | success |
| 04:20 | moina | 릴리즈 | 3분 | 25 | $1.24 | 1.1M / 8K | success |
| 02:08 | moina | review | 2분 | 19 | $0.98 | 780K / 8K | success |
| 02:05 | moina | 개선 | 5분 | 50 | $2.29 | 2.3M / 19K | success |
| 21:24 | moina | 릴리즈 | 3분 | 25 | $1.16 | 903K / 9K | success |
| 21:16 | moina | review | 3분 | 22 | $0.96 | 782K / 9K | success |
| 21:13 | moina | 개선 | 4분 | 43 | $2.04 | 1.9M / 17K | success |
| 09:49 | moina | 릴리즈 | 2분 | 16 | $1.02 | 585K / 9K | success |
| 02:35 | moina | 릴리즈 | 3분 | 24 | $1.31 | 1.0M / 12K | success |
| 02:26 | moina | review | 2분 | 10 | $0.46 | 164K / 7K | success |
| 02:24 | moina | 개선 | 4분 | 31 | $1.55 | 1.4M / 15K | success |
| 16:26 | moina | 릴리즈 | 3분 | 25 | $1.31 | 988K / 9K | success |
| 16:17 | moina | review | 2분 | 10 | $0.54 | 288K / 7K | success |
| 16:14 | moina | 개선 | 5분 | 36 | $1.53 | 1.2M / 17K | success |
아이디어 백로그 — 대기 12 / 전체 13
| 아이디어 | 가치/위험/크기 | 상태 | 메모 | 갱신 |
|---|---|---|---|---|
| 방문 추적 카드가 실린 일반 설정 화면을 다시 캡처해 가이드 3.8절에 그림을 싣기 | 3/1/M | 대기 | 이번 회차는 새 route 없이 카드로 넣어 catalog·캡처 136장을 그대로 두었다. `make image` + docker PostgreSQL + `npm --prefix e2e run capture:web` 전체 재캡처가 필요하며 admin-settings 캡처는 카드가 접힌(꺼짐) 상태로 찍힌다. | 2026-09-12 |
| deploy/docker-compose.offline.yml의 image 태그가 moina:v0.1.12로 고정돼 VERSION과 어긋남 | 3/1/S | 대기 | 배포 매니페스트의 버전 갱신은 릴리즈 세션의 일. 관리자 가이드 설치 절에 '태그를 반입한 버전으로 맞추라'고 적어 둠. | 2026-09-12 |
| 조용한 시간·Digest가 사용자별 시간대가 아닌 인스턴스 기본 시간대만 사용 | 3/3/L | 대기 | 사용자 설정·마이그레이션·UI가 모두 필요해 한 세션 범위를 넘음. | 2026-09-12 |
| updatePost가 DB 오류를 409 not_editable로 보고해 원인을 감춤 | 2/1/S | 대기 | posts.go의 UPDATE에서 err != nil과 RowsAffected()==0을 한 분기로 묶음. err는 500 storage_error로 분리해야 한다. 여전히 유효. | 2026-09-12 |
| Makefile test가 CI와 달리 -race 미사용 | 2/1/S | 대기 | make test는 go test ./...만 실행. 여전히 유효. | 2026-09-12 |
| 웹 앱이 EXIF로 회전된 사진의 자리 예약에 image-orientation을 명시하지 않음 | 2/1/S | 대기 | .moin-media img에 image-orientation: from-image 한 줄 + 주석으로 전제를 고정할 가치. 여전히 유효. | 2026-09-12 |
| Momento 프록시가 data-environment를 prd로 고정해 검증 인스턴스의 방문이 운영 통계에 섞임 | 2/1/S | 대기 | 표준 스니펫이 prd를 예시로 들어 그대로 두었다. 설정에 environment 한 필드를 더하면 된다. | 2026-09-12 |
| safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둠 | 2/2/S | 대기 | photo.png로 올린 JPEG이 filename=photo.png, mime_type=image/jpeg로 저장돼 다운로드 확장자가 내용과 어긋남. 여전히 유효. | 2026-09-12 |
| 업로드가 이미지 dimension만 읽어 동영상은 width·height가 항상 0 | 2/2/M | 대기 | MP4는 moov/tkhd에서 읽을 수 있으나 컨테이너 파싱 필요, WebM은 EBML. 여전히 유효. | 2026-09-12 |
| 방문 추적 차단 기록이 인스턴스 메모리에만 있어 다중 인스턴스에서는 신고를 받은 인스턴스에서만 보임 | 2/2/M | 대기 | 표준대로 메모리 고리 버퍼로 두었다. 다중 인스턴스 운영이 생기면 settings 알림처럼 pg_notify로 합치거나 짧은 TTL 테이블을 검토. | 2026-09-12 |
| 일간 Digest digestDue가 DST 전환일에 존재하지 않는 지역 시각을 time.Date 정규화에 맡김 | 2/3/M | 대기 | 기본 시간대 Asia/Seoul이라 국내 인스턴스는 영향 없음. 여전히 유효. | 2026-09-12 |
| avatar 조회가 프로필 응답과 별도 요청이라 Flow 한 화면에 작성자 수만큼 미디어 요청이 발생 | 2/3/M | 대기 | 아바타는 바꾸면 ID가 바뀌므로 별도 라우트로 immutable 캐시 검토 가치. 여전히 유효. | 2026-09-12 |
| 캠페인 tracking-2026-09: 관리자가 화면에서 방문 추적 스니펫을 붙이는 체계(CSP nonce·정책 출처·차단 기록·Momento 같은 오리진 프록시) | 4/2/L | 완료 | backend/internal/analytics + httpapi/analytics.go, 일반 설정의 방문 추적 카드, 관리자 가이드 3.8절·PDF·OpenAPI. 기본값 꺼짐, 'unsafe-inline' 없음, 비화면 경로는 default-src 'none'. | 2026-09-12 |
원장 (에이전트가 남긴 기록)
2026-09-02
- 선택: 반복된
X-Forwarded-Forheader 줄을 하나의 chain으로 이어 붙이기 (가치 4 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
backend/internal/httpapi/network.go의forwardedAddresses가Header.Get으로 첫 번째X-Forwarded-For줄만 읽어, Proxy가 자기 hop을 별도 header 줄로 덧붙이는 구성에서는 Client가 미리 보낸 줄이 chain 전체를 대신했고clientIP가 위조 값으로 계산되어 감사 기록과 공유 요청 한도 bucket이 오염되었습니다. 같은 함수의Forwarded·X-Forwarded-Proto분기가 이미 쓰던Header.Values로 바꿔 받은 순서대로 이어 붙이도록 고치고, 위조 줄 + Proxy 줄 조합과 신뢰 hop 투명성을 검증하는 테스트 2개를 추가했으며(수정 전 코드에서 실패하는 것을 확인)docs/configuration.md에 이 동작을 한 문장 명시했습니다. 검증은make fmt,make check,go test -race ./...,go vet ./..., staticcheck 전부 통과(frontend는 변경 없어 실행 생략). - 보류 아이디어: Email 알림에 조용한 시간 미적용 — 문서상 Toast·Desktop 전용이 의도라 변경 보류(가치 2 / 위험 3 / M) ·
formatRelativeTime이 연도가 다른 Moin을 월·일만 표시해 모호함(가치 2 / 위험 2 / S) ·frontend/src/utils/format.ts단위 테스트 부재(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) - 릴리즈: v0.1.16 (2026-09-02)
2026-09-02
- 선택: 연도가 다른 상대 시각에 연도 표시 +
utils/format단위 테스트 보강 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
frontend/src/utils/format.ts의formatRelativeTime이 7일이 지난 Moin을Intl.DateTimeFormat('ko-KR', {month,day})로만 표시해 작년 이전 Moin이 올해 Moin과 완전히 같은 “3월 5일” 형태로 보였습니다(Flow·MoinCard·알림 목록 모두 해당). 현재 연도와 다를 때만year: 'numeric'을 덧붙여 평소 목록 밀도는 유지하도록 고쳤고, 보류 목록에 있던format.ts테스트 공백도 함께 채워 상대 시각 경계·서버 시계 스큐·연도 규칙과formatDate·listFrom·topicLabel계약을 검증하는 테스트 11개를 추가했습니다. 검증은npm run lint(0 error),npm test(31 파일 201개 통과),npm run build,make fmt,make check, backendgo test -race ./...·go vet ./...전부 통과. - 보류 아이디어: Email 알림에 조용한 시간 미적용 — 문서상 Toast·Desktop 전용이 의도라 변경 보류(가치 2 / 위험 3 / M) · Makefile
test가 CI와 달리-race미사용(가치 2 / 위험 1 / S) ·pagination()이limit=abc같은 잘못된 query를 조용히 기본값으로 바꿔 400을 주지 않음(가치 2 / 위험 2 / S) ·MoinCard·알림 목록의 상대 시각에 정확한 전체 시각을 담은<time dateTime>·title부재(가치 2 / 위험 2 / M) - 릴리즈: v0.1.17 (2026-09-02)
2026-09-03
- 선택: 작성 뒤 본문이 바뀐 Moin에 “수정됨” 표시 + 상대 시각에 정확한 시각 노출 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약: API·프런트 타입 모두
updatedAt을 이미 실어 나르는데 어떤 화면도 표시하지 않아, 작성자가 공개 Moin을 다시 쓰면 이미 Echo·반응한 사람이 본문 변경을 알 수 없었습니다.utils/format.ts에moinEditedAt을 추가해updatedAt이createdAt보다 1초 이상 뒤일 때만 수정으로 보고, MoinCard에 정확한 수정 시각 tooltip이 붙은수정됨표시를 렌더링했으며 MoinCard·알림 목록의 상대 시각을<time dateTime title>으로 감싸 보류 목록에 있던 정확한 시각 부재도 함께 해결했습니다. 또한 승인 게시 SQL(workflow.go)이updated_at을 함께 옮겨 승인 정책을 켠 인스턴스에서는 모든 승인 Moin이 수정으로 보였을 문제를 고쳤고(상태 전이는published_at과 승인 요청이 이미 기록),api/openapi.yaml에updatedAt의미를 명시했습니다. 검증은 로컬 PostgreSQL 컨테이너에moina_ciDB를 만들어MOINA_TEST_POSTGRES_DSN으로 integration test 포함go test -race ./...전체 통과, 새 integration test가 수정 전 코드에서 실패하는 것 확인,make fmt·make check·go vet·staticcheck·npm run lint(0 error)·npm test(31파일 208개)·npm run build모두 통과. - 보류 아이디어: Email 알림에 조용한 시간 미적용 — 문서상 Toast·Desktop 전용이 의도라 변경 보류(가치 2 / 위험 3 / M) · Makefile
test가 CI와 달리-race미사용(가치 2 / 위험 1 / S) ·pagination()이limit=abc같은 잘못된 query를 조용히 기본값으로 바꿔 400을 주지 않고,offset이 100만을 넘으면 0으로 되돌려nextCursor추종 시 1페이지로 되돌아감(가치 2 / 위험 2 / S) · 조용한 시간·Digest가 사용자별 시간대가 아닌 인스턴스 기본 시간대만 사용(가치 3 / 위험 3 / L) - 릴리즈: v0.1.18 (2026-09-03)
2026-09-03
- 선택: Moin 본문의 http·https 주소를 링크로 표시 (가치 4 / 위험 2 / 작업량 M)
- 요약:
MoinContent가 멘션과 해시태그만 링크로 만들고 주소는 평문으로 남겨, 공유한 주소를 누르면 해당 사이트 대신 Moin 상세 화면이 열려 읽는 사람이 주소를 직접 복사해야 했습니다.richTokenPattern에 URL 분기를 맨 앞에 추가해 주소가 한 token으로 남도록 하고(경로 안의@·#가 멘션·토픽으로 떨어져 나가던 문제도 함께 해결), http·https만 인식해 매치된 문자열을 그대로 href로 쓸 수 있게 했으며rel="noopener noreferrer nofollow"와target="_blank"를 붙였습니다. 문자 집합을 RFC 3986 ASCII 집합으로 제한해 주소 뒤에 붙여 쓴 한글 조사가 주소로 빨려 들어가지 않게 하고, 문장 끝 구두점은 본문으로 되돌리되 주소가 연 괄호는 유지하는trimURLTail을 추가했습니다. 검증은 새 테스트 6개 포함npm test(31파일 214개),npm run lint(0 error),npm run build,make fmt,make check, backendgo vet ./...·go test ./...전부 통과. - 결과: 성공
- 보류 아이디어:
pagination()이limit=abc를 조용히 기본값으로 바꾸고offset이 100만을 넘으면 0으로 되돌려 1페이지로 순환(가치 3 / 위험 2 / S) ·updatePost가 DB 오류를 409not_editable(“본인의 공개 Moin만 수정할 수 있습니다”)로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 조용한 시간·Digest가 사용자별 시간대가 아닌 인스턴스 기본 시간대만 사용(가치 3 / 위험 3 / L) - 릴리즈: v0.1.19 (2026-09-03)
2026-09-03
- 선택: 문서 범위 밖
limit·offset을 400으로 거절하고 상한 넘는nextCursor미발급 (가치 3 / 위험 2 / 작업량 S) - 결과: 성공
- 요약:
api/openapi.yaml은limit1~100,offset0~1,000,000을 공표하는데pagination()은 범위 밖 값을 조용히 기본값으로 바꿔 200을 반환해,limit=abc·limit=0을 보낸 client가 자기 요청이 그대로 실행되지 않았음을 알 수 없었습니다. 더 나쁜 건 목록 collection이 다음offset을nextCursor로 발급하므로 긴 목록을 따라가다offset=1000030에 닿으면 0으로 접혀 1페이지가 다시 나왔고, cursor를 계속 따라가는 client는 끝에 닿지 못한 채 1페이지를 무한 반복했습니다.pagination(w, r)이decodeJSON과 같은 모양으로 400invalid_pagination을 직접 쓰도록 바꾸고(값이 없으면 기본값 유지, 숫자가 아닌cursor는 Flow의 opaque 값이라 그대로 통과),listEnvelope가 상한을 넘는 다음 page의 cursor를 발급하지 않게 했으며, 호출 14곳과 OpenAPI 응답·설명,docs/api-mcp.md를 함께 고쳤습니다. MCP는intArgument가 이미 1~100으로 제한해 영향이 없고 web app이 보내는 limit도 모두 범위 안입니다. 검증은 새 테스트 4개 포함 로컬 PostgreSQL에moina_ciDB를 만들어MOINA_TEST_POSTGRES_DSN으로 integration test 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 모두 통과(frontend 무변경으로 실행 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable(“본인의 공개 Moin만 수정할 수 있습니다”)로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · MCPintArgument가 범위 밖 limit을 조용히 30으로 대체해 REST와 계약이 어긋남(가치 2 / 위험 2 / S) · 조용한 시간·Digest가 사용자별 시간대가 아닌 인스턴스 기본 시간대만 사용(가치 3 / 위험 3 / L) - 릴리즈: v0.1.20 (2026-09-03)
2026-09-04
- 선택: MCP
tools/callarguments를 공표한inputSchema대로 검증 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약: 모든 MCP 도구가
tools/list로limit1~100,mode·visibilityenum,additionalProperties:false, 필수 인자를 공표하는데tools/call은 그 스키마를 전혀 검사하지 않아stringArgument·intArgument가 맞지 않는 값을 조용히 기본값으로 대체했습니다.limit:500, 문자열"50", 이름을 틀린count:100이 모두 30개로 실행돼, 눈으로 확인할 수 없는 agent client는 30개짜리 짧은 목록을 계정 전체로 읽었습니다. v0.1.20에서 REST가 범위 밖limit을 400invalid_pagination으로 거절하기 시작했는데 같은 호출이 MCP로 오면 여전히 조용히 성공해 계약이 어긋난 상태였습니다.validateMCPArguments를 추가해 선언에 없는 이름·타입 불일치·정수가 아니거나 범위 밖인 정수·enum 밖 문자열·빠뜨린 필수 인자를 MCP 명세대로 JSON-RPC-32602로 반환하고(stringArgument가 trim하므로 enum도 trim 후 비교),intArgument는 스키마와 두 번째 계약이 생기지 않도록 clamp를 없애고 기본값 공급만 담당하게 했으며docs/api-mcp.md에 규칙을 적었습니다. 검증은 새 테스트 15케이스 포함 로컬 PostgreSQL(moina_ci)에MOINA_TEST_POSTGRES_DSN을 걸어 integration test까지go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(frontend 무변경이라 ESLint 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable(“본인의 공개 Moin만 수정할 수 있습니다”)로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 조용한 시간·Digest가 사용자별 시간대가 아닌 인스턴스 기본 시간대만 사용(가치 3 / 위험 3 / L) · 일간 DigestdigestDue가 DST 전환일에 존재하지 않는 지역 시각을time.Date정규화에 맡겨 발송 시각이 한 시간 밀림(가치 2 / 위험 3 / M)
2026-09-05
- 선택: 공유한 주소 안의
#fragment·@handle을 Topic·멘션에서 제외 (가치 4 / 위험 2 / 작업량 S) - 결과: 성공
- 요약: v0.1.19에서 웹 앱은 Moin 본문의 http·https 주소를 하나의 링크 token으로 렌더링하기 시작했지만 서버의
extractHashtags·extractMentions는 여전히 원본 본문을 그대로 훑어,https://example.com/guide#install의 fragment가 Topic “install”이 되고https://example.com/@alice의 handle이 같은 아이디를 쓰는 이 인스턴스 회원에게 멘션 알림을 보냈습니다. 읽는 사람 화면에는 평범한 링크만 보이는데 Moin은 엉뚱한 Topic 피드에 들어가고 관계없는 회원은 자기가 멘션됐다는 알림을 받았습니다.MoinContent가 쓰는 RFC 3986 문자 집합 그대로의contentURLPattern으로 주소 구간을 공백 처리한 뒤 token을 추출하도록 고쳐 양쪽이 주소의 끝을 같은 규칙으로 판정하게 했고(문장 끝 구두점은 멘션·해시태그를 시작할 수 없어 되돌릴 필요가 없음을 주석에 명시), http·https만 주소로 보므로ftp://는 여전히 본문이며 20개 token 예산도 작성자가 실제로 쓴 태그에 쓰이게 됩니다.api/openapi.yaml의 Moin 작성 설명에 이 계약을 적었습니다. 검증은 새 단위 테스트 6케이스와 새 integration test 1개(둘 다 수정 전 코드에서 실패하는 것 확인) 포함 로컬 PostgreSQL(moina_ci)에MOINA_TEST_POSTGRES_DSN을 걸어go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable(“본인의 공개 Moin만 수정할 수 있습니다”)로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) ·detectMediaType이ftypbox만 보고 HEIC·AVIF·M4A까지video/mp4로 저장(가치 2 / 위험 2 / S) · 일간 DigestdigestDue가 DST 전환일에 존재하지 않는 지역 시각을time.Date정규화에 맡겨 발송 시각이 한 시간 밀림(가치 2 / 위험 3 / M) - 릴리즈: v0.1.21 (2026-09-05)
2026-09-06
- 선택:
ftyp시그니처만 보던 미디어 판정을 major brand까지 확인하도록 수정 (가치 3 / 위험 2 / 작업량 S) - 결과: 성공
- 요약:
detectMediaType이 4바이트 위치의ftyp만 보고video/mp4를 반환해, 같은 ISO Base Media 컨테이너를 쓰는 HEIC·AVIF 사진과 M4A 오디오, QuickTime·3GPP 동영상이 모두 MP4로 통과했습니다. 아이폰이 찍은 HEIC 사진을 올리면 415 대신 201이 나오고mediaType이video로 저장돼, 읽는 사람 화면에는 재생되지 않는 빈 동영상 첨부가 붙고Content-Type: video/mp4로 내려갔습니다. ftyp box의 major brand를 MP4 brand 목록(isom·mp42·M4V등)과 대조해 실제 MP4일 때만video/mp4로 보고, 그 밖의 ISO Base Media 파일은application/octet-stream으로 남겨 기존 allowlist가 415unsupported_media로 거절하게 했습니다. M4A처럼 호환 brand에mp42를 함께 적어 두는 형식은 Go의http.DetectContentType도video/mp4로 답하므로 major brand 판정 후 표준 감지로 되돌아가지 않도록 했고,api/openapi.yaml의 업로드 설명에 이 계약을 적었습니다. 검증은 새 테스트 7케이스(수정 전 코드에서 HEIC·AVIF·M4A·QuickTime·3GPP 전부 실패) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(DB를 건드리지 않는 순수 로직이라 integration test는 skip, frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable(“본인의 공개 Moin만 수정할 수 있습니다”)로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 미디어 응답의Content-Disposition이 non-ASCII 파일명을 RFC 6266filename*없이 그대로 넣어 브라우저마다 다운로드 이름이 깨짐(가치 2 / 위험 2 / S) · 일간 DigestdigestDue가 DST 전환일에 존재하지 않는 지역 시각을time.Date정규화에 맡겨 발송 시각이 한 시간 밀림(가치 2 / 위험 3 / M)
2026-09-07
- 선택: 미디어 응답
Content-Disposition에 RFC 6266filename*추가 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
getMedia가 저장된 파일 이름을fmt.Sprintf("inline; filename=%q", …)로 header에 그대로 넣어,사진.png같은 한글 이름이 charset 표시 없는 raw UTF-8 바이트로 나갔고 브라우저는 이를 대개 Latin-1로 읽어 “이미지 저장” 이름이 깨졌습니다(header 문법을 깨뜨릴 수 있는"도 조용히 지워 이름이 달라졌습니다). RFC 6266대로 ASCII fallback을filename에, 원래 이름을 RFC 8187 percent-encoding으로filename*=UTF-8''에 함께 내려보내는contentDisposition헬퍼를 추가하고(ASCII 이름은 기존과 완전히 동일한 header 유지),api/openapi.yaml의 media 조회 설명에 이 계약을 적었습니다. 검증은 새 테스트 6케이스(생성 값 비교 +mime.ParseMediaType으로 원래 이름 복원 확인) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(DB를 건드리지 않는 순수 로직이라 integration test는 skip, frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 일간 DigestdigestDue가 DST 전환일에 존재하지 않는 지역 시각을time.Date정규화에 맡겨 발송 시각이 한 시간 밀림(가치 2 / 위험 3 / M)
2026-09-08
- 선택: 미디어 응답을 1시간 캐시 대신 ETag 재검증으로 전환 (가치 3 / 위험 2 / 작업량 S)
- 결과: 성공
- 요약:
getMedia는 요청마다mediaAccessSQL로 차단 관계와 공개 범위를 검사하는데 응답에Cache-Control: private, max-age=3600을 붙여, 게시물을 비공개·팔로워 전용으로 돌리거나 상대를 차단한 뒤에도 이미 이미지를 받아 간 브라우저는 최대 한 시간 동안 캐시에서 그대로 볼 수 있었습니다(권한 검사 결과가 낡은 채 남음).private, no-cache로 바꿔 매 요청 서버 검사를 거치게 하고, 그 대신 저장할 때 계산해 둔 SHA-256을 강한ETag로 내려보내If-None-Match재검증이 본문 없이 304로 끝나게 했습니다(media ID의 본문은 절대 바뀌지 않으므로 강한 ETag가 안전하고, digest가 비었거나 hex가 아닌 예전 행은 ETag 없이 기존 Last-Modified 재검증만 씁니다).api/openapi.yaml의 media 조회 설명에 이 계약을 적었습니다. 검증은 새 단위 테스트 5케이스와 새 integration test 1개(200+ETag → 304 재검증 → 차단 후 같은 재검증이 404, 수정 전 코드에서 실패하는 것 확인) 포함 로컬 PostgreSQL(moina_ci)에MOINA_TEST_POSTGRES_DSN을 걸어go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · HEIC 업로드 415 거절 안내가 형식 목록뿐이라 아이폰 사용자가 원인을 알기 어려움(가치 3 / 위험 2 / M) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S)
2026-09-09
- 선택: HEIC 사진 거절에 아이폰 JPEG 전환 방법 안내 (가치 3 / 위험 1 / 작업량 M)
- 결과: 성공
- 요약: HEIC은 아이폰의 기본 촬영 형식인데
MoinComposer·ProfileAvatarEditor모두 지원 MIME 목록만 되풀이하며 거절해, 사용자는 갤러리에서 보이는 평범한 사진이 왜 안 되는지도 다시 시도할 방법도 알 수 없었습니다(v0.1.21 이후 서버도 415unsupported_media로 확실히 막습니다).utils/media.ts에isHEIC·unsupportedMediaMessage를 추가해 거절한 파일이 HEIC이면 “설정 > 카메라 > 포맷에서 ‘높은 호환성’” 촬영 설정과 이미 찍은 사진의 JPEG 내보내기를 안내하고, 섞여 들어온 경우에는 기존 형식 목록 안내와 함께 보여 줍니다. 브라우저가 HEIC MIME을 모르면File.type이 빈 문자열로 오므로 확장자(.heic·.heif)도 함께 보며, 이 때문에ProfileAvatarEditor.selectFiles가type.startsWith('image/')로 걸러 아무 안내 없이 버리던 경로도 함께 고쳤습니다. iOS Safari 사진 선택기가accept에 JPEG만 있을 때 HEIC을 자동 변환해 주므로MEDIA_ACCEPT·IMAGE_ACCEPT는 그대로 두었고,docs/user-guide.html의 첨부·프로필 이미지 설명에도 같은 안내를 적었습니다. 검증은 새 테스트 9개(유닛 8 + 드롭한IMG_0001.HEIC가 안내를 띄우고 업로드를 시도하지 않는 composer 테스트 1개, 수정 전 코드에서 실패하는 것 확인) 포함npm test(32파일 226개)·npm run lint(0 error)·npm run build·make fmt·make check·backendgo vet ./...·go test ./...전부 통과. -
보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 서버 415unsupported_media메시지가 HEIC을 구분하지 못해 API·MCP client에는 여전히 형식 목록만 전달됨(가치 2 / 위험 2 / S) - 릴리즈: v0.1.25 (2026-09-09, run 2026-09-09-041814-moina-release)
2026-09-09
- 선택: 서버 415
unsupported_media가 HEIC 사진에 JPEG 전환 방법을 안내 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약: 직전 회차에 웹 앱은 HEIC 거절에 아이폰 JPEG 전환 안내를 붙였지만 서버
uploadMedia는 여전히 지원 형식 목록만 돌려줘, API key·MCP·서드파티 client로 올린 사용자는 갤러리에서 평범해 보이는 사진이 왜 415인지 알 수 없었습니다.detectMediaType이 non-MP4 ISO Base Media를application/octet-stream하나로 뭉개 판정 결과를 전달할 수 없던 문제는 sniff한 앞부분을 그대로 보는unsupportedMediaMessage(sniff)를 따로 두어 해결했고(기존 판정 로직은 그대로), ftyp major brand가 HEIF 계열(heic·heix·hevc·mif1·msf1등)이면 웹 앱과 같은 문구로 촬영 설정 변경과 JPEG 내보내기를 안내하며 그 밖의 형식은 기존 형식 목록을 유지합니다.api/openapi.yaml·docs/api-mcp.md에 이 계약을 적었습니다. 검증은 새 테스트 9케이스(HEIF brand 4종은 안내, AVIF·M4A·PDF·짧은 파일·빈 파일은 형식 목록) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(DB를 건드리지 않는 순수 로직이라 integration test는 skip, frontend 무변경이라 ESLint·vitest 생략). -
보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 업로드가 이미지 dimension만 읽어 동영상은 항상width=0,height=0으로 저장돼 client가 재생 전 자리를 잡지 못함(가치 2 / 위험 2 / M) - 릴리즈: v0.1.26 (2026-09-09, run 2026-09-09-084149-moina-improve)
2026-09-10
- 선택: 업로드한 WebP 이미지의 실제 픽셀 크기를 저장·응답 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
imageDimensionsFrom이image.DecodeConfig만 쓰는데 표준 라이브러리에는 WebP 디코더가 없어, allowlist가 허용하고 웹 앱도 첨부를 받는image/webp만 언제나width=0,height=0으로 저장됐습니다(model.Media의omitempty때문에 media·Moin 응답에서 크기 필드 자체가 빠졌고, OpenAPI가mimeType예시로 쓰는 형식이 바로 WebP였습니다) — API client는 그림이 도착하기 전 자리를 잡을 수 없었습니다. 새 의존성 없이 이미 읽어 둔 sniff 앞부분에서 RIFF 컨테이너의 첫 chunk를 읽는webpDimensions를 추가해 VP8(lossy, key frame의 sync code 확인 후 14비트 크기), VP8L(서명 뒤 14비트 크기-1), VP8X(flags·reserved 뒤 24비트 canvas 크기-1, 알파·애니메이션) 세 종류를 모두 처리하고, WebP가 아니거나 헤더가 잘린 데이터는 기존처럼 표준 디코더 경로로 넘겨 0,0을 유지합니다. 검증은 libwebp가 만든 실제 WebP 3개(23x17 lossy·31x13 lossless·45x29 알파)로 크기를 대조하고 잘린 헤더·다른 RIFF·PNG 등 7케이스가 거절되는지 확인하는 새 테스트 3개(11케이스) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(이 환경에 PostgreSQL이 없어 integration test는 skip 상태, frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 웹 앱이 API가 주는 미디어width·height를 쓰지 않아 Flow에서 이미지 로드 중 레이아웃이 밀림(가치 3 / 위험 2 / M)
2026-09-10
- 선택: 웹 앱이 API의 미디어 width·height로 이미지 자리를 미리 예약 (가치 3 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약: 서버는
media_values가 만드는 Moin 응답에 이미width·height를 담아 주는데(직전 회차에 WebP까지 채워졌습니다)normalizeMoin이 두 값을 통째로 버려Moin.media타입에도 필드가 없었고,MoinCard의img는 크기를 모른 채 렌더링됐습니다..moin-media img는min-height:180px만 잡고 있어 그림이 도착하는 순간 카드 높이가 실제 비율로 늘어났고, Flow를 스크롤하며 읽는 동안 아래 카드들이 밀렸습니다.adapters.ts에dimension()을 두어 양수 정수인 크기만 남기고(서버가 크기를 못 읽은 동영상 등은 0을 주므로 생략),types.ts의 media 항목에width·height를 추가한 뒤 두 값이 모두 있을 때만img에 속성으로 넘겨 브라우저가 로드 전에 자리를 예약하게 했습니다. 속성으로 준 비율은 로드 전 placeholder로만 쓰이고 그림이 도착하면 실제 비율이 이기므로(EXIF로 회전된 사진도 지금과 같게 배치됨) 로드 후 화면은 변하지 않습니다. 검증은 새 테스트 3개(adapters의 크기 보존·0/소수 생략, MoinCard의 속성 전달·크기 없을 때 미부착 — 수정 전 코드에서 2개 파일 모두 실패하는 것 확인) 포함npm test(32파일 229개)·npm run lint(0 error)·npm run build·make fmt·make check·backendgo vet ./...·go test ./...전부 통과. -
보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 업로드가 EXIF orientation을 무시해 회전된 JPEG의 저장 크기가 화면 표시와 뒤바뀜(가치 2 / 위험 2 / M) - 릴리즈: v0.1.27 (2026-09-10, run 2026-09-10-135116-moina-improve)
2026-09-10
- 선택: 업로드한 JPEG의 EXIF orientation을 반영해 표시 크기를 저장·응답 (가치 3 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약: 세로로 든 휴대전화는 사진을 센서 방향 그대로(가로) 저장하고 EXIF orientation만 남기는 경우가 많은데
imageDimensionsFrom이image.DecodeConfig의 저장 픽셀 크기를 그대로 media에 담아, orientation 5~8인 사진은width·height가 브라우저가 실제로 그리는 크기와 반대였습니다(브라우저 기본값image-orientation: from-image가 돌려 그립니다). 직전 회차에 웹 앱이 이 값을img속성으로 넘겨 로드 전 자리를 예약하기 시작해, 세로 사진은 가로 자리를 예약했다가 그림이 도착하는 순간 오히려 배치가 밀렸습니다 — 레이아웃 밀림을 막으려던 변경이 회전된 사진에서만 반대로 작동한 셈입니다. 새 의존성 없이 이미 sniff한 앞부분에서 JPEG marker를 훑어 APP1 Exif(Exif\0\0)를 찾고 TIFF 블록의 IFD0 tag 0x0112(SHORT 1개, II·MM 양쪽 byte order)를 읽어 5~8이면width·height를 뒤바꿔 표시 크기를 보고합니다. IFD0은 TIFF 헤더 바로 뒤라 sniff 범위로 충분하고, JPEG이 아니거나 헤더가 잘렸거나 orientation이 1~8 밖이면 크기를 그대로 둬 멀쩡한 첨부가 뒤집히지 않게 했으며(WebP·PNG 경로는 그대로),api/openapi.yaml의width설명에 “화면에 그려지는 크기”라는 계약을 적었습니다. 검증은 새 테스트 2개(13케이스: orientation 1·3은 그대로, 5·6·7·8과 big-endian TIFF는 뒤바뀜, 비-JPEG·Exif 없음·길이 잘림·byte order 깨짐·범위 밖 orientation은 거절 — 수정 전 코드에서 실패하는 것 확인) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과(이 환경에 PostgreSQL이 없어 integration test는 skip 상태, frontend 무변경이라 ESLint·vitest 생략). - 보류 아이디어:
updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S) · 업로드가 이미지 dimension만 읽어 MP4·WebM 동영상은 항상width=0,height=0(가치 2 / 위험 2 / M)
2026-09-11
- 선택: 화면 캡처가 실린 사용자·관리자 가이드 정본(MD+PDF) 완성 (가치 4 / 위험 2 / 작업량 L)
- 결과: 성공
- 요약: GUIDE-STANDARD.md대로
docs/USER_GUIDE.md·USER_GUIDE.pdf와docs/ADMIN_GUIDE.md·ADMIN_GUIDE.pdf네 개를 만들었습니다. 그림은 지어내지 않고make image로 빌드한moina:v0.1.27을 docker의 전용 PostgreSQL과 함께 127.0.0.1:18080에 띄운 뒤 저장소 캡처 도구(npm --prefix e2e run capture:web)로 136장을 새로 찍어docs/assets/screenshots를 갱신하고 그중 24장을 두 문서에 실었으며(계정은 capture-admin·”MOINA 연구소” 같은 캡처 전용 가짜 데이터), 환경 변수 표는config.go, 역할·권한 표는001_initial.sql·004_feed_snapshots.sql, 상태 점검 API의 메서드는server.go의 라우트 등록 자리에서 읽어 만들었습니다. 이미 있던docs/user-guide.html·admin-guide.html은 웹 사본으로 남기되 맨 위에 정본을 가리키는 안내를 넣고 README·llms.txt링크를 정본으로 옮겨 정본이 하나가 되게 했습니다. 검증은make check(런타임 계약·route·브랜드·Pages QA) 통과,make pages-test(3개 문서 × 2 viewport 브라우저 QA) 통과, PDF 25쪽씩 렌더링해 표지·표·코드 블록·그림이 깨지지 않은 것을 눈으로 확인했습니다. - 보류 아이디어:
deploy/docker-compose.offline.yml의image:가moina:v0.1.12로 고정돼 VERSION(v0.1.27)과 어긋남 — 릴리즈 세션의 일이라 이번에는 가이드에 “태그를 맞추라”고 적는 것으로 대신함(가치 3 / 위험 1 / S) ·updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) ·safeFilename이 확장자와 판정한 MIME의 불일치를 그대로 둬 JPEG이photo.png로 저장·다운로드됨(가치 2 / 위험 2 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S)
2026-09-12
- 선택: 캠페인 tracking-2026-09 — 관리자가 화면에서 방문 추적 스니펫을 붙이는 체계(CSP nonce·정책 출처·차단 기록·Momento 프록시) (가치 4 / 위험 2 / 작업량 L)
- 결과: 성공
- 요약: TRACKING-STANDARD.md대로
backend/internal/analytics(설정·스니펫 렌더·정책 출처 추출·차단 기록 고리 버퍼)와httpapi/analytics.go(관리 API, CSP 신고 수신, 같은 오리진 Momento 프록시)를 새로 만들고,securityHeaders가 화면 요청에만 요청별 nonce를 만들어script-src 'nonce-…'와 스니펫에 함께 넣도록 했으며serveSPA가 추적이 켜진 문서에만 스니펫을 끼워 넣습니다('unsafe-inline'없음, 끄면 원래 정책·Last-Modified 재검증 그대로, 비화면 경로는default-src 'none'으로 더 좁힘). provider는 Momento를 첫 자리에 두고/momento/*프록시를 기본으로 삼아 정책에 외부 출처가 등장하지 않게 했고(OIDC·AI·SMTP와 같은 exact-authority 아웃바운드 정책, cookie·Authorization 미전달), 추적이 켜진 동안만report-uri로 받은 차단 출처·지시어를 메모리(100개)에 기억해 일반 설정 → 방문 추적 카드에서 한 번 눌러 허용 목록에 넣게 했습니다. 기본값은 꺼짐이라 새 설치는 달라지지 않습니다. 관리자 가이드 3.8절·보안 절과 PDF(공용 md2pdf로 27쪽 재생성), OpenAPI(route 계약 118개 통과), security.md를 갱신했습니다. 검증은 Go 새 테스트 15개(analytics 11 + httpapi 10케이스 — 꺼짐 무변경, nonce 일치·요청별 상이, body 배치·직접 연결 출처, 비화면·admin 제외,Origin: null신고 수신, 프록시 404/전달/loopback 거절) 포함go test -race ./...전체 통과,make fmt·make check·go vet·staticcheck 통과, 프런트 새 테스트 5개 포함vitest33파일 234개·ESLint(경고 40 = 기존 상한, 0 error)·npm run build통과. 새 관리 route를 만들지 않고 기존 일반 설정 페이지에 카드로 넣어 화면 catalog·실제 캡처 136장은 그대로이며, 가이드 3.8절에는 이번에 찍은 그림이 없습니다(표·절차로 대신). 표준의 “실제로 띄워 수집이 들어오는 것을 확인”은 이 환경에 Momento 수집기가 없어 httptest 수집기로 프록시 전달만 확인했습니다. - 보류 아이디어: 방문 추적 카드가 실린 일반 설정 화면을 다시 캡처해 가이드 3.8절에 그림을 싣기(가치 3 / 위험 1 / M) ·
deploy/docker-compose.offline.yml의image:가moina:v0.1.12로 고정돼 VERSION과 어긋남 — 릴리즈 세션의 일(가치 3 / 위험 1 / S) ·updatePost가 DB 오류를 409not_editable로 보고해 원인을 감춤(가치 2 / 위험 1 / S) · Makefiletest가 CI와 달리-race미사용(가치 2 / 위험 1 / S)