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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,14 @@ a required BuildKit secret, never through build arguments or committed files.

## Catalog and release

The next app release is **0.1.15**, also recorded in `web/package.json`.
The next app release is **0.1.16**, also recorded in `web/package.json`.
`catalog/entry.yaml` carries no committed version: the first tag (`v0.1.0`)
created `versions[]` with `ghcr.io/appthrust/tracker@sha256:<index-digest>`,
stream `0.1`; later tags append at publication time. Do not add a version with
an unbuilt or placeholder image digest. OpenAPI's `info.version` describes the
API schema, not the app release. This repository has no hand-maintained changelog.

After the release prerequisites are available, push the SemVer tag `v0.1.15`.
After the release prerequisites are available, push the SemVer tag `v0.1.16`.
The tag workflow:

1. Performs one multi-platform build on `ubuntu-24.04` for Linux amd64 and arm64,
Expand All @@ -205,8 +205,8 @@ The tag workflow:
Publication commands used by the workflow:

```sh
bun hack/publish-entry.ts submit --version 0.1.15 --digest sha256:<image-index-digest>
bun hack/publish-entry.ts await --version 0.1.15 --digest sha256:<image-index-digest>
bun hack/publish-entry.ts submit --version 0.1.16 --digest sha256:<image-index-digest>
bun hack/publish-entry.ts await --version 0.1.16 --digest sha256:<image-index-digest>
```

Writers are serialized by `tracker-catalog-publish`; the workflow is guarded to
Expand All @@ -227,7 +227,7 @@ After publication, each estate's `AppCatalogSource` syncs the signed catalog.
Install Issues from the Project Marketplace (Directory is an automatic
dependency). Existing `follow` installations on stream `0.1` roll to the new
version; `pinned` installations show `UpdateAvailable` and require an explicit
version update. Confirm the installation is Ready, `/releasez` reports `0.1.15`,
version update. Confirm the installation is Ready, `/releasez` reports `0.1.16`,
the Project lists the `tracker` Access Key space as Ready, and an exchanged key reads `/api/tracker/v1/me`. These are release steps,
not actions performed by a source-only version bump.

