1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-03 11:46:22 -07:00

(Mostly) finish the configuration web interface, remove all _img fields since text boards won't happen

This commit is contained in:
Joshua Merrell 2018-08-09 00:04:45 -07:00
parent 0ef760dd61
commit c700e4c0ce
20 changed files with 415 additions and 379 deletions

View file

@ -34,25 +34,17 @@
"SiteWebfolder": "/",
"DomainRegex": "(https|http):\\/\\/(gochan\\.lunachan\\.net|gochan\\.org)\\/(.*)",
"Styles_img": ["pipes","efchan"],
"DefaultStyle_img": "pipes",
"Styles_txt": ["efchan,pipes"],
"DefaultStyle_txt": "pipes",
"Styles": ["pipes","efchan"],
"DefaultStyle": "pipes",
"AllowDuplicateImages": true,
"AllowVideoUploads": true,
"NewThreadDelay": 30,
"ReplyDelay": 7,
"MaxLineLength": 150,
"ReversedTrips": [
{
"from": "##thischangesto",
"to": "this"
},
{
"from": "##andthischangesto",
"to": "this"
}
"ReservedTrips": [
"thischangesto##this",
"andthischangesto##this"
],
"ThumbWidth": 200,
@ -62,18 +54,15 @@
"ThumbWidth_catalog": 50,
"ThumbHeight_catalog": 50,
"ThreadsPerPage_img": 4,
"ThreadsPerPage_txt": 15,
"PostsPerThreadPage": 4,
"ThreadsPerPage": 15,
"PostsPerThreadPage": 50,
"RepliesOnBoardPage": 3,
"StickyRepliesOnBoardPage": 1,
"BanColors": [
{
"Role": "admin",
"Color": "#0000A0"
}
"admin:#0000A0",
"somemod:blue"
],
"BanMsg": "<br /><span style=\"color:##BANCOLOR## \"><b>(USER WAS BANNED FOR THIS POST)</b></span>",
"BanMsg": "USER WAS BANNED FOR THIS POST",
"EmbedWidth": 200,
"EmbedHeight": 164,
"ExpandButton": true,
@ -81,9 +70,7 @@
"MakeURLsHyperlinked": true,
"NewTabOnOutlinks": true,
"DateTimeFormat": "Mon, January 02, 2006 15:04 PM",
"DefaultBanReason": "",
"AkismetAPIKey": "",
"EnableGeoIP": true,
"_comment": "set GeoIPDBlocation to cf to use Cloudflare's GeoIP",
@ -91,7 +78,7 @@
"MaxRecentPosts": 3,
"Verbosity": 0,
"EnableAppeals": true,
"MaxModlogDays": 14,
"MaxLogDays": 14,
"_comment": "Set RandomSeed to a (preferrably large) string of letters and numbers",
"RandomSeed": ""
}

View file

@ -59,3 +59,17 @@ h1, h2 {
padding-left: 4px;
padding-right: 4px;
}
input.config-text {
-moz-box-sizing: border-box;
box-sizing: border-box;
display: block;
padding: 4px;
width: 100%;
height: 100%;
}
.warning, div.config-status {
color:red;
font-weight: bold;
}

View file

@ -1,175 +0,0 @@
#main {
border-bottom:10px!important;
height:85%;
left:16%;
position:absolute;
top:90px;
width:auto;
}
#menu {
border:0;
height:100%;
left:0;
margin-left:5px;
padding:0;
position:absolute;
top:75px;
width:15%;
}
#site-title {
font-size:50px;
}
#top-pane {
height:75px;
left:0;
position:absolute;
text-align:center;
top:0;
width:100%;
}
#topmenu {
left:16%;
position:absolute;
top:76px;
}
#topmenu li {
background-color:#DFDFFE;
border:1px solid #9295a4;
border-left:none;
display:block;
float:left;
margin-top:-7px;
padding:3px 10px 2px;
}
#topmenu li.current {
background-color:#D6DAF0;
border-bottom:none;
margin-top:-8px;
padding-top:5px;
}
#topmenu li.first {
border-left:1px solid #9295a4;
}
.content {
margin-left:0!important;
padding-left:0!important;
text-align:justify;
}
.menu {
margin-top:1em;
text-align:center;
}
.newssub {
position:absolute;
}
.permalink {
display:block;
text-align:right;
}
.permalink a {
color:blue;
text-decoration:none;
}
.plus {
background:#c5c9e0;
border:1px solid #b4b8d0;
color:#000;
cursor:pointer;
float:right;
font-size:8px;
font-weight:400;
margin:0;
padding:1px 4px 2px;
}
.plus:hover {
background:#c5c9e0;
border:1px solid #c97;
}
a {
color:#34345C;
text-decoration:none;
}
body {
background:#EEF2FF;
color:#000;
font-family:sans-serif;
font-size:75%;
margin:8px;
width:90%;
}
body,html {
height:100%;
margin:0;
padding:0;
}
h1 {
color:#000;
font-size:150%;
margin:0;
text-align:center;
}
h1,h2 {
background:#D6DAF0;
text-align:left;
}
h1,h3,.menu {
font-family:Verdana,Tahoma,sans-serif;
}
h2 {
background:#D6DAF0;
font-size:100%;
margin:1em 0 0;
}
h2 a {
color:#550;
text-decoration:none;
}
h3 {
color:#800;
font-size:medium;
font-weight:400;
margin:0;
text-align:center;
}
li {
margin:0;
}
li a {
display:block;
width:100%;
}
ul.boardmenu li:hover,ul.modmenulink li:hover {
background:#C6DAF0;
}
ul.boardmenu,ul.modmenulink,div#topmenu ul {
list-style:none;
margin:0;
padding-left:0;
}

View file

@ -4,7 +4,6 @@ import (
"os"
)
// set in Makefile via -ldflags
var version string
var buildtimeString string // set in Makefile, format: YRMMDD.HHMM

View file

