Skip to content
Open
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
18 changes: 14 additions & 4 deletions internal/base/server/http_funcmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ var funcMap = template.FuncMap{
return trans
},
"timeFormatISO": func(tz string, timestamp int64) string {
_, _ = time.LoadLocation(tz)
return time.Unix(timestamp, 0).Format("2006-01-02T15:04:05.000Z")
return day.FormatISO(timestamp, tz)
},
"translatorTimeFormatLongDate": func(la i18n.Language, tz string, timestamp int64) string {
trans := translator.GlobalTrans.Tr(la, "ui.dates.long_date_with_time")
Expand All @@ -91,7 +90,6 @@ var funcMap = template.FuncMap{
between int64 = 0
trans string
)
_, _ = time.LoadLocation(tz)
if now > timestamp {
between = now - timestamp
}
Expand Down Expand Up @@ -119,7 +117,7 @@ var funcMap = template.FuncMap{

if between >= 3600*24 &&
between < 3600*24*366 &&
time.Unix(timestamp, 0).Format("2006") == time.Unix(now, 0).Format("2006") {
sameYearInZone(timestamp, now, tz) {
trans = translator.GlobalTrans.Tr(la, "ui.dates.long_date")
return day.Format(timestamp, trans, tz)
}
Expand All @@ -137,6 +135,18 @@ var funcMap = template.FuncMap{
"urlTitle": htmltext.UrlTitle,
}

// sameYearInZone reports whether two instants fall into the same calendar
// year when rendered in the given IANA zone. The relative/absolute date
// rendering uses this to decide whether the year may be omitted, and the
// decision must follow the zone the value is rendered in, not the server's
// zone: two instants sharing a year in Tokyo can sit on opposite sides of
// New Year in Los Angeles.
func sameYearInZone(a, b int64, tz string) bool {
yearA := time.Unix(a, 0).In(day.Location(tz)).Format("2006")
yearB := time.Unix(b, 0).In(day.Location(tz)).Format("2006")
return yearA == yearB
}

func FormatLinkNofollow(html string) string {
var hrefRegexp = regexp.MustCompile("(?m)<a.*?[^<]>.*?</a>")
match := hrefRegexp.FindAllString(html, -1)
Expand Down
80 changes: 80 additions & 0 deletions internal/base/server/http_funcmap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/

package server

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

// timeFormatISO feeds every server-rendered <time datetime="..."> attribute:
// it must render the instant in the requested zone with a real ISO-8601 zone
// marker ("Z" only for UTC), not the server's local wall clock labeled as Z.
func TestTimeFormatISO(t *testing.T) {
// 2023-11-14 22:13:20 UTC
const ts = int64(1700000000)
formatISO := funcMap["timeFormatISO"].(func(string, int64) string)

tests := []struct {
name string
tz string
expected string
}{
{"utc_renders_z", "UTC", "2023-11-14T22:13:20.000Z"},
{"tokyo_offset", "Asia/Tokyo", "2023-11-15T07:13:20.000+09:00"},
{"new_york_offset", "America/New_York", "2023-11-14T17:13:20.000-05:00"},
{"kolkata_half_hour", "Asia/Kolkata", "2023-11-15T03:43:20.000+05:30"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, formatISO(tt.tz, ts))
})
}
}

// An empty or unknown zone must fall back to the server's local zone rendered
// with a real marker, matching the semantics day.Format established.
func TestTimeFormatISOFallsBackToLocalForUnknownZone(t *testing.T) {
const ts = int64(1700000000)
formatISO := funcMap["timeFormatISO"].(func(string, int64) string)
expected := time.Unix(ts, 0).In(time.Local).Format("2006-01-02T15:04:05.000Z07:00")

for _, tz := range []string{"", "Not/AZone", "UTC+99"} {
t.Run("tz="+tz, func(t *testing.T) {
assert.Equal(t, expected, formatISO(tz, ts))
})
}
}

// The long-date/short-date choice depends on the calendar year of the
// rendered zone: two instants that share a year in Tokyo can sit on opposite
// sides of New Year in Los Angeles.
func TestSameYearInZone(t *testing.T) {
// 2024-01-01 08:00 JST == 2023-12-31 15:00 PST
const jstNewYear = int64(1704067200)
// 2024-06-01 08:00 JST == 2024-05-31 23:00 UTC
const midYear = int64(1717200000)

assert.True(t, sameYearInZone(midYear, jstNewYear, "Asia/Tokyo"))
assert.False(t, sameYearInZone(midYear, jstNewYear, "America/Los_Angeles"))
}
10 changes: 7 additions & 3 deletions internal/controller/template_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/pkg/checker"
"github.com/apache/answer/pkg/converter"
"github.com/apache/answer/pkg/day"
"github.com/apache/answer/pkg/htmltext"
"github.com/apache/answer/pkg/obj"
"github.com/apache/answer/pkg/uid"
Expand Down Expand Up @@ -422,6 +423,9 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
if siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionID || siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDByShortID {
siteInfo.Canonical = fmt.Sprintf("%s/questions/%s", siteInfo.General.SiteUrl, id)
}
// JSON-LD dates are machine-read (search engines), so they must carry the
// site's configured zone like every other server-rendered date.
siteLoc := day.Location(siteInfo.Interface.TimeZone)
jsonLD := &schema.QAPageJsonLD{}
jsonLD.Context = "https://schema.org"
jsonLD.Type = "QAPage"
Expand All @@ -430,7 +434,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
jsonLD.MainEntity.Text = detail.HTML
jsonLD.MainEntity.AnswerCount = int(answerCount)
jsonLD.MainEntity.UpvoteCount = detail.VoteCount
jsonLD.MainEntity.DateCreated = time.Unix(detail.CreateTime, 0)
jsonLD.MainEntity.DateCreated = time.Unix(detail.CreateTime, 0).In(siteLoc)
jsonLD.MainEntity.Author.Type = "Person"
jsonLD.MainEntity.Author.Name = detail.UserInfo.DisplayName
jsonLD.MainEntity.Author.URL = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, detail.UserInfo.Username)
Expand All @@ -440,7 +444,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
acceptedAnswerItem := &schema.AcceptedAnswerItem{}
acceptedAnswerItem.Type = "Answer"
acceptedAnswerItem.Text = answer.HTML
acceptedAnswerItem.DateCreated = time.Unix(answer.CreateTime, 0)
acceptedAnswerItem.DateCreated = time.Unix(answer.CreateTime, 0).In(siteLoc)
acceptedAnswerItem.UpvoteCount = answer.VoteCount
acceptedAnswerItem.URL = fmt.Sprintf("%s/%s", siteInfo.Canonical, answer.ID)
acceptedAnswerItem.Author.Type = "Person"
Expand All @@ -451,7 +455,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
item := &schema.SuggestedAnswerItem{}
item.Type = "Answer"
item.Text = answer.HTML
item.DateCreated = time.Unix(answer.CreateTime, 0)
item.DateCreated = time.Unix(answer.CreateTime, 0).In(siteLoc)
item.UpvoteCount = answer.VoteCount
item.URL = fmt.Sprintf("%s/%s", siteInfo.Canonical, answer.ID)
item.Author.Type = "Person"
Expand Down
23 changes: 18 additions & 5 deletions pkg/day/day.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,27 @@ func Format(unix int64, format, tz string) (formatted string) {
from = suffix
}

formatted = time.Unix(unix, 0).In(Location(tz)).Format(toFormat.String())
return
}