Expand Down
24 changes: 24 additions & 0 deletions cmd/server/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,30 @@ func TestAttachmentRoundTrip(t *testing.T) {
call[any](h, "viewer", "DELETE", "/attachments/"+attachment.ID, nil, 204)
call[any](h, "viewer", "GET", "/attachments/"+attachment.ID, nil, 404)
}
func TestAttachmentSizeBoundary(t *testing.T) {
h := newHarness(t)
issue := call[domain.Issue](h, "viewer", "POST", "/issues", map[string]string{"title": "Screenshot", "type": "task"}, 201)
upload := func(size int) int {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreatePart(textproto.MIMEHeader{"Content-Disposition": {`form-data; name="file"; filename="shot.png"`}, "Content-Type": {"image/png"}})
require.NoError(t, err)
_, err = part.Write(make([]byte, size))
require.NoError(t, err)
require.NoError(t, writer.Close())
r, err := http.NewRequest("POST", h.server.URL+"/api/tracker/v1/issues/"+issue.ID+"/attachments", &body)
require.NoError(t, err)
r.Header.Set("Authorization", "Bearer "+token("viewer"))
r.Header.Set("Content-Type", writer.FormDataContentType())
response, err := h.server.Client().Do(r)
require.NoError(t, err)
defer response.Body.Close()
return response.StatusCode
}
// The stored row's CHECK and the handler agree on the bound.
require.Equal(t, 201, upload(domain.AttachmentMaxBytes))
require.Equal(t, 400, upload(domain.AttachmentMaxBytes+1))
}
func TestOpenAPIRouteInventory(t *testing.T) {
var document struct {
OpenAPI string `json:"openapi"`
Expand Down
8 changes: 4 additions & 4 deletions cmd/server/internal/api/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func (a *API) commentDelete(s *request) (any, int, error) {
return nil, 204, s.tx.DeleteComment(s.r.PathValue("id"), s.actor)
}
func (a *API) attachmentCreate(s *request) (any, int, error) {
s.r.Body = http.MaxBytesReader(s.w, s.r.Body, (2<<20)+(64<<10))
s.r.Body = http.MaxBytesReader(s.w, s.r.Body, domain.AttachmentMaxBytes+(64<<10))
reader, err := s.r.MultipartReader()
if err != nil {
return nil, 0, invalid("Expected a multipart file")
Expand All @@ -135,12 +135,12 @@ func (a *API) attachmentCreate(s *request) (any, int, error) {
if err != nil {
return nil, 0, invalid("Invalid attachment media type")
}
data, err := io.ReadAll(io.LimitReader(part, (2<<20)+1))
data, err := io.ReadAll(io.LimitReader(part, domain.AttachmentMaxBytes+1))
if err != nil {
return nil, 0, invalid("Could not read attachment")
}
if len(data) > 2<<20 {
return nil, 0, invalid("Attachment must contain 1 byte to 2 MiB")
if len(data) > domain.AttachmentMaxBytes {
return nil, 0, invalid("Attachment must contain 1 byte to 10 MiB")
}
if _, err := reader.NextPart(); err != io.EOF {
return nil, 0, invalid("Upload exactly one file")
Expand Down
4 changes: 2 additions & 2 deletions cmd/server/internal/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Tracker API",
"version": "0.1.15",
"version": "0.1.16",
"description": "Contract: design/API.md. One workspace per PLATFORM_PROJECT_ID. The bearer is the signed-in user's token or an Access Key assertion (audience tracker; scope tracker:read for GET, tracker:write otherwise). External-subject assertions resolve by immutable registered agent handle. GET scopes tracker:read; other methods tracker:write; missing scope is 403 insufficient_scope. Every key is refused on Admin routes. See /agent-guide for the collaboration protocol."
},
"servers": [
Expand Down Expand Up @@ -2606,7 +2606,7 @@
"post": {
"operationId": "createAttachment",
"summary": "createAttachment",
"description": "Authorization: any authenticated user. One multipart file, at most 2 MiB and 10 attachments per issue. Allowed: image/png, image/jpeg, image/webp, text/plain, application/json. Multipart upload is excluded from Idempotency-Key replay.",
"description": "Authorization: any authenticated user. One multipart file, at most 10 MiB and 10 attachments per issue. Allowed: image/png, image/jpeg, image/webp, text/plain, application/json. Multipart upload is excluded from Idempotency-Key replay.",
"responses": {
"201": {
"description": "Success",
Expand Down
9 changes: 7 additions & 2 deletions cmd/server/internal/domain/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,20 @@ func ValidateView(view View) error {
}
return ValidateFilter(view.Filter)
}

// AttachmentMaxBytes bounds one attachment; the store's CHECK (migration 015)
// and the SPA's ATTACHMENT_MAX_BYTES carry the same value.
const AttachmentMaxBytes = 10 << 20

func ValidateAttachment(filename, mediaType string, size int) error {
if !Text(filename, 1, 200) || strings.ContainsAny(filename, "\r\n\x00") {
return errors.New("Invalid attachment filename")
}
if !slices.Contains([]string{"image/png", "image/jpeg", "image/webp", "text/plain", "application/json"}, mediaType) {
return errors.New("Unsupported attachment media type")
}
if size < 1 || size > 2<<20 {
return errors.New("Attachment must contain 1 byte to 2 MiB")
if size < 1 || size > AttachmentMaxBytes {
return errors.New("Attachment must contain 1 byte to 10 MiB")
}
return nil
}
2 changes: 1 addition & 1 deletion cmd/server/internal/domain/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestIssueAndAttachmentLimits(t *testing.T) {
media string
size int
valid bool
}{{"image/png", 2 << 20, true}, {"text/plain", 1, true}, {"application/json", 0, false}, {"image/png", (2 << 20) + 1, false}, {"text/html", 1, false}} {
}{{"image/png", AttachmentMaxBytes, true}, {"text/plain", 1, true}, {"application/json", 0, false}, {"image/png", AttachmentMaxBytes + 1, false}, {"text/html", 1, false}} {
require.Equal(t, tc.valid, ValidateAttachment("file", tc.media, tc.size) == nil)
}
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/server/internal/store/migrations/015_attachment_size.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Full-resolution screenshots did not fit in 2 MiB; domain.AttachmentMaxBytes is the same bound.
ALTER TABLE tracker_attachment DROP CONSTRAINT tracker_attachment_size_check,
ADD CONSTRAINT tracker_attachment_size_check CHECK (size BETWEEN 1 AND 10485760);
2 changes: 1 addition & 1 deletion cmd/server/internal/store/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func TestProjectsMigrationFromV010(t *testing.T) {
}
var versions int
require.NoError(t, tx.SQL.QueryRowContext(t.Context(), `SELECT count(*) FROM tracker_schema_migrations`).Scan(&versions))
require.Equal(t, 14, versions)
require.Equal(t, 15, versions)
var reporterIndex string
require.NoError(t, tx.SQL.QueryRowContext(t.Context(), `SELECT indexdef FROM pg_indexes WHERE schemaname=current_schema() AND indexname='tracker_issue_reporter'`).Scan(&reporterIndex))
require.Contains(t, reporterIndex, "USING btree (workspace_id, reporter_sub) WHERE (deleted_at IS NULL)")
Expand Down
2 changes: 1 addition & 1 deletion design/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Admin. `PATCH /views/{id}` accepts `{ name?, layout?, filter?, sort?, shared?, p
| `POST /issues/{id}/comments` | `{ body }` → `Comment` 201 | any |
| `PATCH /comments/{id}` | `{ body }` → `Comment` | author |
| `DELETE /comments/{id}` | 204 | author or Admin |
| `POST /issues/{id}/attachments` | multipart `file` → `Attachment` 201; ≤ 2 MiB, ≤ 10/issue, allowed media types only | any |
| `POST /issues/{id}/attachments` | multipart `file` → `Attachment` 201; ≤ 10 MiB (2 MiB before v0.1.16), ≤ 10/issue, allowed media types only | any |
| `GET /attachments/{id}` | bytes, `Content-Type`, `Content-Disposition: inline; filename=…`, `Cache-Control: private, max-age=31536000, immutable` | any |
| `DELETE /attachments/{id}` | 204 | uploader or Admin |
| `GET /views` | → `{ items: View[] }` = shared ∪ mine, by position | any |
Expand Down
7 changes: 4 additions & 3 deletions design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ erDiagram
| **Issue** | any signed-in user | `number` from a per-workspace sequence; soft-deleted by Admin only | `title` 1–200 chars; `description` ≤ 50 KiB Markdown; `type ∈ {epic, story, bug, feature, task}`; an epic has no parent, a story's parent is an epic, a work item's parent is any issue (`domain.ValidateHierarchy`); `priority ∈ {urgent, high, medium, low, none}`; `parent` must not create a cycle; `blocks` is symmetric-stored as `blocked_by`. `report` is present iff `type = bug`. |
| **Report** (embedded in Issue, `jsonb`) | reporter | written on create, editable | `environment` (free label, suggestions: `appthrust.ink`, `craftsman`, `appthrust.dev`, `local`), `surface ∈ {dashboard, platform-api, apth, builder-console, console-app, cluster, other}`, `version` (what the reporter saw on `/releasez`, optional), `steps`, `expected`, `actual` (each ≤ 4 KiB). |
| **Comment** | author; Admin may delete | append | ≤ 20 KiB Markdown; edits keep `edited_at`. |
| **Attachment** | uploader | immutable | `bytea` ≤ 2 MiB, ≤ 10 per issue, `image/png|image/jpeg|image/webp|text/plain|application/json`; served only to signed-in users of the same workspace. |
| **Attachment** | uploader | immutable | `bytea` ≤ 10 MiB, ≤ 10 per issue, `image/png|image/jpeg|image/webp|text/plain|application/json`; served only to signed-in users of the same workspace. |
| **Activity** | server | append-only | one row per changed field (`field, from, to, actor, at`); comments appear in the same timeline by `created_at`. |
| **View** | its creator; shared flag | CRUD | `filter` JSON validated against the filter schema; ≤ 50 per workspace. |

Expand Down Expand Up @@ -252,15 +252,16 @@ v0.1.0, as the other apps do).
- **Teams/Projects/Cycles in v1** — every extra container multiplies filter,
permission and UI surface; parent/child plus labels covers triage needs.
- **Object storage for attachments** — no storage component in the catalog
today; `bytea` with a 2 MiB cap is sufficient for screenshots.
today; `bytea` with a 10 MiB cap holds full-resolution screenshots (an issue
is at most 100 MiB). Each upload and download is held in memory once.
- **Custom fields instead of a fixed `report` shape** — generic schema engine
for one use case; the fixed shape keeps filters and the form simple.

## Open questions

1. External (anonymous) bug intake: requires a public route outside the OIDC
edge and spam controls; deferred until asked.
2. Attachment limits (2 MiB / 10 per issue) — adjust after first weeks of use.
2. Attachment limits (10 MiB / 10 per issue; 2 MiB until v0.1.16, raised because full-page and high-density screenshots did not fit).
3. Name: slug `tracker`, display "Issues" (ja: 「課題」). Alternative
`bugtracker` / 「不具合トラッカー」 if the tool stays defect-only.
4. Whether to add the `tracker` icon to the Dashboard icon set (platform PR)
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@appthrust/tracker",
"version": "0.1.15",
"version": "0.1.16",
"private": true,
"type": "module",
"scripts": {
Expand Down
10 changes: 8 additions & 2 deletions web/src/copy/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const issueMessages = defineDashboardMessages({
'tracker.view.deleteBody': { defaultMessage: 'Issues are not affected.', translations: { ja: '課題には影響しません。' } },

'tracker.newIssue.create': { defaultMessage: 'Create issue', translations: { ja: '作成' } },
'tracker.newIssue.fullReport': { defaultMessage: 'For steps, expected result and screenshots, use', translations: { ja: '再現手順やスクリーンショットも残すなら' } },
'tracker.newIssue.fullReport': { defaultMessage: 'For steps and the expected result, use', translations: { ja: '再現手順や期待結果も残すなら' } },

'tracker.issue.title': { defaultMessage: 'Title', translations: { ja: 'タイトル' } },
'tracker.issue.description': { defaultMessage: 'Description', translations: { ja: '説明' } },
Expand Down Expand Up @@ -155,9 +155,15 @@ export const issueMessages = defineDashboardMessages({
'tracker.attachment.delete': { defaultMessage: 'Remove {name}', translations: { ja: '{name} を削除' } },
'tracker.attachment.failed': { defaultMessage: "Couldn't attach the file", translations: { ja: 'ファイルを添付できませんでした' } },
'tracker.attachment.uploading': { defaultMessage: 'Uploading…', translations: { ja: 'アップロード中…' } },
'tracker.attachment.uploadingProgress': { defaultMessage: 'Uploading {current} of {total}…', translations: { ja: 'アップロード中 {current}/{total}…' } },
'tracker.attachment.limits': {
defaultMessage: 'Paste or drop files here. {types}, up to {size} MiB each, {max} files at most.',
translations: { ja: '貼り付けやドロップもできます。{types}、1 ファイル {size} MiB まで、{max} 件まで。' },
},
'tracker.attachment.uploadFailedTitle': { defaultMessage: '{identifier} was created, but some files were not attached', translations: { ja: '{identifier} を作成しましたが、一部のファイルを添付できませんでした' } },
'tracker.attachment.tooMany': { defaultMessage: 'An issue holds at most {max} files.', translations: { ja: '1 つの課題に添付できるのは {max} 件までです。' } },
'tracker.attachment.badType': { defaultMessage: '{name}: only PNG, JPEG, WebP, text and JSON files.', translations: { ja: '{name}: PNG・JPEG・WebP・テキスト・JSON のみ添付できます。' } },
'tracker.attachment.tooLarge': { defaultMessage: '{name}: files must be at most 2 MiB.', translations: { ja: '{name}: 2 MiB 以下のファイルにしてください。' } },
'tracker.attachment.tooLarge': { defaultMessage: '{name}: files must be at most 10 MiB.', translations: { ja: '{name}: 10 MiB 以下のファイルにしてください。' } },

'tracker.comment.label': { defaultMessage: 'Comment', translations: { ja: 'コメント' } },
'tracker.comment.placeholder': { defaultMessage: 'Leave a comment… (paste screenshots)', translations: { ja: 'コメントを書く…(スクリーンショットを貼り付けできます)' } },
Expand Down
2 changes: 1 addition & 1 deletion web/src/dev/mock-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ describe('tracker mock: comments, attachments, timeline', () => {
gif.append('file', new File(['GIF89a'], 'a.gif', { type: 'image/gif' }))
expect((await call('POST', `/issues/${issue.id}/attachments`, gif)).status).toBe(400)
const big = new FormData()
big.append('file', new File([new Uint8Array(2 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' }))
big.append('file', new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' }))
expect((await call('POST', `/issues/${issue.id}/attachments`, big)).status).toBe(400)

await as('dev-sam')
Expand Down
2 changes: 1 addition & 1 deletion web/src/dev/mock-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1545,7 +1545,7 @@ async function attach(issue: StoredIssue, request: Request): Promise<Response> {
if (existing >= ATTACHMENTS_PER_ISSUE) return invalid('file', `at most ${ATTACHMENTS_PER_ISSUE} attachments per issue`)
const mediaType = file.type.split(';')[0]!.trim()
if (!ATTACHMENT_TYPES.includes(mediaType)) return invalid('file', `media type must be one of ${ATTACHMENT_TYPES.join(', ')}`)
if (file.size === 0 || file.size > ATTACHMENT_MAX_BYTES) return invalid('file', 'file must be 1 byte to 2 MiB')
if (file.size === 0 || file.size > ATTACHMENT_MAX_BYTES) return invalid('file', 'file must be 1 byte to 10 MiB')
const id = nextId()
const filename = (file instanceof File && file.name ? file.name : 'attachment').slice(0, 200)
const stored: StoredAttachment = {
Expand Down
6 changes: 3 additions & 3 deletions web/src/features/issues/attachments.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Attachments: pasted or dropped screenshots and small text/JSON files
// (API.md: ≤ 2 MiB, ≤ 10 per issue, png/jpeg/webp/text/json). Images preview
// (API.md: ≤ 10 MiB, ≤ 10 per issue, png/jpeg/webp/text/json). Images preview
// inline; every file opens from its same-origin URL.
import { FileText, ImagePlus, Trash2 } from 'lucide-react'
import { useRef, type ClipboardEvent, type DragEvent } from 'react'
import { useAttach, useDeleteAttachment } from '../../api/queries'
import type { Attachment } from '../../api/tracker'
import { useText, type Translate } from '../../i18n'
import { ATTACHMENT_TYPES, ATTACHMENTS_PER_ISSUE, attachmentProblem, isImage } from '../../model/issues'
import { ATTACHMENT_TYPES, ATTACHMENTS_PER_ISSUE, attachmentProblem, isImage, type AttachmentProblem } from '../../model/issues'
import { Button, errorDetail, errorMessageId, showToast } from '../../ui'
import { useReference } from './use-reference'

Expand All @@ -26,7 +26,7 @@ export function transferFiles(transfer: DataTransfer | null): File[] {
return [...transfer.items].filter((item) => item.kind === 'file').map((item) => item.getAsFile()).filter((file): file is File => file !== null).map((file) => namedFile(file))
}

export function problemText(t: Translate, problem: 'tooLarge' | 'type' | 'tooMany', file: { name: string }) {
export function problemText(t: Translate, problem: AttachmentProblem, file: { name: string }) {
if (problem === 'tooMany') return t('tracker.attachment.tooMany', { max: ATTACHMENTS_PER_ISSUE })
if (problem === 'type') return t('tracker.attachment.badType', { name: file.name })
return t('tracker.attachment.tooLarge', { name: file.name })
Expand Down
Loading