@ -3,12 +3,15 @@ package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@ -124,7 +127,7 @@ func createSession(key string, username string, password string, request *http.R
//returns 0 for successful, 1 for password mismatch, and 2 for other
domain := request.Host
var err error
chopPortNumRegex := regexp.MustCompile("(.+|\\w+):(\\d+)$")
chopPortNumRegex := regexp.MustCompile(`(.+|\w+):(\d+)$`)
domain = chopPortNumRegex.Split(domain, -1)[0]
if !validReferrer(request) {
@ -208,22 +211,205 @@ var manage_functions = map[string]ManageFunction{
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
var status string
if do == "save" {
// configJSON, err := json.Marshal(config)
// if err != nil {
// html += err.Error()
// return
// }
configJSON, err := json.MarshalIndent(config, "", "\t")
if err != nil {
status += err.Error() + "<br />\n"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status += "Error backing up old gochan.json, cancelling save: " + err.Error() + "\n"
} else {
config.Lockdown = (request.PostFormValue("Lockdown") == "on")
config.LockdownMessage = request.PostFormValue("LockdownMessage")
Sillytags_arr := strings.Split(request.PostFormValue("Sillytags"), "\n")
var Sillytags []string
for _, tag := range Sillytags_arr {
Sillytags = append(Sillytags, strings.Trim(tag, " \n\r"))
}
config.Sillytags = Sillytags
config.UseSillytags = (request.PostFormValue("UseSillytags") == "on")
config.Modboard = request.PostFormValue("Modboard")
config.SiteName = request.PostFormValue("SiteName")
config.SiteSlogan = request.PostFormValue("SiteSlogan")
config.SiteHeaderURL = request.PostFormValue("SiteHeaderURL")
config.SiteWebfolder = request.PostFormValue("SiteWebfolder")
Styles_arr := strings.Split(request.PostFormValue("Styles"), "\n")
var Styles []string
for _, style := range Styles_arr {
Styles = append(Styles, strings.Trim(style, " \n\r"))
}
config.Styles = Styles
config.DefaultStyle = request.PostFormValue("DefaultStyle")
config.AllowDuplicateImages = (request.PostFormValue("AllowDuplicateImages") == "on")
config.AllowVideoUploads = (request.PostFormValue("AllowVideoUploads") == "on")
NewThreadDelay, err := strconv.Atoi(request.PostFormValue("NewThreadDelay"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.NewThreadDelay = NewThreadDelay
}
// err = ioutil.WriteFile("gochan.json", configJSON, 0666)
// if err != nil {
// html += "Error writing \"gochan.json\": %s\n" + err.Error()
// return
// }
ReplyDelay, err := strconv.Atoi(request.PostFormValue("ReplyDelay"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ReplyDelay = ReplyDelay
}
MaxLineLength, err := strconv.Atoi(request.PostFormValue("MaxLineLength"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxLineLength = MaxLineLength
}
ReservedTrips_arr := strings.Split(request.PostFormValue("ReservedTrips"), "\n")
var ReservedTrips []string
for _, trip := range ReservedTrips_arr {
ReservedTrips = append(ReservedTrips, strings.Trim(trip, " \n\r"))
}
config.ReservedTrips = ReservedTrips
ThumbWidth, err := strconv.Atoi(request.PostFormValue("ThumbWidth"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth = ThumbWidth
}
ThumbHeight, err := strconv.Atoi(request.PostFormValue("ThumbHeight"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight = ThumbHeight
}
ThumbWidth_reply, err := strconv.Atoi(request.PostFormValue("ThumbWidth_reply"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth_reply = ThumbWidth_reply
}
ThumbHeight_reply, err := strconv.Atoi(request.PostFormValue("ThumbHeight_reply"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight_reply = ThumbHeight_reply
}
ThumbWidth_catalog, err := strconv.Atoi(request.PostFormValue("ThumbWidth_catalog"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth_catalog = ThumbWidth_catalog
}
ThumbHeight_catalog, err := strconv.Atoi(request.PostFormValue("ThumbHeight_catalog"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight_catalog = ThumbHeight_catalog
}
PostsPerThreadPage, err := strconv.Atoi(request.PostFormValue("PostsPerThreadPage"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.PostsPerThreadPage = PostsPerThreadPage
}
RepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("RepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.RepliesOnBoardPage = RepliesOnBoardPage
}
StickyRepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("StickyRepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.StickyRepliesOnBoardPage = StickyRepliesOnBoardPage
}
BanColors_arr := strings.Split(request.PostFormValue("BanColors"), "\n")
var BanColors []string
for _, color := range BanColors_arr {
BanColors = append(BanColors, strings.Trim(color, " \n\r"))
}
config.BanColors = BanColors
config.BanMsg = request.PostFormValue("BanMsg")
EmbedWidth, err := strconv.Atoi(request.PostFormValue("EmbedWidth"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.EmbedWidth = EmbedWidth
}
EmbedHeight, err := strconv.Atoi(request.PostFormValue("EmbedHeight"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.EmbedHeight = EmbedHeight
}
config.ExpandButton = (request.PostFormValue("ExpandButton") == "on")
config.ImagesOpenNewTab = (request.PostFormValue("ImagesOpenNewTab") == "on")
config.MakeURLsHyperlinked = (request.PostFormValue("MakeURLsHyperlinked") == "on")
config.NewTabOnOutlinks = (request.PostFormValue("NewTabOnOutlinks") == "on")
config.EnableQuickReply = (request.PostFormValue("EnableQuickReply") == "on")
config.DateTimeFormat = request.PostFormValue("DateTimeFormat")
AkismetAPIKey := request.PostFormValue("AkismetAPIKey")
err = checkAkismetAPIKey(AkismetAPIKey)
if err != nil {
status += err.Error() + "<br />"
} else {
config.AkismetAPIKey = AkismetAPIKey
}
config.EnableGeoIP = (request.PostFormValue("EnableGeoIP") == "on")
config.GeoIPDBlocation = request.PostFormValue("GeoIPDBlocation")
MaxRecentPosts, err := strconv.Atoi(request.PostFormValue("MaxRecentPosts"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxRecentPosts = MaxRecentPosts
}
Verbosity, err := strconv.Atoi(request.PostFormValue("Verbosity"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.Verbosity = Verbosity
}
config.EnableAppeals = (request.PostFormValue("EnableAppeals") == "on")
MaxLogDays, err := strconv.Atoi(request.PostFormValue("MaxLogDays"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxLogDays = MaxLogDays
}
configJSON, err = json.MarshalIndent(config, "", "\t")
if err != nil {
status += err.Error() + "<br />\n"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status = "Error writing gochan.json: %s\n" + err.Error()
} else {
status = "Wrote gochan.json successfully <br />"
}
}
}
manageConfigBuffer := bytes.NewBufferString("")
if err := manage_config_tmpl.Execute(manageConfigBuffer, nil); err != nil {
if err := manage_config_tmpl.Execute(manageConfigBuffer,
map[string]interface{}{"config": config, "status": status},
); err != nil {
html += handleError(1, err.Error())
return
}
@ -576,7 +762,8 @@ var manage_functions = map[string]ManageFunction{
if err != nil {
board.MaxPages = 11
}
board.DefaultStyle = request.FormValue("defaultstyle")
board.DefaultStyle = strings.Trim(request.FormValue("defaultstyle"), "\n")
board.Locked = (request.FormValue("locked") == "on")
board.ForcedAnon = (request.FormValue("forcedanon") == "on")

View file

@ -112,7 +112,6 @@ func buildBoardPages(board *BoardsTable) (html string) {
for _, op := range op_posts {
var thread Thread
var posts_in_thread []PostTable
thread.IName = "thread"
// Get the number of replies to this thread.
if err = queryRowSQL("SELECT COUNT(*) FROM `"+config.DBprefix+"posts` WHERE `boardid` = ? AND `parentid` = ? AND `deleted_timestamp` = ?",
@ -214,7 +213,7 @@ func buildBoardPages(board *BoardsTable) (html string) {
return
} else {
// Create the archive pages.
thread_pages = paginate(config.ThreadsPerPage_img, threads)
thread_pages = paginate(config.ThreadsPerPage, threads)
board.NumPages = len(thread_pages) - 1
// Create array of page wrapper objects, and open the file.
@ -475,7 +474,6 @@ func buildFrontPage() (html string) {
for rows.Next() {
frontpage := new(FrontTable)
frontpage.IName = "front page"
if err = rows.Scan(&frontpage.ID, &frontpage.Page, &frontpage.Order, &frontpage.Subject,
&frontpage.Message, &frontpage.Timestamp, &frontpage.Poster, &frontpage.Email); err != nil {
return handleError(1, err.Error())
@ -537,7 +535,7 @@ func buildBoardListJSON() (html string) {
for _, board_int := range allBoards {
board := board_int.(BoardsTable)
board_obj := BoardJSON{BoardName: board.Dir, Title: board.Title, WorkSafeBoard: 1,
ThreadsPerPage: config.ThreadsPerPage_img, Pages: board.MaxPages, MaxFilesize: board.MaxImageSize,
ThreadsPerPage: config.ThreadsPerPage, Pages: board.MaxPages, MaxFilesize: board.MaxImageSize,
MaxMessageLength: board.MaxMessageLength, BumpLimit: 200, ImageLimit: board.NoImagesAfter,
Cooldowns: cooldowns_obj, Description: board.Description, IsArchived: 0}
if board.EnableNSFW {
@ -568,14 +566,13 @@ func bumpThread(postID, boardID int) error {
// Checks check poster's name/tripcode/file checksum (from PostTable post) for banned status
// returns true if the user is banned
func checkBannedStatus(post *PostTable, writer http.ResponseWriter) ([]interface{}, error) {
var isExpired bool
var ban_entry BanlistTable
var banEntry BanlistTable
var interfaces []interface{}
// var count int
// var search string
err := queryRowSQL("SELECT `ip`, `name`, `tripcode`, `message`, `boards`, `timestamp`, `expires`, `appeal_at` FROM `"+config.DBprefix+"banlist` WHERE `ip` = ?",
[]interface{}{&post.IP},
[]interface{}{&ban_entry.IP, &ban_entry.Name, &ban_entry.Tripcode, &ban_entry.Message, &ban_entry.Boards, &ban_entry.Timestamp, &ban_entry.Expires, &ban_entry.AppealAt},
[]interface{}{&banEntry.IP, &banEntry.Name, &banEntry.Tripcode, &banEntry.Message, &banEntry.Boards, &banEntry.Timestamp, &banEntry.Expires, &banEntry.AppealAt},
)
if err == sql.ErrNoRows {
// the user isn't banned
@ -585,18 +582,18 @@ func checkBannedStatus(post *PostTable, writer http.ResponseWriter) ([]interface
handleError(1, "Error checking banned status: "+err.Error())
return interfaces, err
}
isExpired = ban_entry.Expires.After(time.Now()) == false
if isExpired {
if !banEntry.Expires.After(time.Now()) {
// if it is expired, send a message saying that it's expired, but still post
println(1, "expired")
return interfaces, nil
}
// the user's IP is in the banlist. Check if the ban has expired
if getSpecificSQLDateTime(ban_entry.Expires) == "0001-01-01 00:00:00" || ban_entry.Expires.After(time.Now()) {
if getSpecificSQLDateTime(banEntry.Expires) == "0001-01-01 00:00:00" || banEntry.Expires.After(time.Now()) {
// for some funky reason, Go's MySQL driver seems to not like getting a supposedly nil timestamp as an ACTUAL nil timestamp
// so we're just going to wing it and cheat. Of course if they change that, we're kind of hosed.
return []interface{}{config, ban_entry}, nil
return []interface{}{config, banEntry}, nil
}
return interfaces, nil
}
@ -633,8 +630,8 @@ func createImageThumbnail(image_obj image.Image, size string) image.Image {
return image_obj
}
thumb_w, thumb_h := getThumbnailSize(old_rect.Max.X, old_rect.Max.Y, size)
image_obj = imaging.Resize(image_obj, thumb_w, thumb_h, imaging.CatmullRom) // resize to 600x400 px using CatmullRom cubic filter
thumbW, thumbH := getThumbnailSize(old_rect.Max.X, old_rect.Max.Y, size)
image_obj = imaging.Resize(image_obj, thumbW, thumbH, imaging.CatmullRom) // resize to 600x400 px using CatmullRom cubic filter
return image_obj
}
@ -680,7 +677,7 @@ func getNewFilename() string {
}
// find out what out thumbnail's width and height should be, partially ripped from Kusaba X
func getThumbnailSize(w int, h int, size string) (new_w int, new_h int) {
func getThumbnailSize(w int, h int, size string) (newWidth int, newHeight int) {
var thumbWidth int
var thumbHeight int
@ -696,8 +693,8 @@ func getThumbnailSize(w int, h int, size string) (new_w int, new_h int) {
thumbHeight = config.ThumbHeight_catalog
}
if w == h {
new_w = thumbWidth
new_h = thumbHeight
newWidth = thumbWidth
newHeight = thumbHeight
} else {
var percent float32
if w > h {
@ -705,8 +702,8 @@ func getThumbnailSize(w int, h int, size string) (new_w int, new_h int) {
} else {
percent = float32(thumbHeight) / float32(h)
}
new_w = int(float32(w) * percent)
new_h = int(float32(h) * percent)
newWidth = int(float32(w) * percent)
newHeight = int(float32(h) * percent)
}
return
}
@ -729,7 +726,7 @@ func parseName(name string) map[string]string {
// inserts prepared post object into the SQL table so that it can be rendered
func insertPost(post PostTable, bump bool) (sql.Result, error) {
result, err := execSQL(
"INSERT INTO "+config.DBprefix+"posts "+
"INSERT INTO `"+config.DBprefix+"posts` "+
"(`boardid`,`parentid`,`name`,`tripcode`,`email`,`subject`,`message`,`message_raw`,`password`,`filename`,`filename_original`,`file_checksum`,`filesize`,`image_w`,`image_h`,`thumb_w`,`thumb_h`,`ip`,`tag`,`timestamp`,`autosage`,`poster_authority`,`deleted_timestamp`,`bumped`,`stickied`,`locked`,`reviewed`,`sillytag`)"+
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
post.BoardID, post.ParentID, post.Name, post.Tripcode, post.Email,
@ -768,7 +765,6 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
chopPortNumRegex := regexp.MustCompile("(.+|\\w+):(\\d+)$")
domain = chopPortNumRegex.Split(domain, -1)[0]
post.IName = "post"
post.ParentID, _ = strconv.Atoi(request.FormValue("threadid"))
post.BoardID, _ = strconv.Atoi(request.FormValue("boardid"))

View file

@ -131,23 +131,23 @@ func initServer() {
server.namespaces = make(map[string]func(http.ResponseWriter, *http.Request))
// Check if Akismet API key is usable at startup.
if config.AkismetAPIKey != "" {
checkAkismetAPIKey()
if err = checkAkismetAPIKey(config.AkismetAPIKey); err != nil {
config.AkismetAPIKey = ""
handleError(0, "%s", err.Error())
}
// Compile regex for checking referrers.
referrerRegex = regexp.MustCompile(config.DomainRegex)
testfunc := func(writer http.ResponseWriter, request *http.Request) {
if writer != nil {
_, _ = writer.Write([]byte("hahahaha"))
}
}
server.AddNamespace("example", testfunc)
server.AddNamespace("manage", callManageFunction)
server.AddNamespace("post", makePost)
server.AddNamespace("util", utilHandler)
server.AddNamespace("example", func(writer http.ResponseWriter, request *http.Request) {
if writer != nil {
_, _ = writer.Write([]byte("hahahaha"))
}
})
// eventually plugins will be able to register new namespaces. Or they will be restricted to something like /plugin
if config.UseFastCGI {

View file

@ -69,7 +69,7 @@ func connectToSQLServer() {
}
if newInstall {
printf(0, "\nThis looks like a new install, setting up the database...")
printf(0, "\nThis looks like a new install or one that needs updating, setting up the database...")
if _, err = db.Exec(
"INSERT INTO `" + config.DBname + "`.`" + config.DBprefix + "staff` " +
"(`username`, `password_checksum`, `salt`, `rank`) " +

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"html"
"reflect"
"strconv"
"strings"
"text/template"
@ -106,8 +107,9 @@ var funcMap = template.FuncMap{
return a == b
},
"intToString": strconv.Itoa,
"isStyleDefault_img": func(style string) bool {
return style == config.DefaultStyle_img
"arrToString": arrToString,
"isStyleDefault": func(style string) bool {
return style == config.DefaultStyle
},
"formatTimestamp": humanReadableTime,
"getThreadID": func(post_i interface{}) (thread int) {
@ -152,12 +154,10 @@ var funcMap = template.FuncMap{
"formatFilesize": func(size_int int) string {
size := float32(size_int)
if size < 1000 {
return fmt.Sprintf("%fB", size)
return fmt.Sprintf("%d B", size_int)
} else if size <= 100000 {
//size = size * 0.2
return fmt.Sprintf("%0.1f KB", size/1024)
} else if size <= 100000000 {
//size = size * 0.2
return fmt.Sprintf("%0.2f MB", size/1024/1024)
}
return fmt.Sprintf("%0.2f GB", size/1024/1024/1024)
@ -173,6 +173,58 @@ var funcMap = template.FuncMap{
}
return img[0:index] + "t." + filetype
},
"generateConfigTable": func() string {
configType := reflect.TypeOf(config)
tableOut := "<table style=\"border-collapse: collapse;\"><tr><th>Field name</th><th>Value</th><th>Type</th><th>Description</th></tr>\n"
numFields := configType.NumField()
for f := 17; f < numFields-2; f++ {
// starting at Lockdown because the earlier fields can't be safely edited from a web interface
field := configType.Field(f)
if field.Tag.Get("critical") != "" {
continue
}
name := field.Name
tableOut += "<tr><th>" + name + "</th><td>"
f := reflect.Indirect(reflect.ValueOf(config)).FieldByName(name)
kind := f.Kind()
switch kind {
case reflect.Int:
tableOut += "<input name=\"" + name + "\" type=\"number\" value=\"" + html.EscapeString(fmt.Sprintf("%v", f)) + "\" class=\"config-text\"/>"
case reflect.String:
tableOut += "<input name=\"" + name + "\" type=\"text\" value=\"" + html.EscapeString(fmt.Sprintf("%v", f)) + "\" class=\"config-text\"/>"
case reflect.Bool:
checked := ""
if f.Bool() {
checked = "checked"
}
tableOut += "<input name=\"" + name + "\" type=\"checkbox\" " + checked + " />"
case reflect.Slice:
tableOut += "<textarea name=\"" + name + "\" rows=\"4\" cols=\"28\">"
arrLength := f.Len()
for s := 0; s < arrLength; s++ {
newLine := "\n"
if s == arrLength-1 {
newLine = ""
}
tableOut += html.EscapeString(f.Slice(s, s+1).Index(0).String()) + newLine
}
tableOut += "</textarea>"
default:
tableOut += fmt.Sprintf("%v", kind)
}
tableOut += "</td><td>" + kind.String() + "</td><td>"
defaultTag := field.Tag.Get("default")
var defaultTagHTML string
if defaultTag != "" {
defaultTagHTML = " <b>Default: " + defaultTag + "</b>"
}
tableOut += field.Tag.Get("description") + defaultTagHTML + "</td>"
tableOut += "</tr>\n"
}
tableOut += "</table>\n"
return tableOut
},
}
var (

View file

@ -36,7 +36,6 @@ type RecentPost struct {
}
type Thread struct {
IName string
OP PostTable
NumReplies int
NumImages int
@ -81,7 +80,6 @@ type BannedHashesTable struct {
}
type BoardsTable struct {
IName string
ID int
CurrentPage int
NumPages int
@ -117,7 +115,6 @@ type BoardsTable struct {
}
type BoardSectionsTable struct {
IName string
ID int
Order int
Hidden bool
@ -150,7 +147,6 @@ type FiletypesTable struct {
// FrontTable represents the information (News, rules, etc) on the front page
type FrontTable struct {
IName string
ID int
Page int
Order int
@ -192,7 +188,6 @@ type PollResultsTable struct {
// PostTable represents each post in the database
type PostTable struct {
IName string
ID int
CurrentPage int
NumPages int
@ -330,7 +325,6 @@ type ThreadJSON struct {
// GochanConfig stores crucial info and is read from/written to gochan.json
type GochanConfig struct {
IName string //used by our template parser
ListenIP string
Port int
FirstPage []string
@ -351,67 +345,63 @@ type GochanConfig struct {
DBprefix string
DBkeepalive bool
Lockdown bool
LockdownMessage string
Sillytags []string
UseSillytags bool
Modboard string
Lockdown bool `description:"Disables posting." default:"unchecked"`
LockdownMessage string `description:"Message displayed when someone tries to post while the site is on lockdown."`
Sillytags []string `description:"List of randomly selected staff tags separated by line, e.g. <span style=\"color: red;\">## Mod</span>, to be randomly assigned to posts if UseSillytags is checked. Don't include the \"## \""`
UseSillytags bool `description:"Use Sillytags" default:"unchecked"`
Modboard string `description:"A super secret clubhouse board that only staff can view/post to." default:"staff"`
SiteName string
SiteSlogan string
SiteHeaderURL string
SiteWebfolder string
SiteDomain string
DomainRegex string
SiteName string `description:"The name of the site that appears in the header of the front page." default:"Gochan"`
SiteSlogan string `description:"The text that appears below SiteName on the home page"`
SiteHeaderURL string `description:"To be honest, I'm not even sure what this does. It'll probably be removed later."`
SiteWebfolder string `description:"The HTTP root appearing in the browser (e.g. https://gochan.org/&lt;SiteWebFolder&gt;" default:"/"`
SiteDomain string `description:"The server's domain (duh). Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"127.0.0.1" critical:"true"`
DomainRegex string `description:"Regular expression used for incoming request validation. Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"(https|http):\\\\/\\\\/(gochan\\\\.lunachan\\.net|gochan\\\\.org)\\/(.*)" critical:"true"`
Styles_img []string
DefaultStyle_img string
Styles_txt []string
DefaultStyle_txt string
Styles []string `description:"List of styles (one per line) that should be accessed online at /&lt;SiteWebFolder&gt;/css/&lt;Style&gt;/"`
DefaultStyle string `description:"Style used by default (duh). This should appear in the list above or bad things might happen."`
AllowDuplicateImages bool
AllowVideoUploads bool
NewThreadDelay int
ReplyDelay int
MaxLineLength int
ReservedTrips []interface{}
AllowDuplicateImages bool `description:"Disabling this will cause gochan to reject a post if the image has already been uploaded for another post.<br />This may end up being removed or being made board-specific in the future." default:"checked"`
AllowVideoUploads bool `description:"Allows users to upload .webm videos. <br />This may end up being removed or being made board-specific in the future."`
NewThreadDelay int `description:"The amount of time in seconds that is required before an IP can make a new thread.<br />This may end up being removed or being made board-specific in the future." default:"30"`
ReplyDelay int `description:"Same as the above, but for replies." default:"7"`
MaxLineLength int `description:"Any line in a post that exceeds this will be split into two (or more) lines.<br />I'm not really sure why this is here, so it may end up being removed." default:"150"`
ReservedTrips []string `description:"Secure tripcodes (!!Something) can be reserved here.<br />Each reservation should go on its own line and should look like this:<br />TripPassword1##Tripcode1<br />TripPassword2##Tripcode2"`
ThumbWidth int
ThumbHeight int
ThumbWidth_reply int
ThumbHeight_reply int
ThumbWidth_catalog int
ThumbHeight_catalog int
ThumbWidth int `description:"OP thumbnails use this as their max width.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbHeight int `description:"OP thumbnails use this as their max height.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbWidth_reply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbHeight_reply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbWidth_catalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"125"`
ThumbHeight_catalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"125"`
ThreadsPerPage_img int
ThreadsPerPage_txt int
PostsPerThreadPage int
RepliesOnBoardPage int
StickyRepliesOnBoardPage int
BanColors []interface{}
BanMsg string
EmbedWidth int
EmbedHeight int
ExpandButton bool
ImagesOpenNewTab bool
MakeURLsHyperlinked bool
NewTabOnOutlinks bool
EnableQuickReply bool
ThreadsPerPage int `default:"15"`
PostsPerThreadPage int `description:"Max number of replies to a thread to show on each thread page." default:"50"`
RepliesOnBoardPage int `description:"Number of replies to a thread to show on the board page." default:"3"`
StickyRepliesOnBoardPage int `description:"Same as above for stickied threads." default:"1"`
BanColors []string `description:"Colors to be used for public ban messages (e.g. USER WAS BANNED FOR THIS POST).<br />Each entry should be on its own line, and should look something like this:<br />username1:#FF0000<br />username2:#FAF00F<br />username3:blue<br />Invalid entries/nonexistent usernames will show a warning and use the default red."`
BanMsg string `description:"The default public ban message." default:"USER WAS BANNED FOR THIS POST"`
EmbedWidth int `description:"The width for inline/expanded webm videos." default:"200"`
EmbedHeight int `description:"The height for inline/expanded webm videos." default:"164"`
ExpandButton bool `description:"If checked, adds [Embed] after a Youtube, Vimeo, etc link to toggle an inline video frame." default:"checked"`
ImagesOpenNewTab bool `description:"If checked, thumbnails will open the respective image/video in a new tab instead of expanding them." default:"unchecked"`
MakeURLsHyperlinked bool `description:"If checked, URLs in posts will be turned into a hyperlink. If unchecked, ExpandButton and NewTabOnOutlinks are ignored." default:"checked"`
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab." default:"checked"`
EnableQuickReply bool `description:"If checked, an optional quick reply box is used. This may end up being removed." default:"checked"`
DateTimeFormat string
DefaultBanReason string
AkismetAPIKey string
EnableGeoIP bool
GeoIPDBlocation string // set to "cf" or the path to the db
MaxRecentPosts int
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info."`
AkismetAPIKey string `description:"The API key to be sent to Akismet for post spam checking. If the key is invalid, Akismet won't be used."`
EnableGeoIP bool `description:"If checked, this enables the usage of GeoIP for posts." default:"checked"`
GeoIPDBlocation string `description:"Specifies the location of the GeoIP database file. If you're using CloudFlare, you can set it to cf to rely on CloudFlare for GeoIP information." default:"/usr/share/GeoIP/GeoIP.dat"`
MaxRecentPosts int `description:"The maximum number of posts to show on the Recent Posts list on the front page." default:"3"`
// Verbosity = 0 for no debugging info. Critical errors and general output only
// Verbosity = 1 for non-critical warnings and important info
// Verbosity = 2 for all debugging/benchmarks/warnings
Verbosity int
EnableAppeals bool
MaxModlogDays int
RandomSeed string
Version string
Verbosity int `description:"The level of verbosity to use in error/warning messages. 0 = critical errors/startup messages, 1 = warnings, 2 = benchmarks/notices." default:"0"`
EnableAppeals bool `description:"If checked, allow banned users to appeal their bans.<br />This will likely be removed (permanently allowing appeals) or made board-specific in the future." default:"checked"`
MaxLogDays int `description:"The maximum number of days to keep messages in the moderation/staff log file."`
RandomSeed string `critical:"true"`
Version string `critical:"true"`
}
func initConfig() {
@ -426,7 +416,6 @@ func initConfig() {
os.Exit(2)
}
config.IName = "GochanConfig"
if config.ListenIP == "" {
println(0, "ListenIP not set in gochan.json, halting.")
os.Exit(2)
@ -559,22 +548,13 @@ func initConfig() {
//config.DomainRegex = "(https|http):\\/\\/(" + config.SiteDomain + ")\\/(.*)"
}
if config.Styles_img == nil {
println(0, "Styles_img not set in gochan.json, halting.")
if config.Styles == nil {
println(0, "Styles not set in gochan.json, halting.")
os.Exit(2)
}
if config.DefaultStyle_img == "" {
config.DefaultStyle_img = config.Styles_img[0]
}
if config.Styles_txt == nil {
println(0, "Styles_txt not set in gochan.json, halting.")
os.Exit(2)
}
if config.DefaultStyle_txt == "" {
config.DefaultStyle_txt = config.Styles_txt[0]
if config.DefaultStyle == "" {
config.DefaultStyle = config.Styles[0]
}
if config.NewThreadDelay == 0 {
@ -615,12 +595,8 @@ func initConfig() {
config.ThumbHeight_catalog = 50
}
if config.ThreadsPerPage_img == 0 {
config.ThreadsPerPage_img = 10
}
if config.ThreadsPerPage_txt == 0 {
config.ThreadsPerPage_txt = 15
if config.ThreadsPerPage == 0 {
config.ThreadsPerPage = 10
}
if config.PostsPerThreadPage == 0 {
@ -659,8 +635,8 @@ func initConfig() {
config.MaxRecentPosts = 10
}
if config.MaxModlogDays == 0 {
config.MaxModlogDays = 15
if config.MaxLogDays == 0 {
config.MaxLogDays = 15
}
if config.RandomSeed == "" {

View file

@ -31,6 +31,17 @@ const (
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 abcdefghijklmnopqrstuvwxyz~!@#$%%^&*()_+{}[]-=:\"\\/?.>,<;:'"
)
func arrToString(arr []string) string {
var out string
for i, val := range arr {
out += val
if i < len(arr)-1 {
out += ","
}
}
return out
}
func benchmarkTimer(name string, givenTime time.Time, starting bool) (returnTime time.Time) {
if starting {
// starting benchmark test
@ -194,7 +205,6 @@ func getBoardArr(parameterList map[string]interface{}, extra string) (boards []B
// then append it to the boards array we are going to return
for rows.Next() {
board := new(BoardsTable)
board.IName = "board"
if err = rows.Scan(
&board.ID,
&board.Order,
@ -283,7 +293,6 @@ func getPostArr(parameterList map[string]interface{}, extra string) (posts []Pos
// then append it to the posts array we are going to return
for rows.Next() {
var post PostTable
post.IName = "post"
if err = rows.Scan(&post.ID, &post.BoardID, &post.ParentID, &post.Name, &post.Tripcode,
&post.Email, &post.Subject, &post.MessageHTML, &post.MessageText, &post.Password, &post.Filename,
&post.FilenameOriginal, &post.FileChecksum, &post.Filesize, &post.ImageW,
@ -313,8 +322,6 @@ func getSectionArr(where string) (sections []interface{}, err error) {
for rows.Next() {
section := new(BoardSectionsTable)
section.IName = "section"
if err = rows.Scan(&section.ID, &section.Order, &section.Hidden, &section.Name, &section.Abbreviation); err != nil {
handleError(1, customError(err))
return
@ -352,15 +359,15 @@ func getFileExtension(filename string) (extension string) {
return
}
func getFormattedFilesize(size int) string {
func getFormattedFilesize(size float64) string {
if size < 1000 {
return fmt.Sprintf("%fB", size)
return fmt.Sprintf("%dB", int(size))
} else if size <= 100000 {
return fmt.Sprintf("%fKB", size/1024)
} else if size <= 100000000 {
return fmt.Sprintf("%fMB", size/1024/1024)
return fmt.Sprintf("%fMB", size/1024.0/1024.0)
}
return fmt.Sprintf("%0.2fGB", size/1024/1024/1024)
return fmt.Sprintf("%0.2fGB", size/1024.0/1024.0/1024.0)
}
// returns the filename, line number, and function where getMetaInfo() is called
@ -475,25 +482,30 @@ func bToA(b bool) string {
}
// Checks the validity of the Akismet API key given in the config file.
func checkAkismetAPIKey() {
resp, err := http.PostForm("https://rest.akismet.com/1.1/verify-key", url.Values{"key": {config.AkismetAPIKey}, "blog": {"http://" + config.SiteDomain}})
if err != nil {
handleError(1, err.Error())
func checkAkismetAPIKey(key string) error {
if key == "" {
return fmt.Errorf("Blank key given, Akismet won't be used.")
}
resp, err := http.PostForm("https://rest.akismet.com/1.1/verify-key", url.Values{"key": {key}, "blog": {"http://" + config.SiteDomain}})
defer func() {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
}()
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
handleError(1, err.Error())
return err
}
if string(body) == "invalid" {
// This should disable the Akismet checks if the API key is not valid.
errorLog.Print("Akismet API key is invalid, Akismet spam protection will be disabled.")
config.AkismetAPIKey = ""
errmsg := "Akismet API key is invalid, Akismet spam protection will be disabled."
return fmt.Errorf(errmsg)
}
return nil
}
// Checks a given post for spam with Akismet. Only checks if Akismet API key is set.

View file

@ -3,10 +3,10 @@
<head>
<title>Banned</title>
<link rel="stylesheet" href="/css/global/front.css" />
{{range $i, $style := .config.Styles_img}}
<link rel="{{if not (isStyleDefault_img $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/front.css" />{{end}}
{{range $i, $style := .config.Styles}}
<link rel="{{if not (isStyleDefault $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/front.css" />{{end}}
<script type="text/javascript">
var styles = [{{range $i, $style := .config.Styles_img}}{{if gt $i 0}}, {{end}}"{{$style}}"{{end}}];
var styles = [{{range $i, $style := .config.Styles}}{{if gt $i 0}}, {{end}}"{{$style}}"{{end}}];
var webroot = "{{.config.SiteWebfolder}}"
</script>
<script type="text/javascript" src="/javascript/jquery-3.3.1.min.js"></script>

View file

@ -7,15 +7,15 @@
<script type="text/javascript" src="/javascript/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="/javascript/msgpack.js"></script>
<script type="text/javascript">
var styles = [{{range $ii, $style := .config.Styles_img}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var styles = [{{range $ii, $style := .config.Styles}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var webroot = "{{.config.SiteWebfolder}}"
</script>
<script type="text/javascript" src="/javascript/gochan.js"></script>
<script type="text/javascript" src="/javascript/manage.js"></script>
<link rel="stylesheet" href="/css/global/front.css" />
{{range $i, $style := .config.Styles_img}}
<link rel="{{if not (isStyleDefault_img $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/front.css" />{{end}}
{{range $i, $style := .config.Styles}}
<link rel="{{if not (isStyleDefault $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/front.css" />{{end}}
<link rel="shortcut icon" href="/favicon.png">
</head>
<body>

View file

@ -12,7 +12,7 @@
{{else}}<title>/{{.board.Dir}}/ - {{.board.Title}}</title>{{end}}
<script type="text/javascript" src="/javascript/jquery-3.3.1.min.js"></script>
<script type="text/javascript">
var styles = [{{range $ii, $style := $.config.Styles_img}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var styles = [{{range $ii, $style := $.config.Styles}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var webroot = "{{$.config.SiteWebfolder}}";
var thread_type = "thread";
@ -23,8 +23,8 @@
<script type="text/javascript" src="/javascript/gochan.js"></script>
<script type="text/javascript" src="/javascript/manage.js"></script>
<link rel="stylesheet" href="/css/global/img.css" />
{{range $_, $style := .config.Styles_img}}
<link rel="{{if not (isStyleDefault_img $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/img.css" />{{end}}
{{range $_, $style := .config.Styles}}
<link rel="{{if not (isStyleDefault $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/img.css" />{{end}}
<link rel="shortcut icon" href="/favicon.png" />
</head>
<body>

View file

@ -15,7 +15,7 @@
<tr><td>Max image size</td><td><input type="text" name="maximagesize" value="4718592" /></td></tr>
<tr><td>Max pages</td><td><input type="text" name="maxpages" value="11" /></td></tr>
<tr><td>Default style</td><td><select name="defaultstyle" selected="">
{{range $_, $style := .config.Styles_img}}<option value="{{$style}}">{{$style}}</option>{{end}}
{{range $_, $style := .config.Styles}}<option value="{{$style}}">{{$style}}</option>{{end}}
</select></td></tr>
<tr><td>Locked</td><td><input type="checkbox" name="locked" {{if $.board.Locked}}checked{{end}}/></td></tr>
<tr><td>Forced anonymity</td><td><input type="checkbox" name="forcedanon" {{if .board.ForcedAnon}}checked{{end}}/></td></tr>

View file

@ -1,22 +1,10 @@
<h2>Config editor</h2>
{{if ne .status ""}}{{.status}}<hr />{{end}}
Some fields omitted because they can not be (safely) edited from the web interface while Gochan is running.
Edit these directly in gochan.json, then restart Gochan.<br />
<span class="warning">This config editor isn't fully stable so MAKE BACKUPS!</span>
<form action="/manage?action=config" method="POST">
<table>
<tr><th>Name</th><th>Value</th><th>Description</th></tr>
<tr><th>DocumentRoot</th><td><input type="text" value="{{.config.DocumentRoot}}"/></td></tr>
<tr><th>TemplateDir</th><td><input type="text" value="{{.config.TemplateDir}}" /></td></tr>
<tr><th>LogDir</th><td><input type="text" value="{{.config.LogDir}}" /></td></tr>
<tr><th>Lockdown</th><td><input type="checkbox" {{if .config.Lockdown}}checked{{end}}/></td></tr>
<tr><th>LockdownMessage</th><td><input type="text" value="{{.config.LockdownMessage}}" /></td></tr>
<tr><th>UseSillytags</th><td><input type="checkbox" /></td></tr>
<tr><th>Modboard</th><td><input type="text" value="{{.config.Modboard}}"></td></tr>
<tr><th>SiteName</th><td><input type="text" value="{{.config.SiteName}}" /></td></tr>
<tr><th>SiteSlogan</th><td><input type="text" value="{{.config.SiteSlogan}}" /></td></tr>
<tr><th>SiteHeaderURL</th><td><input type="text" value="{{.config.SiteHeaderURL}}" /></td></tr>
{{/*<tr><th>SiteWebfolder</th><td><input type="text" value="{{.config.SiteWebFolder}}" /></td></tr>*/}}
<tr><th>DomainRegex</th><td><input type="text" value="{{.config.DomainRegex}}" /></td><td>Don't touch this unless you know what you're doing! This could fuck your shit up<br />Default: (https|http)://(.*)/(.*)</td></tr>
</table>
<input name="do" value="save" type="hidden" />
{{generateConfigTable}}<br />
<input type="submit" />
</form>

View file

@ -1,10 +1,10 @@
<title>Gochan Manage page</title>
<link rel="stylesheet" href="/css/global/manage.css" />
{{range $i, $style := .Styles_img}}
<link rel="{{if not (isStyleDefault_img $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/manage.css" />{{end}}
{{range $i, $style := .Styles}}
<link rel="{{if not (isStyleDefault $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/manage.css" />{{end}}
<link rel="shortcut icon" href="/favicon.png" />
<script type="text/javascript">
var styles = [{{range $i, $style := .Styles_img}}{{if gt $i 0}}, {{end}}"{{$style}}"{{end}}];
var styles = [{{range $i, $style := .Styles}}{{if gt $i 0}}, {{end}}"{{$style}}"{{end}}];
var webroot = "{{.SiteWebfolder}}"
</script>
<script type="text/javascript" src="/javascript/jquery-3.3.1.min.js"></script>

View file

@ -6,14 +6,14 @@
<title>Edit Post</title>
<script type="text/javascript" src="/javascript/jquery-3.3.1.min.js"></script>
<script type="text/javascript">
var styles = [{{range $ii, $style := $.config.Styles_img}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var styles = [{{range $ii, $style := $.config.Styles}}{{if gt $ii 0}}, {{end}}"{{$style}}"{{end}}];
var webroot = "{{$.config.SiteWebfolder}}";
</script>
<script type="text/javascript" src="/javascript/gochan.js"></script>
<script type="text/javascript" src="/javascript/manage.js"></script>
<link rel="stylesheet" href="/css/global/img.css" />
{{range $_, $style := .config.Styles_img}}
<link rel="{{if not (isStyleDefault_img $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/img.css" />{{end}}
{{range $_, $style := .config.Styles}}
<link rel="{{if not (isStyleDefault $style)}}alternate {{end}}stylesheet" href="/css/{{$style}}/img.css" />{{end}}
<link rel="shortcut icon" href="/favicon.png" />
</head>
<body>

View file

@ -1 +1 @@
1.10.3
1.11.0