// Location resolves an IANA time zone name to a *time.Location. An empty or
// unknown name falls back to the server's local zone, so a bad configuration
// never shifts the instant that is being rendered.
func Location(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil || tz == "" {
// An empty or unknown zone must not shift the instant that is being
// rendered: fall back to the server local zone.
loc = time.Local
return time.Local
}
formatted = time.Unix(unix, 0).In(loc).Format(toFormat.String())
return
return loc
}

// FormatISO renders a unix timestamp as an ISO-8601 string in the given IANA
// time zone, e.g. "2023-11-15T07:13:20.000+09:00". The zone marker is a real
// offset, or "Z" when the instant renders in UTC, so the output is always a
// valid datetime attribute for the <time> element and for schema.org dates.
func FormatISO(unix int64, tz string) string {
return time.Unix(unix, 0).In(Location(tz)).Format("2006-01-02T15:04:05.000Z07:00")
}

func nextStdChunk(from []rune) (to, suffix []rune) {
Expand Down
39 changes: 39 additions & 0 deletions pkg/day/day_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,42 @@ func TestFormatUnknownTimezoneFallsBackToLocal(t *testing.T) {
})
}
}

// Location resolves an IANA name and must fall back to the server's local
// zone for empty or unknown names, so a bad configuration never shifts the
// rendered instant.
func TestLocation(t *testing.T) {
assert.Equal(t, time.UTC, Location("UTC"))
assert.Equal(t, time.Local, Location(""))
assert.Equal(t, time.Local, Location("Not/AZone"))

tokyo, err := time.LoadLocation("Asia/Tokyo")
assert.Nil(t, err)
assert.Equal(t, tokyo, Location("Asia/Tokyo"))
}

// FormatISO feeds every server-rendered <time datetime="..."> attribute: it
// renders the instant in the requested zone with a real ISO-8601 marker -
// "Z" only for UTC, an explicit offset otherwise.
func TestFormatISO(t *testing.T) {
// 2023-11-14 22:13:20 UTC
const sec = int64(1700000000)

tests := []struct {
name string
tz string
expected string
}{
{"utc_renders_z", "UTC", "2023-11-14T22:13:20.000Z"},
{"tokyo_offset", "Asia/Tokyo", "2023-11-15T07:13:20.000+09:00"},
{"new_york_offset", "America/New_York", "2023-11-14T17:13:20.000-05:00"},
{"kolkata_half_hour", "Asia/Kolkata", "2023-11-15T03:43:20.000+05:30"},
{"london_winter_is_z", "Europe/London", "2023-11-14T22:13:20.000Z"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, FormatISO(sec, tt.tz))
})
}
}