[
  {
    "path": ".codebeatignore",
    "content": "/public/chartist/**\n/public/trumbowyg/**\n/public/jquery-emojiarea/**\n/public/font-awesome-4.7.0/**\n/public/jquery-3.1.1.min.js\n/public/EQCSS.min.js\n/public/EQCSS.js\n/schema/**\n\ntmpl_list.go\ntmpl_forum.go\ntmpl_forums.go\ntmpl_topic.go\ntmpl_topic_alt.go\ntmpl_topics.go\ntmpl_profile.go\n\ngen_mysql.go\ngen_mssql.go\ngen_pgsql.go\ngen_router.go\n"
  },
  {
    "path": ".codeclimate.yml",
    "content": "exclude_patterns:\n- \"gen_*\"\n- \"schema/*\"\n- \"public/chartist/*\"\n- \"public/trumbowyg/*\"\n- \"public/jquery-emojiarea/*\"\n- \"public/font-awesome-4.7.0/*\"\n- \"public/jquery-3.1.1.min.js\"\n- \"public/EQCSS.min.js\"\n- \"public/EQCSS.js\"\n- \"public/Sortable-1.4.0/*\""
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n    \"env\": {\n        \"browser\": true,\n        \"commonjs\": true,\n        \"es6\": true,\n        \"node\": false\n    },\n    \"parserOptions\": {\n        \"ecmaFeatures\": {\n            \"jsx\": true\n        },\n        \"sourceType\": \"module\"\n    },\n    \"rules\": {\n        \"no-const-assign\": \"warn\",\n        \"no-this-before-super\": \"warn\",\n        \"no-undef\": \"warn\",\n        \"no-unreachable\": \"warn\",\n        \"no-unused-vars\": \"warn\",\n        \"constructor-super\": \"warn\",\n        \"valid-typeof\": \"warn\"\n    },\n\t\"globals\": {\n\t\t\"$\": true,\n        \"addHook\": true,\n        \"runHook\": true,\n        \"addInitHook\": true,\n        \"runInitHook\": true,\n        \"loadScript\": true\n\t}\n}"
  },
  {
    "path": ".gitignore",
    "content": "tmp/*\n!tmp/filler.txt\ntmp2/*\ncert_test/*\ntmp.txt\nrun_notemplategen.bat\nbrun.bat\n\nattachs/*\n!attachs/filler.txt\nuploads/avatar_*\nuploads/socialgroup_*\nbackups/*.sql\nlogs/*.log\nconfig/config.json\nnode_modules/*\nsamples/vue/node_modules/*\nsamples/vue/*\nbin/*\nout/*\n*.exe\n*.exe~\n*.prof\n*.log\n.DS_Store\n.vscode/launch.json\nconfig/config.go\nQueryGen\nRouterGen\nPatcher\nGosora\nInstaller\ntmpl_*.go\ntmpl_*.jgo"
  },
  {
    "path": ".htaccess",
    "content": "# Gosora doesn't use Apache, this file is just here to stop Apache from blindly serving our config files, etc. when this program isn't intended to be served in such a manner at all\n\ndeny from all"
  },
  {
    "path": ".travis.yml",
    "content": "language: go\ngo:\n  - \"1.13\"\n  - \"1.14\"\n  - \"1.15\"\n  - \"1.16\"\n  - master\nbefore_install:\n  - cd $HOME\n  - git clone https://github.com/Azareal/Gosora gosora\n  - cd gosora\n  - chmod -R 0777 .\n  - mv ./config/config_example.json ./config/config.json\n  - ./update-deps-linux\n  - ./dev-update-travis\n  - mv ./experimental/plugin_sendmail.go ..\ninstall: true\nbefore_script:\n  - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter\n  - chmod +x ./cc-test-reporter\n  - ./cc-test-reporter before-build\nscript: ./run-linux-tests\nafter_script:\n  - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT\naddons:\n  mariadb: '10.3'"
  },
  {
    "path": ".vscode/settings.json",
    "content": "// Place your settings in this file to overwrite default and user settings.\n{\n\t\"editor.insertSpaces\": false\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nFirst and foremost, if you want to add a contribution, you'll have to open a pull request and to sign the CLA (contributor level agreement).\n\nIt's mainly there to deal with any legal issues which may come our way and to switch licenses without having to track down every contributor who has ever contributed.\n\nSome things we could do is commercial licensing for companies which are not authorised to use open source licenses or moving to a more permissive license, although I'm not too experianced in these matters, if anyone has any ideas, then feel free to put them forward.\n\nTry to prefix commits which introduce a lot of bugs or otherwise has a large impact on the usability of Gosora with UNSTABLE.\n\nIf something seems to be strange, then feel free to bring up an alternative for it, although I'd rather not get hung up on the little details, if it's something which is purely a matter of opinion.\n\n# Coding Standards\n\nAll code must be unit tested where ever possible with the exception of JavaScript which is untestable with our current technologies, tread with caution there.\n\nUse tabs not spaces for indentation.\n\n# Golang\n\nUse the standard linter and listen to what it tells you to do.\n\nThe route assignments in main.go are *legacy code*, add new routes to `router_gen/routes.go` instead.\n\nTry to use the single responsibility principle where ever possible, with the exception for if doing so will cause a large performance drop. In other words, don't give your interfaces / structs too many responsibilities, keep them simple.\n\nAvoid hand-rolling queries. Use the builders, a ready built statement or a datastore structure instead. Preferably a datastore.\n\nCommits which require the patcher / update script to be run should be prefixed with \"Database Changes: \"\n\nMore coming up.\n\n# JavaScript\n\nUse semicolons at the end of statements. If you don't, you might wind up breaking a minifier or two.\n\nAlways use strict mode.\n\nDon't worry about ES5, we're targetting modern browsers. If we decide to backport code to older browsers, then we'll transpile the files.\n\nPlease don't use await. It incurs too much of a cognitive overhead as to where and when you can use it. We can't use it everywhere quite yet, which means that we really should be using it nowhere.\n\nPlease don't abuse `const` just to shave off a few nanoseconds. Even in the Go server where I care about performance the most, I don't use const everywhere, only in about five spots in thirty thousand lines and I don't use it for performance at all there.\n\nTo keep consistency with Go code, variables must be camelCase.\n\n# JSON\n\nTo keep consistency with Go code, map keys must be camelCase.\n\n# Phrases\n\nTry to keep the name of the phrase close to the actual phrase in english to make it easier for localisers to reason about which phrase is which.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    {one line to give the program's name and a brief idea of what it does.}\n    Copyright (C) {year}  {name of author}\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    {project}  Copyright (C) {year}  {fullname}\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Gosora ![Build Status](https://travis-ci.org/Azareal/Gosora.svg?branch=master) [![Azareal's Discord Chat](https://img.shields.io/badge/style-Invite-7289DA.svg?style=flat&label=Discord)](https://discord.gg/eyYvtTf)\n\nA super fast forum software written in Go. You can talk to us on our Discord chat!\n\nThe initial code-base was forked from one of my side projects, but has now gone far beyond that. We've moved along in a development and the software should be somewhat stable for general use.\n\nFeatures may break from time to time, however I will generally try to warn of the biggest offenders in advance, so that you can tread with caution around certain commits, the upcoming v0.1 will undergo even more rigorous testing.\n\nFile an issue or open a topic on the forum, if there's something you want and you very well might find it landing in the software fairly quickly.\n\nFor plugin and theme developers, things are a little dicier, as the internal APIs and ways of writing themes are in constant flux, however some stability in that area should be coming fairly soon.\n\nIf you like this software, please give it a star and give us some feedback :)\n\nIf you dislike it, please give us some feedback on how to make it better! We're always looking for feedback. We love hearing your opinions. If there's something missing or something doesn't look quite right, don't worry! We plan to add many, many things in the run up to v0.1!\n\n\n# Features\nStandard Forum Functionality. All of the little things you would expect of any forum software. E.g. Common Moderation features, modlogs, theme system, avatars, bbcode parser, markdown parser, report system, per-forum permissions, group permissions and so on.\n\nCustom Pages. There are some rough edges\n\nEmojis. Allow your users to express themselves without resorting to serving tons upon tons of image files.\n\nIn-memory static file, forum and group caches. We have a slightly more dynamic cache for users and topics.\n\nA profile system, including profile comments and moderation tools for the profile owner.\n\nA template engine which compiles templates down to machine code. Over forty times faster than the standard template library `html/templates`, although it does remove some of the hand holding to achieve this. Compatible with templates written for `html/templates`, so you don't need to learn any new templating language.\n\nA plugin system. We have a number of APIs and hooks for plugins, however they're currently subject to change and don't cover as much of the software as we'd like yet.\n\nA responsive design. Looks great on mobile phones, tablets, laptops, desktops and more!\n\nOther modern features like alerts, likes, advanced dashboard with live stats (CPU, RAM, online user count, and so on), etc.\n\n\n# Requirements\n\nGo 1.13 or newer - You will need to install this. Pick the .msi, if you want everything sorted out for you rather than having to go around updating the environment settings. https://golang.org/doc/install\n\nFor Ubuntu, you can consult: https://tecadmin.net/install-go-on-ubuntu/\nYou will also want to run `ln -s /usr/local/go/bin/go` (replace /usr/local with where ever you put Go), so that go becomes visible to other users.\n\nIf you followed the instructions above, you can update to the latest version of Go simply by deleting the `/go/` folder and replacing it with a `/go/` folder for the latest version of Go.\n\nGit - You may need this for downloading updates via the updater. You might already have this installed on your server, if the `git` commands don't work, then install this. https://git-scm.com/downloads\n\nMySQL Database - You will need to setup a MySQL Database somewhere. A MariaDB Database works equally well and is much faster than MySQL. You could use something like WNMP / XAMPP which have a little PHP script called PhpMyAdmin for managing MySQL databases or you could install MariaDB directly.\n\nDownload the .msi installer from [MariaDB](https://mariadb.com/downloads) and run that. You may want to set it up as a service to avoid running it every-time the computer starts up.\n\nInstructions on how to set MariaDB up on Linux: https://downloads.mariadb.org/mariadb/repositories/\n\nWe recommend changing the root password (that is the password for the user 'root'). Remember that password, you will need it for the installation process. Of course, we would advise using a user other than root for maximum security, although that adds additional steps to the process of getting everything setup.\n\nYou might also want to run `mysql_secure_installation` to further harden (aka make it more secure) MySQL / MariaDB.\n\nIf you're using Ubuntu, you might want to look at: https://www.itzgeek.com/how-tos/linux/ubuntu-how-tos/install-mariadb-on-ubuntu-16-04.html\n\nIt's entirely possible that your host already has MySQL installed and ready to go, so you might be able to skip this step, particularly if it's a managed VPS or a shared host. Or they might have a quicker and easier method of setting up MySQL.\n\n\n# How to download\n\nFor Linux, you can skip down to the Installation section as it covers this.\n\nOn Windows, you might want to try the [GosoraBootstrapper](https://github.com/Azareal/GosoraBootstrapper), if you can't find the command prompt or otherwise can't follow those instructions. It's just a matter of double-clicking on the bat file there and it'll download the rest of the files for you.\n\n# Installation\n\nConsult [installation](https://github.com/Azareal/Gosora/blob/master/docs/installation.md) for instructions on how to install Gosora.\n\n# Updating\n\nConsult [updating](https://github.com/Azareal/Gosora/blob/master/docs/updating.md) for instructions on how to update Gosora.\n\n\n# Running the program\n\n*Linux*\n\nIf you have setup a service, you can run:\n\n`./pre-run-linux`\n\n`service gosora start`\n\nYou can then, check Gosora's current status (to see if it started up properly) with:\n\n`service gosora status`\n\nAnd you can stop it with:\n\n`service gosora stop`\n\nIf you haven't setup a service, you can run `./run-linux`, although you will be responsible for finding a way to run it in the background, so that it doesn't close when the terminal does.\n\nOne method might be to use: https://serverfault.com/questions/34750/is-it-possible-to-detach-a-process-from-its-terminal-or-i-should-have-used-s\n\n*Windows*\n\nRun `run.bat`, e.g. double-clicking on it.\n\n\n# How do I install plugins?\n\nFor the default plugins like Markdown and Helloworld, you can find them in the Plugin Manager of your Control Panel. For ones which aren't included by default, you will need to drop them down in the `/extend/` directory.\n\nYou will then need to recompile Gosora in order to link the plugin code with Gosora's code. For plugins not written in Go (e.g. JavaScript), they will automatically show up in your Control Panel ready to be installed, although we currently don't support these types of plugins at this time.\n\nThere are also some experimental plugins in the `/experimental/` folder like plugin_sendmail which you may want to make use of, although there aren't any particular guarantees about whether they will continue to function or not.\n\nWe're currently in the process of moving plugins from the `/` to the `/extend/` folder, if there is a piece of functionality that you would like to tap into, but which you cannot from that package, then feel free to poke me, otherwise you may need to drop it in `/` and name the package accordingly.\n\n\n# Images\n![Shadow Theme](https://github.com/Azareal/Gosora/blob/master/images/shadow.png)\n\n![Shadow Quick Topic](https://github.com/Azareal/Gosora/blob/master/images/quick-topics.png)\n\n![Tempra Simple Theme](https://github.com/Azareal/Gosora/blob/master/images/tempra-simple.png)\n\n![Tempra Simple Topic List](https://github.com/Azareal/Gosora/blob/master/images/topic-list.png)\n\n![Tempra Simple Mobile](https://github.com/Azareal/Gosora/blob/master/images/tempra-simple-mobile-375px.png)\n\n![Cosora Prototype WIP](https://github.com/Azareal/Gosora/blob/master/images/cosora-wip.png)\n\nMore images in the /images/ folder. Beware though, some of them are *really* outdated. Also, keep in mind that a new theme is in the works.\n\n# Dependencies \n\nThese are the libraries and pieces of software which Gosora relies on to function, an \"ingredients\" list so to speak.\n\nA few of these like Rez aren't currently in use, but are things we think we'll need in the very near future and want to have those things ready, so that we can quickly slot them in.\n\n* Go 1.11+\n\n* MariaDB (or any other MySQL compatible database engine). We'll allow other database engines in the future.\n\n* github.com/go-sql-driver/mysql For interfacing with MariaDB.\n\n* golang.org/x/crypto/bcrypt For hashing passwords.\n* golang.org/x/crypto/argon2 For hashing passwords.\n\n* github.com/Azareal/gopsutil For pulling information on CPU and memory usage. I've temporarily forked this, as we were having stability issues with the latest build.\n\n  * github.com/StackExchange/wmi Dependency for gopsutil on Windows.\n\n  * golang.org/x/sys/windows Also a dependency for gopsutil on Windows. This isn't needed at the moment, as I've rolled things back to an older more stable build.\n\n* github.com/gorilla/websocket Needed for Gosora's Optional WebSockets Module.\n\n* github.com/robertkrimen/otto Needed for the upcoming JS plugin type.\n\n  * gopkg.in/sourcemap.v1 Dependency for Otto.\n\n* github.com/lib/pq For interfacing with PostgreSQL. You will be able to pick this instead of MariaDB soon.\n\n* ithub.com/denisenkom/go-mssqldb For interfacing with MSSQL. You will be able to pick this instead of MSSQL soon.\n\n* github.com/bamiaux/rez An image resizer (e.g. for spitting out thumbnails)\n\n  * github.com/esimov/caire The other image resizer, slower but may be useful for covering cases Rez does not. A third faster one we might point to at some point is probably Discord's Lilliput, however it requires a C Compiler and we don't want to add that as a dependency at this time.\n\n* github.com/fsnotify/fsnotify A library for watching events on the file system.\n\n* github.com/pkg/errors Some helpers to make it easier for us to track down bugs.\n\n* More items to come here, our dependencies are going through a lot of changes, and I'll be documenting those soon ;)\n\n# Bundled Plugins\n\nThere are several plugins which are bundled with the software by default. These cover various common tasks which aren't common enough to clutter the core with or which have competing implementation methods (E.g. plugin_markdown vs plugin_bbcode for post mark-up).\n\n* Hey There / Skeleton / Hey There (JS Version) - Example plugins for helping you learn how to develop plugins.\n\n* BBCode - A plugin in early development for converting BBCode Tags into HTML.\n\n* Markdown - An extremely simple plugin for converting Markdown into HTML.\n\n* Social Groups - An extremely unstable WIP plugin which lets users create their own little discussion areas which they can administrate / moderate on their own.\n\n# Developers\n\nThere are a few things you'll need to know before running the more developer oriented features like the tests or the benchmarks.\n\nThe benchmarks are currently being rewritten as they're currently extremely serial which can lead to severe slow-downs when run on a home computer due to the benchmarks being run on the one core everything else is being run on (Browser, OS, etc.) and the tests not taking parallelism into account.\n"
  },
  {
    "path": "TODO.md",
    "content": "# TO-DO\n\nOh my, you caught me right at the start of this project. There's nothing to see here yet, asides from the absolute basics. You might want to look again later!\n\n\nThe various little features which somehow got stuck in the net. Don't worry, I'll get to them!\n\nMore moderation features. E.g. Move, Approval Queue (Posts made by users in certain usergroups will need to be approved by a moderator before they're publically visible), etc.\n\nAdd a simple anti-spam measure. I have quite a few ideas in mind, but it'll take a while to implement the more advanced ones, so I'd like to put off some of those to a later date and focus on the basics. E.g. CAPTCHAs, hidden fields, etc.\n\nAdd more granular permissions management to the Forum Manager.\n\nAdd a *better* plugin system. E.g. Allow for plugins written in Javascript and ones written in Go. Also, we need to add many, many, many more plugin hooks.\n\nI will need to ponder over implementing an even faster router. We don't need one immediately, although it would be nice if we could get one in the near future. It really depends. Ideally, it would be one which can easily integrate with the current structure without much work, although I'm not beyond making some alterations to faciliate it, assuming that we don't get too tightly bound to that specific router.\n\nAllow themes to define their own templates and to override core templates with their own.\n\nAdd a friend system.\n\nImprove profile customisability.\n\nImplement all the common BBCode tags in plugin_bbcode\n\nImplement all the common Markdown codes in plugin_markdown\n\nAdd more administration features.\n\nAdd more features for improving user engagement. E.g. A like system. I have a few of these in mind, but I've been pre-occupied with implementing other features.\n\nAdd a widget system.\n\nAdd support for multi-factor authentication.\n\nAdd support for secondary emails for users.\n\nImprove the shell scripts and possibly add support for Make? A make.go might be a good solution?\n"
  },
  {
    "path": "attachs/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "backups/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "build-linux",
    "content": "echo \"Deleting artifacts from previous builds\"\nrm -f template_*.go\nrm -f tmpl_*.go\nrm -f gen_*.go\nrm -f tmpl_client/template_*\nrm -f tmpl_client/tmpl_*\nrm -f ./Gosora\nrm -f ./common/gen_extend.go\n\necho \"Building the router generator\"\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\necho \"Running the router generator\"\n./RouterGen\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\necho \"Building the hook generator\"\ngo build -tags hookgen -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\necho \"Running the hook generator\"\n./HookGen\n\necho \"Generating the JSON handlers\"\neasyjson -pkg common\n\necho \"Building the query generator\"\ngo build -ldflags=\"-s -w\" -o QueryGen \"./cmd/query_gen\"\necho \"Running the query generator\"\n./QueryGen\n\necho \"Building Gosora\"\ngo generate\ngo build -ldflags=\"-s -w\" -o Gosora\n\necho \"Building the installer\"\ngo build -ldflags=\"-s -w\" -o Installer \"./install\"\n"
  },
  {
    "path": "build-linux-nowebsockets",
    "content": "echo \"Deleting artifacts from previous builds\"\nrm -f template_*.go\nrm -f tmpl_*.go\nrm -f gen_*.go\nrm -f tmpl_client/template_*\nrm -f tmpl_client/tmpl_*\nrm -f ./Gosora\nrm -f ./common/gen_extend.go\n\necho \"Building the router generator\"\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\necho \"Running the router generator\"\n./RouterGen\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\necho \"Building the hook generator\"\ngo build -tags hookgen -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\necho \"Running the hook generator\"\n./HookGen\n\necho \"Generating the JSON handlers\"\neasyjson -pkg common\n\necho \"Building the query generator\"\ngo build -ldflags=\"-s -w\" -o QueryGen \"./cmd/query_gen\"\necho \"Running the query generator\"\n./QueryGen\n\necho \"Building Gosora\"\ngo generate\ngo build -ldflags=\"-s -w\" -o Gosora -tags no_ws\n\necho \"Building the installer\"\ngo build -ldflags=\"-s -w\" -o Installer \"./install\""
  },
  {
    "path": "build-nowebsockets.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe -tags no_ws\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the installer\ngo build -ldflags=\"-s -w\" \"./cmd/install\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Gosora was successfully built\npause"
  },
  {
    "path": "build.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the installer\ngo build -ldflags=\"-s -w\" \"./cmd/install\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Gosora was successfully built\npause"
  },
  {
    "path": "build_templates.bat",
    "content": "echo Building the templates\ngosora.exe -build-templates\n\necho Rebuilding the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\npause"
  },
  {
    "path": "cmd/common_hook_gen/hookgen.go",
    "content": "package hookgen\n\nimport (\n\t\"bytes\"\n\t\"log\"\n\t\"os\"\n\t\"text/template\"\n)\n\ntype HookVars struct {\n\tImports []string\n\tHooks   []Hook\n}\n\ntype Hook struct {\n\tName       string\n\tParams     string\n\tParams2    string\n\tRet        string\n\tType       string\n\tAny        bool\n\tMultiHook  bool\n\tSkip       bool\n\tDefaultRet string\n\tPure       string\n}\n\nfunc AddHooks(add func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string)) {\n\tvhookskip := func(name, params string) {\n\t\tadd(name, params, \"(bool,RouteError)\", \"VhookSkippable_\", false, true, \"false,nil\", \"\")\n\t}\n\tvhookskip(\"simple_forum_check_pre_perms\", \"w http.ResponseWriter,r *http.Request,u *User,fid *int,h *HeaderLite\")\n\tvhookskip(\"forum_check_pre_perms\", \"w http.ResponseWriter,r *http.Request,u *User,fid *int,h *Header\")\n\tvhookskip(\"router_after_filters\", \"w http.ResponseWriter,r *http.Request,prefix string\")\n\tvhookskip(\"router_pre_route\", \"w http.ResponseWriter,r *http.Request,u *User,prefix string\")\n\tvhookskip(\"route_forum_list_start\", \"w http.ResponseWriter,r *http.Request,u *User,h *Header\")\n\tvhookskip(\"route_topic_list_start\", \"w http.ResponseWriter,r *http.Request,u *User,h *Header\")\n\tvhookskip(\"route_attach_start\", \"w http.ResponseWriter,r *http.Request,u *User,fname string\")\n\tvhookskip(\"route_attach_post_get\", \"w http.ResponseWriter,r *http.Request,u *User,a *Attachment\")\n\n\tvhooknoret := func(name, params string) {\n\t\tadd(name, params, \"\", \"Vhooks\", false, false, \"false,nil\", \"\")\n\t}\n\tvhooknoret(\"router_end\", \"w http.ResponseWriter,r *http.Request,u *User,prefix string,extraData string\")\n\tvhooknoret(\"topic_reply_row_assign\", \"r *ReplyUser\")\n\tvhooknoret(\"counters_perf_tick_row\", \"low int64,high int64,avg int64\")\n\t//forums_frow_assign\n\t//Hook(name string, data interface{}) interface{}\n\t/*hook := func(name, params, ret, pure string) {\n\t\tadd(name,params,ret,\"Hooks\",true,false,ret,pure)\n\t}*/\n\n\thooknoret := func(name, params string) {\n\t\tadd(name, params, \"\", \"HooksNoRet\", true, false, \"\", \"\")\n\t}\n\thooknoret(\"forums_frow_assign\", \"f *Forum\")\n\n\thookskip := func(name, params string) {\n\t\tadd(name, params, \"(skip bool)\", \"HooksSkip\", true, true, \"\", \"\")\n\t}\n\t//hookskip(\"forums_frow_assign\",\"f *Forum\")\n\thookskip(\"topic_create_frow_assign\", \"f *Forum\")\n\n\thookss := func(name string) {\n\t\tadd(name, \"d string\", \"string\", \"Sshooks\", true, false, \"\", \"d\")\n\t}\n\thookss(\"topic_ogdesc_assign\")\n}\n\nfunc Write(hookVars HookVars) {\n\tfileData := `// Code generated by Gosora's Hook Generator. DO NOT EDIT.\n/* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */\npackage common\nimport ({{range .Imports}}\n\t\"{{.}}\"{{end}}\n)\n{{range .Hooks}}\nfunc H_{{.Name}}_hook(t *HookTable,{{.Params}}) {{.Ret}} { {{if .Any}}\n\t{{if .MultiHook}}for _, hook := range t.{{.Type}}[\"{{.Name}}\"] {\n\t\t{{if .Skip}}if skip = hook({{.Params2}}); skip {\n\t\t\tbreak\n\t\t}{{else}}{{if .Pure}}{{.Pure}} = {{else if .Ret}}return {{end}}hook({{.Params2}}){{end}}\n\t}{{else}}hook := t.{{.Type}}[\"{{.Name}}\"]\n\tif hook != nil {\n\t\t{{if .Ret}}return {{end}}hook({{.Params2}})\n\t} {{end}}{{end}}{{if .Pure}}\n\treturn {{.Pure}}{{else if .Ret}}\n\treturn {{.DefaultRet}}{{end}}\n}{{end}}\n`\n\ttmpl := template.Must(template.New(\"hooks\").Parse(fileData))\n\tvar b bytes.Buffer\n\tif e := tmpl.Execute(&b, hookVars); e != nil {\n\t\tlog.Fatal(e)\n\t}\n\n\terr := writeFile(\"./common/gen_extend.go\", b.String())\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n\nfunc writeFile(name, body string) error {\n\tf, e := os.Create(name)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif _, e = f.WriteString(body); e != nil {\n\t\treturn e\n\t}\n\tif e = f.Sync(); e != nil {\n\t\treturn e\n\t}\n\treturn f.Close()\n}\n"
  },
  {
    "path": "cmd/elasticsearch/setup.go",
    "content": "// Work in progress\npackage main\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"log\"\n\t\"os\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/query_gen\"\n\t\"gopkg.in/olivere/elastic.v6\"\n)\n\nfunc main() {\n\tlog.Print(\"Loading the configuration data\")\n\terr := c.LoadConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tlog.Print(\"Processing configuration data\")\n\terr = c.ProcessConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif c.DbConfig.Adapter != \"mysql\" && c.DbConfig.Adapter != \"\" {\n\t\tlog.Fatal(\"Only MySQL is supported for upgrades right now, please wait for a newer build of the patcher\")\n\t}\n\n\terr = prepMySQL()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tclient, err := elastic.NewClient(elastic.SetErrorLog(log.New(os.Stdout, \"ES \", log.LstdFlags)))\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\t_, _, err = client.Ping(\"http://127.0.0.1:9200\").Do(context.Background())\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\terr = setupIndices(client)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\terr = setupData(client)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n\nfunc prepMySQL() error {\n\treturn qgen.Builder.Init(\"mysql\", map[string]string{\n\t\t\"host\":      c.DbConfig.Host,\n\t\t\"port\":      c.DbConfig.Port,\n\t\t\"name\":      c.DbConfig.Dbname,\n\t\t\"username\":  c.DbConfig.Username,\n\t\t\"password\":  c.DbConfig.Password,\n\t\t\"collation\": \"utf8mb4_general_ci\",\n\t})\n}\n\ntype ESIndexBase struct {\n\tMappings ESIndexMappings `json:\"mappings\"`\n}\n\ntype ESIndexMappings struct {\n\tDoc ESIndexDoc `json:\"_doc\"`\n}\n\ntype ESIndexDoc struct {\n\tProperties map[string]map[string]string `json:\"properties\"`\n}\n\ntype ESDocMap map[string]map[string]string\n\nfunc (d ESDocMap) Add(column string, cType string) {\n\td[\"column\"] = map[string]string{\"type\": cType}\n}\n\nfunc setupIndices(client *elastic.Client) error {\n\texists, err := client.IndexExists(\"topics\").Do(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\tif exists {\n\t\tdeleteIndex, err := client.DeleteIndex(\"topics\").Do(context.Background())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !deleteIndex.Acknowledged {\n\t\t\treturn errors.New(\"delete not acknowledged\")\n\t\t}\n\t}\n\n\tdocMap := make(ESDocMap)\n\tdocMap.Add(\"tid\", \"integer\")\n\tdocMap.Add(\"title\", \"text\")\n\tdocMap.Add(\"content\", \"text\")\n\tdocMap.Add(\"createdBy\", \"integer\")\n\tdocMap.Add(\"ip\", \"ip\")\n\tdocMap.Add(\"suggest\", \"completion\")\n\tindexBase := ESIndexBase{ESIndexMappings{ESIndexDoc{docMap}}}\n\toBytes, err := json.Marshal(indexBase)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcreateIndex, err := client.CreateIndex(\"topics\").Body(string(oBytes)).Do(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !createIndex.Acknowledged {\n\t\treturn errors.New(\"not acknowledged\")\n\t}\n\n\texists, err = client.IndexExists(\"replies\").Do(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\tif exists {\n\t\tdeleteIndex, err := client.DeleteIndex(\"replies\").Do(context.Background())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !deleteIndex.Acknowledged {\n\t\t\treturn errors.New(\"delete not acknowledged\")\n\t\t}\n\t}\n\n\tdocMap = make(ESDocMap)\n\tdocMap.Add(\"rid\", \"integer\")\n\tdocMap.Add(\"tid\", \"integer\")\n\tdocMap.Add(\"content\", \"text\")\n\tdocMap.Add(\"createdBy\", \"integer\")\n\tdocMap.Add(\"ip\", \"ip\")\n\tdocMap.Add(\"suggest\", \"completion\")\n\tindexBase = ESIndexBase{ESIndexMappings{ESIndexDoc{docMap}}}\n\toBytes, err = json.Marshal(indexBase)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcreateIndex, err = client.CreateIndex(\"replies\").Body(string(oBytes)).Do(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !createIndex.Acknowledged {\n\t\treturn errors.New(\"not acknowledged\")\n\t}\n\n\treturn nil\n}\n\ntype ESTopic struct {\n\tID        int    `json:\"tid\"`\n\tTitle     string `json:\"title\"`\n\tContent   string `json:\"content\"`\n\tCreatedBy int    `json:\"createdBy\"`\n\tIP string `json:\"ip\"`\n}\n\ntype ESReply struct {\n\tID        int    `json:\"rid\"`\n\tTID       int    `json:\"tid\"`\n\tContent   string `json:\"content\"`\n\tCreatedBy int    `json:\"createdBy\"`\n\tIP string `json:\"ip\"`\n}\n\nfunc setupData(client *elastic.Client) error {\n\ttcount := 4\n\terrs := make(chan error)\n\n\tgo func() {\n\t\ttin := make([]chan ESTopic, tcount)\n\t\ttf := func(tin chan ESTopic) {\n\t\t\tfor {\n\t\t\t\ttopic, more := <-tin\n\t\t\t\tif !more {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\t_, err := client.Index().Index(\"topics\").Type(\"_doc\").Id(strconv.Itoa(topic.ID)).BodyJson(topic).Do(context.Background())\n\t\t\t\tif err != nil {\n\t\t\t\t\terrs <- err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor i := 0; i < 4; i++ {\n\t\t\tgo tf(tin[i])\n\t\t}\n\n\t\toi := 0\n\t\terr := qgen.NewAcc().Select(\"topics\").Cols(\"tid,title,content,createdBy,ip\").Each(func(rows *sql.Rows) error {\n\t\t\tt := ESTopic{}\n\t\t\terr := rows.Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.IP)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ttin[oi] <- t\n\t\t\tif oi < 3 {\n\t\t\t\toi++\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\tclose(tin[i])\n\t\t}\n\t\terrs <- err\n\t}()\n\n\tgo func() {\n\t\trin := make([]chan ESReply, tcount)\n\t\trf := func(rin chan ESReply) {\n\t\t\tfor {\n\t\t\t\treply, more := <-rin\n\t\t\t\tif !more {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\t_, err := client.Index().Index(\"replies\").Type(\"_doc\").Id(strconv.Itoa(reply.ID)).BodyJson(reply).Do(context.Background())\n\t\t\t\tif err != nil {\n\t\t\t\t\terrs <- err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor i := 0; i < 4; i++ {\n\t\t\trf(rin[i])\n\t\t}\n\t\toi := 0\n\t\terr := qgen.NewAcc().Select(\"replies\").Cols(\"rid,tid,content,createdBy,ip\").Each(func(rows *sql.Rows) error {\n\t\t\tr := ESReply{}\n\t\t\terr := rows.Scan(&r.ID, &r.TID, &r.Content, &r.CreatedBy, &r.IP)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\trin[oi] <- r\n\t\t\tif oi < 3 {\n\t\t\t\toi++\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\tclose(rin[i])\n\t\t}\n\t\terrs <- err\n\t}()\n\n\tfin := 0\n\tfor {\n\t\terr := <-errs\n\t\tif err == nil {\n\t\t\tfin++\n\t\t\tif fin == 2 {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/hook_gen/main.go",
    "content": "// +build hookgen\n\npackage main // import \"github.com/Azareal/Gosora/hook_gen\"\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"runtime/debug\"\n\t\"strings\"\n\n\th \"github.com/Azareal/Gosora/cmd/common_hook_gen\"\n\tc \"github.com/Azareal/Gosora/common\"\n\t_ \"github.com/Azareal/Gosora/extend\"\n)\n\n// TODO: Make sure all the errors in this file propagate upwards properly\nfunc main() {\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t}\n\t}()\n\n\thooks := make(map[string]int)\n\tfor _, pl := range c.Plugins {\n\t\tif len(pl.Meta.Hooks) > 0 {\n\t\t\tfor _, hook := range pl.Meta.Hooks {\n\t\t\t\thooks[hook]++\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif pl.Init != nil {\n\t\t\tif e := pl.Init(pl); e != nil {\n\t\t\t\tlog.Print(\"early plugin init err: \", e)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif pl.Hooks != nil {\n\t\t\tlog.Print(\"Hooks not nil for \", pl.UName)\n\t\t\tfor hook, _ := range pl.Hooks {\n\t\t\t\thooks[hook] += 1\n\t\t\t}\n\t\t}\n\t}\n\tlog.Printf(\"hooks: %+v\\n\", hooks)\n\n\timports := []string{\"net/http\"}\n\thookVars := h.HookVars{imports, nil}\n\tvar params2sb strings.Builder\n\tadd := func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string) {\n\t\tfirst := true\n\t\tfor _, param := range strings.Split(params, \",\") {\n\t\t\tif !first {\n\t\t\t\tparams2sb.WriteRune(',')\n\t\t\t}\n\t\t\tpspl := strings.Split(strings.ReplaceAll(strings.TrimSpace(param), \"  \", \" \"), \" \")\n\t\t\tparams2sb.WriteString(pspl[0])\n\t\t\tfirst = false\n\t\t}\n\t\thookVars.Hooks = append(hookVars.Hooks, h.Hook{name, params, params2sb.String(), ret, htype, hooks[name] > 0, multiHook, skip, defaultRet, pure})\n\t\tparams2sb.Reset()\n\t}\n\n\th.AddHooks(add)\n\th.Write(hookVars)\n\tlog.Println(\"Successfully generated the hooks\")\n}\n"
  },
  {
    "path": "cmd/hook_stub_gen/main.go",
    "content": "package main // import \"github.com/Azareal/Gosora/hook_stub_gen\"\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"runtime/debug\"\n\t\n\th \"github.com/Azareal/Gosora/cmd/common_hook_gen\"\n)\n\n// TODO: Make sure all the errors in this file propagate upwards properly\nfunc main() {\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t}\n\t}()\n\t\n\timports := []string{\"net/http\"}\n\thookVars := h.HookVars{imports,nil}\n\tadd := func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string) {\n\t\tvar params2 string\n\t\tfirst := true\n\t\tfor _, param := range strings.Split(params,\",\") {\n\t\t\tif !first {\n\t\t\t\tparams2 += \",\"\n\t\t\t}\n\t\t\tpspl := strings.Split(strings.ReplaceAll(strings.TrimSpace(param),\"  \",\" \"),\" \")\n\t\t\tparams2 += pspl[0]\n\t\t\tfirst = false\n\t\t}\n\t\thookVars.Hooks = append(hookVars.Hooks, h.Hook{name, params, params2, ret, htype, true, multiHook, skip, defaultRet,pure})\n\t}\n\n\th.AddHooks(add)\n\th.Write(hookVars)\n\tlog.Println(\"Successfully generated the hooks\")\n}"
  },
  {
    "path": "cmd/install/install.go",
    "content": "/*\n*\n* Gosora Installer\n* Copyright Azareal 2017 - 2019\n*\n */\npackage main\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/install\"\n)\n\nvar scanner *bufio.Scanner\n\nvar siteShortName string\nvar siteName string\nvar siteURL string\nvar serverPort string\n\nvar defaultAdapter = \"mysql\"\nvar defaultHost = \"localhost\"\nvar defaultUsername = \"root\"\nvar defaultDbname = \"gosora\"\nvar defaultSiteShortName = \"SN\"\nvar defaultSiteName = \"Site Name\"\nvar defaultsiteURL = \"localhost\"\nvar defaultServerPort = \"80\" // 8080's a good one, if you're testing and don't want it to clash with port 80\n\nfunc main() {\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t\tpressAnyKey()\n\t\t\treturn\n\t\t}\n\t}()\n\n\tscanner = bufio.NewScanner(os.Stdin)\n\tfmt.Println(\"Welcome to Gosora's Installer\")\n\tfmt.Println(\"We're going to take you through a few steps to help you get started :)\")\n\tadap, ok := handleDatabaseDetails()\n\tif !ok {\n\t\terr := scanner.Err()\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t} else {\n\t\t\terr = errors.New(\"Something went wrong!\")\n\t\t}\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\tif !getSiteDetails() {\n\t\terr := scanner.Err()\n\t\tif err != nil {\n\t\t\tfmt.Println(err)\n\t\t} else {\n\t\t\terr = errors.New(\"Something went wrong!\")\n\t\t}\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\terr := adap.InitDatabase()\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\terr = adap.TableDefs()\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\terr = adap.CreateAdmin()\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\terr = adap.InitialData()\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\tconfigContents := []byte(`{\n\t\"Site\": {\n\t\t\"ShortName\":\"` + siteShortName + `\",\n\t\t\"Name\":\"` + siteName + `\",\n\t\t\"URL\":\"` + siteURL + `\",\n\t\t\"Port\":\"` + serverPort + `\",\n\t\t\"EnableSsl\":false,\n\t\t\"EnableEmails\":false,\n\t\t\"HasProxy\":false,\n\t\t\"Language\": \"english\"\n\t},\n\t\"Config\": {\n\t\t\"SslPrivkey\": \"\",\n\t\t\"SslFullchain\": \"\",\n\t\t\"SMTPServer\": \"\",\n\t\t\"SMTPUsername\": \"\",\n\t\t\"SMTPPassword\": \"\",\n\t\t\"SMTPPort\": \"25\",\n\n\t\t\"MaxRequestSizeStr\":\"5MB\",\n\t\t\"UserCache\":\"static\",\n\t\t\"TopicCache\":\"static\",\n\t\t\"ReplyCache\":\"static\",\n\t\t\"UserCacheCapacity\":180,\n\t\t\"TopicCacheCapacity\":400,\n\t\t\"ReplyCacheCapacity\":20,\n\t\t\"DefaultPath\":\"/topics/\",\n\t\t\"DefaultGroup\":3,\n\t\t\"ActivationGroup\":5,\n\t\t\"StaffCSS\":\"staff_post\",\n\t\t\"DefaultForum\":2,\n\t\t\"MinifyTemplates\":true,\n\t\t\"BuildSlugs\":true,\n\t\t\"ServerCount\":1,\n\t\t\"Noavatar\":\"https://api.adorable.io/avatars/{width}/{id}.png\",\n\t\t\"ItemsPerPage\":25\n\t},\n\t\"Database\": {\n\t\t\"Adapter\": \"` + adap.Name() + `\",\n\t\t\"Host\": \"` + adap.DBHost() + `\",\n\t\t\"Username\": \"` + adap.DBUsername() + `\",\n\t\t\"Password\": \"` + adap.DBPassword() + `\",\n\t\t\"Dbname\": \"` + adap.DBName() + `\",\n\t\t\"Port\": \"` + adap.DBPort() + `\",\n\n\t\t\"TestAdapter\": \"` + adap.Name() + `\",\n\t\t\"TestHost\": \"\",\n\t\t\"TestUsername\": \"\",\n\t\t\"TestPassword\": \"\",\n\t\t\"TestDbname\": \"\",\n\t\t\"TestPort\": \"\"\n\t},\n\t\"Dev\": {\n\t\t\"DebugMode\":true,\n\t\t\"SuperDebug\":false\n\t}\n}`)\n\n\tfmt.Println(\"Opening the configuration file\")\n\tconfigFile, err := os.Create(\"./config/config.json\")\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\tfmt.Println(\"Writing to the configuration file...\")\n\t_, err = configFile.Write(configContents)\n\tif err != nil {\n\t\tabortError(err)\n\t\treturn\n\t}\n\n\tconfigFile.Sync()\n\tconfigFile.Close()\n\tfmt.Println(\"Finished writing to the configuration file\")\n\n\tfmt.Println(\"Yay, you have successfully installed Gosora!\")\n\tfmt.Println(\"Your name is Admin and you can login with the password 'password'. Don't forget to change it! Seriously. It's really insecure.\")\n\tpressAnyKey()\n}\n\nfunc abortError(err error) {\n\tfmt.Println(err)\n\tfmt.Println(\"Aborting installation...\")\n\tpressAnyKey()\n}\n\nfunc handleDatabaseDetails() (adap install.InstallAdapter, ok bool) {\n\tvar dbAdapter string\n\tvar dbHost string\n\tvar dbUsername string\n\tvar dbPassword string\n\tvar dbName string\n\t// TODO: Let the admin set the database port?\n\t//var dbPort string\n\n\tfor {\n\t\tfmt.Println(\"Which database adapter do you wish to use? mysql or mssql? Default: mysql\")\n\t\tif !scanner.Scan() {\n\t\t\treturn nil, false\n\t\t}\n\t\tdbAdapter := strings.TrimSpace(scanner.Text())\n\t\tif dbAdapter == \"\" {\n\t\t\tdbAdapter = defaultAdapter\n\t\t}\n\t\tadap, ok = install.Lookup(dbAdapter)\n\t\tif ok {\n\t\t\tbreak\n\t\t}\n\t\tfmt.Println(\"That adapter doesn't exist\")\n\t}\n\tfmt.Println(\"Set database adapter to \" + dbAdapter)\n\n\tfmt.Println(\"Database Host? Default: \" + defaultHost)\n\tif !scanner.Scan() {\n\t\treturn nil, false\n\t}\n\tdbHost = scanner.Text()\n\tif dbHost == \"\" {\n\t\tdbHost = defaultHost\n\t}\n\tfmt.Println(\"Set database host to \" + dbHost)\n\n\tfmt.Println(\"Database Username? Default: \" + defaultUsername)\n\tif !scanner.Scan() {\n\t\treturn nil, false\n\t}\n\tdbUsername = scanner.Text()\n\tif dbUsername == \"\" {\n\t\tdbUsername = defaultUsername\n\t}\n\tfmt.Println(\"Set database username to \" + dbUsername)\n\n\tfmt.Println(\"Database Password? Default: ''\")\n\tif !scanner.Scan() {\n\t\treturn nil, false\n\t}\n\tdbPassword = scanner.Text()\n\tif len(dbPassword) == 0 {\n\t\tfmt.Println(\"You didn't set a password for this user. This won't block the installation process, but it might create security issues in the future.\")\n\t\tfmt.Println(\"\")\n\t} else {\n\t\tfmt.Println(\"Set password to \" + obfuscatePassword(dbPassword))\n\t}\n\n\tfmt.Println(\"Database Name? Pick a name you like or one provided to you. Default: \" + defaultDbname)\n\tif !scanner.Scan() {\n\t\treturn nil, false\n\t}\n\tdbName = scanner.Text()\n\tif dbName == \"\" {\n\t\tdbName = defaultDbname\n\t}\n\tfmt.Println(\"Set database name to \" + dbName)\n\n\tadap.SetConfig(dbHost, dbUsername, dbPassword, dbName, adap.DefaultPort())\n\treturn adap, true\n}\n\nfunc getSiteDetails() bool {\n\tfmt.Println(\"Okay. We also need to know some actual information about your site!\")\n\tfmt.Println(\"What's your site's name? Default: \" + defaultSiteName)\n\tif !scanner.Scan() {\n\t\treturn false\n\t}\n\tsiteName = scanner.Text()\n\tif siteName == \"\" {\n\t\tsiteName = defaultSiteName\n\t}\n\tfmt.Println(\"Set the site name to \" + siteName)\n\n\t// ? - We could compute this based on the first letter of each word in the site's name, if it's name spans multiple words. I'm not sure how to do this for single word names.\n\tfmt.Println(\"Can we have a short abbreviation for your site? Default: \" + defaultSiteShortName)\n\tif !scanner.Scan() {\n\t\treturn false\n\t}\n\tsiteShortName = scanner.Text()\n\tif siteShortName == \"\" {\n\t\tsiteShortName = defaultSiteShortName\n\t}\n\tfmt.Println(\"Set the short name to \" + siteShortName)\n\n\tfmt.Println(\"What's your site's url? Default: \" + defaultsiteURL)\n\tif !scanner.Scan() {\n\t\treturn false\n\t}\n\tsiteURL = scanner.Text()\n\tif siteURL == \"\" {\n\t\tsiteURL = defaultsiteURL\n\t}\n\tfmt.Println(\"Set the site url to \" + siteURL)\n\n\tfmt.Println(\"What port do you want the server to listen on? If you don't know what this means, you should probably leave it on the default. Default: \" + defaultServerPort)\n\tif !scanner.Scan() {\n\t\treturn false\n\t}\n\tserverPort = scanner.Text()\n\tif serverPort == \"\" {\n\t\tserverPort = defaultServerPort\n\t}\n\t_, err := strconv.Atoi(serverPort)\n\tif err != nil {\n\t\tfmt.Println(\"That's not a valid number!\")\n\t\treturn false\n\t}\n\tfmt.Println(\"Set the server port to \" + serverPort)\n\treturn true\n}\n\nfunc obfuscatePassword(password string) (out string) {\n\tfor i := 0; i < len(password); i++ {\n\t\tout += \"*\"\n\t}\n\treturn out\n}\n\nfunc pressAnyKey() {\n\t//fmt.Println(\"Press any key to exit...\")\n\tfmt.Println(\"Please press enter to exit...\")\n\tfor scanner.Scan() {\n\t\t_ = scanner.Text()\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "cmd/query_gen/build.bat",
    "content": "@echo off\necho Building the query generator\ngo build -ldflags=\"-s -w\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho The query generator was successfully built\npause"
  },
  {
    "path": "cmd/query_gen/main.go",
    "content": "/* WIP Under Construction */\npackage main // import \"github.com/Azareal/Gosora/query_gen\"\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Make sure all the errors in this file propagate upwards properly\nfunc main() {\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t\treturn\n\t\t}\n\t}()\n\n\tlog.Println(\"Running the query generator\")\n\tfor _, a := range qgen.Registry {\n\t\tlog.Printf(\"Building the queries for the %s adapter\", a.GetName())\n\t\tqgen.Install.SetAdapterInstance(a)\n\t\tqgen.Install.AddPlugins(NewPrimaryKeySpitter()) // TODO: Do we really need to fill the spitter for every adapter?\n\n\t\te := writeStatements(a)\n\t\tif e != nil {\n\t\t\tlog.Print(e)\n\t\t}\n\t\te = qgen.Install.Write()\n\t\tif e != nil {\n\t\t\tlog.Print(e)\n\t\t}\n\t\te = a.Write()\n\t\tif e != nil {\n\t\t\tlog.Print(e)\n\t\t}\n\t}\n}\n\n// nolint\nfunc writeStatements(a qgen.Adapter) (err error) {\n\te := func(f func(qgen.Adapter) error) {\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = f(a)\n\t}\n\te(createTables)\n\te(seedTables)\n\te(writeSelects)\n\te(writeLeftJoins)\n\te(writeInnerJoins)\n\te(writeInserts)\n\te(writeUpdates)\n\te(writeDeletes)\n\te(writeSimpleCounts)\n\te(writeInsertSelects)\n\te(writeInsertLeftJoins)\n\te(writeInsertInnerJoins)\n\treturn err\n}\n\ntype si = map[string]interface{}\ntype tK = tblKey\n\nfunc seedTables(a qgen.Adapter) error {\n\tqgen.Install.AddIndex(\"topics\", \"parentID\", \"parentID\")\n\tqgen.Install.AddIndex(\"replies\", \"tid\", \"tid\")\n\tqgen.Install.AddIndex(\"polls\", \"parentID\", \"parentID\")\n\tqgen.Install.AddIndex(\"likes\", \"targetItem\", \"targetItem\")\n\tqgen.Install.AddIndex(\"emails\", \"uid\", \"uid\")\n\tqgen.Install.AddIndex(\"attachments\", \"originID\", \"originID\")\n\tqgen.Install.AddIndex(\"attachments\", \"path\", \"path\")\n\tqgen.Install.AddIndex(\"activity_stream_matches\", \"watcher\", \"watcher\")\n\t// TODO: Remove these keys to save space when Elasticsearch is active?\n\t//qgen.Install.AddKey(\"topics\", \"title\", tK{\"title\", \"fulltext\", \"\", false})\n\t//qgen.Install.AddKey(\"topics\", \"content\", tK{\"content\", \"fulltext\", \"\", false})\n\t//qgen.Install.AddKey(\"topics\", \"title,content\", tK{\"title,content\", \"fulltext\", \"\", false})\n\t//qgen.Install.AddKey(\"replies\", \"content\", tK{\"content\", \"fulltext\", \"\", false})\n\n\tinsert := func(tbl, cols, vals string) {\n\t\tqgen.Install.SimpleInsert(tbl, cols, vals)\n\t}\n\tinsert(\"sync\", \"last_update\", \"UTC_TIMESTAMP()\")\n\taddSetting := func(name, content, stype string, constraints ...string) {\n\t\tif strings.Contains(name, \"'\") {\n\t\t\tpanic(\"name contains '\")\n\t\t}\n\t\tif strings.Contains(stype, \"'\") {\n\t\t\tpanic(\"stype contains '\")\n\t\t}\n\t\t// TODO: Add more field validators\n\t\tcols := \"name,content,type\"\n\t\tif len(constraints) > 0 {\n\t\t\tcols += \",constraints\"\n\t\t}\n\t\tq := func(s string) string {\n\t\t\treturn \"'\" + s + \"'\"\n\t\t}\n\t\tc := func() string {\n\t\t\tif len(constraints) == 0 {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn \",\" + q(constraints[0])\n\t\t}\n\t\tinsert(\"settings\", cols, q(name)+\",\"+q(content)+\",\"+q(stype)+c())\n\t}\n\taddSetting(\"activation_type\", \"1\", \"list\", \"1-3\")\n\taddSetting(\"bigpost_min_words\", \"250\", \"int\")\n\taddSetting(\"megapost_min_words\", \"1000\", \"int\")\n\taddSetting(\"meta_desc\", \"\", \"html-attribute\")\n\taddSetting(\"rapid_loading\", \"1\", \"bool\")\n\taddSetting(\"google_site_verify\", \"\", \"html-attribute\")\n\taddSetting(\"avatar_visibility\", \"0\", \"list\", \"0-1\")\n\tinsert(\"themes\", \"uname, default\", \"'cosora',1\")\n\tinsert(\"emails\", \"email, uid, validated\", \"'admin@localhost',1,1\") // ? - Use a different default email or let the admin input it during installation?\n\n\t/*\n\t\tThe Permissions:\n\n\t\tGlobal Permissions:\n\t\tBanUsers\n\t\tActivateUsers\n\t\tEditUser\n\t\tEditUserEmail\n\t\tEditUserPassword\n\t\tEditUserGroup\n\t\tEditUserGroupSuperMod\n\t\tEditUserGroupAdmin\n\t\tEditGroup\n\t\tEditGroupLocalPerms\n\t\tEditGroupGlobalPerms\n\t\tEditGroupSuperMod\n\t\tEditGroupAdmin\n\t\tManageForums\n\t\tEditSettings\n\t\tManageThemes\n\t\tManagePlugins\n\t\tViewAdminLogs\n\t\tViewIPs\n\n\t\tNon-staff Global Permissions:\n\t\tUploadFiles\n\t\tUploadAvatars\n\t\tUseConvos\n\t\tUseConvosOnlyWithMod\n\t\tCreateProfileReply\n\t\tAutoEmbed\n\t\tAutoLink\n\t\t// CreateConvo ?\n\t\t// CreateConvoReply ?\n\n\t\tForum Permissions:\n\t\tViewTopic\n\t\tLikeItem\n\t\tCreateTopic\n\t\tEditTopic\n\t\tDeleteTopic\n\t\tCreateReply\n\t\tEditReply\n\t\tDeleteReply\n\t\tPinTopic\n\t\tCloseTopic\n\t\tMoveTopic\n\t*/\n\n\tp := func(perms *c.Perms) string {\n\t\tjBytes, err := json.Marshal(perms)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\treturn string(jBytes)\n\t}\n\taddGroup := func(name string, perms c.Perms, mod, admin, banned bool, tag string) {\n\t\tmi, ai, bi := \"0\", \"0\", \"0\"\n\t\tif mod {\n\t\t\tmi = \"1\"\n\t\t}\n\t\tif admin {\n\t\t\tai = \"1\"\n\t\t}\n\t\tif banned {\n\t\t\tbi = \"1\"\n\t\t}\n\t\tinsert(\"users_groups\", \"name, permissions, plugin_perms, is_mod, is_admin, is_banned, tag\", `'`+name+`','`+p(&perms)+`','{}',`+mi+`,`+ai+`,`+bi+`,\"`+tag+`\"`)\n\t}\n\n\tperms := c.AllPerms\n\tperms.EditUserGroupAdmin = false\n\tperms.EditGroupAdmin = false\n\taddGroup(\"Administrator\", perms, true, true, false, \"Admin\")\n\n\tperms = c.Perms{BanUsers: true, ActivateUsers: true, EditUser: true, EditUserEmail: false, EditUserGroup: true, ViewIPs: true, UploadFiles: true, UploadAvatars: true, UseConvos: true, UseConvosOnlyWithMod: true, CreateProfileReply: true, AutoEmbed: true, AutoLink: true, ViewTopic: true, LikeItem: true, CreateTopic: true, EditTopic: true, DeleteTopic: true, CreateReply: true, EditReply: true, DeleteReply: true, PinTopic: true, CloseTopic: true, MoveTopic: true}\n\taddGroup(\"Moderator\", perms, true, false, false, \"Mod\")\n\n\tperms = c.Perms{UploadFiles: true, UploadAvatars: true, UseConvos: true, UseConvosOnlyWithMod: true, CreateProfileReply: true, AutoEmbed: true, AutoLink: true, ViewTopic: true, LikeItem: true, CreateTopic: true, CreateReply: true}\n\taddGroup(\"Member\", perms, false, false, false, \"\")\n\n\tperms = c.Perms{ViewTopic: true}\n\taddGroup(\"Banned\", perms, false, false, true, \"\")\n\taddGroup(\"Awaiting Activation\", c.Perms{ViewTopic: true, UseConvosOnlyWithMod: true}, false, false, false, \"\")\n\taddGroup(\"Not Loggedin\", perms, false, false, false, \"Guest\")\n\n\t//\n\t// TODO: Stop processFields() from stripping the spaces in the descriptions in the next commit\n\n\tinsert(\"forums\", \"name, active, desc, tmpl\", \"'Reports',0,'All the reports go here',''\")\n\tinsert(\"forums\", \"name, lastTopicID, lastReplyerID, desc, tmpl\", \"'General',1,1,'A place for general discussions which don't fit elsewhere',''\")\n\n\t//\n\n\t/*var addForumPerm = func(gid, fid int, permStr string) {\n\t\tinsert(\"forums_permissions\", \"gid, fid, permissions\", strconv.Itoa(gid)+`,`+strconv.Itoa(fid)+`,'`+permStr+`'`)\n\t}*/\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `1,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"PinTopic\":true,\"CloseTopic\":true}'`)\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `2,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CloseTopic\":true}'`)\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", \"3,1,'{}'\")\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", \"4,1,'{}'\")\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", \"5,1,'{}'\")\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", \"6,1,'{}'\")\n\n\t//\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `1,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}'`)\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `2,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}'`)\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `3,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true}'`)\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `4,2,'{\"ViewTopic\":true}'`)\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `5,2,'{\"ViewTopic\":true}'`)\n\n\tinsert(\"forums_permissions\", \"gid, fid, permissions\", `6,2,'{\"ViewTopic\":true}'`)\n\n\t//\n\n\tinsert(\"topics\", \"title, content, parsed_content, createdAt, lastReplyAt, lastReplyBy, createdBy, parentID, ip\", \"'Test Topic','A topic automatically generated by the software.','A topic automatically generated by the software.',UTC_TIMESTAMP(),UTC_TIMESTAMP(),1,1,2,''\")\n\n\tinsert(\"replies\", \"tid, content, parsed_content, createdAt, createdBy, lastUpdated, lastEdit, lastEditBy, ip\", \"1,'A reply!','A reply!',UTC_TIMESTAMP(),1,UTC_TIMESTAMP(),0,0,''\")\n\n\tinsert(\"menus\", \"\", \"\")\n\n\t// Go maps have a random iteration order, so we have to do this, otherwise the schema files will become unstable and harder to audit\n\torder := 0\n\tmOrder := \"mid, name, htmlID, cssClass, position, path, aria, tooltip, guestOnly, memberOnly, staffOnly, adminOnly\"\n\taddMenuItem := func(data map[string]interface{}) {\n\t\tif data[\"mid\"] == nil {\n\t\t\tdata[\"mid\"] = 1\n\t\t}\n\t\tif data[\"position\"] == nil {\n\t\t\tdata[\"position\"] = \"left\"\n\t\t}\n\t\tcols, values := qgen.InterfaceMapToInsertStrings(data, mOrder)\n\t\tinsert(\"menu_items\", cols+\", order\", values+\",\"+strconv.Itoa(order))\n\t\torder++\n\t}\n\n\taddMenuItem(si{\"name\": \"{lang.menu_forums}\", \"htmlID\": \"menu_forums\", \"path\": \"/forums/\", \"aria\": \"{lang.menu_forums_aria}\", \"tooltip\": \"{lang.menu_forums_tooltip}\"})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_topics}\", \"htmlID\": \"menu_topics\", \"cssClass\": \"menu_topics\", \"path\": \"/topics/\", \"aria\": \"{lang.menu_topics_aria}\", \"tooltip\": \"{lang.menu_topics_tooltip}\"})\n\n\taddMenuItem(si{\"htmlID\": \"general_alerts\", \"cssClass\": \"menu_alerts\", \"position\": \"right\", \"tmplName\": \"menu_alerts\"})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_account}\", \"cssClass\": \"menu_account\", \"path\": \"/user/edit/\", \"aria\": \"{lang.menu_account_aria}\", \"tooltip\": \"{lang.menu_account_tooltip}\", \"memberOnly\": true})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_profile}\", \"cssClass\": \"menu_profile\", \"path\": \"{me.Link}\", \"aria\": \"{lang.menu_profile_aria}\", \"tooltip\": \"{lang.menu_profile_tooltip}\", \"memberOnly\": true})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_panel}\", \"cssClass\": \"menu_panel menu_account\", \"path\": \"/panel/\", \"aria\": \"{lang.menu_panel_aria}\", \"tooltip\": \"{lang.menu_panel_tooltip}\", \"memberOnly\": true, \"staffOnly\": true})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_logout}\", \"cssClass\": \"menu_logout\", \"path\": \"/accounts/logout/?s={me.Session}\", \"aria\": \"{lang.menu_logout_aria}\", \"tooltip\": \"{lang.menu_logout_tooltip}\", \"memberOnly\": true})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_register}\", \"cssClass\": \"menu_register\", \"path\": \"/accounts/create/\", \"aria\": \"{lang.menu_register_aria}\", \"tooltip\": \"{lang.menu_register_tooltip}\", \"guestOnly\": true})\n\n\taddMenuItem(si{\"name\": \"{lang.menu_login}\", \"cssClass\": \"menu_login\", \"path\": \"/accounts/login/\", \"aria\": \"{lang.menu_login_aria}\", \"tooltip\": \"{lang.menu_login_tooltip}\", \"guestOnly\": true})\n\n\t/*var fSet []string\n\tfor _, table := range tables {\n\t\tfSet = append(fSet, \"'\"+table+\"'\")\n\t}\n\tqgen.Install.SimpleBulkInsert(\"tables\", \"name\", fSet)*/\n\n\treturn nil\n}\n\n// ? - What is this for?\n/*func copyInsertMap(in map[string]interface{}) (out map[string]interface{}) {\n\tout = make(map[string]interface{})\n\tfor col, value := range in {\n\t\tout[col] = value\n\t}\n\treturn out\n}*/\n\ntype LitStr string\n\nfunc writeSelects(a qgen.Adapter) error {\n\tb := a.Builder()\n\n\t// Looking for getTopic? Your statement is in another castle\n\n\t//b.Select(\"isPluginInstalled\").Table(\"plugins\").Columns(\"installed\").Where(\"uname = ?\").Parse()\n\n\tb.Select(\"forumEntryExists\").Table(\"forums\").Columns(\"fid\").Where(\"name = ''\").Orderby(\"fid ASC\").Limit(\"0,1\").Parse()\n\n\tb.Select(\"groupEntryExists\").Table(\"users_groups\").Columns(\"gid\").Where(\"name = ''\").Orderby(\"gid ASC\").Limit(\"0,1\").Parse()\n\n\treturn nil\n}\n\nfunc writeLeftJoins(a qgen.Adapter) error {\n\ta.SimpleLeftJoin(\"getForumTopics\", \"topics\", \"users\", \"topics.tid, topics.title, topics.content, topics.createdBy, topics.is_closed, topics.sticky, topics.createdAt, topics.lastReplyAt, topics.parentID, users.name, users.avatar\", \"topics.createdBy = users.uid\", \"topics.parentID = ?\", \"topics.sticky DESC, topics.lastReplyAt DESC, topics.createdBy desc\", \"\")\n\n\treturn nil\n}\n\nfunc writeInnerJoins(a qgen.Adapter) (err error) {\n\treturn nil\n}\n\nfunc writeInserts(a qgen.Adapter) error {\n\tb := a.Builder()\n\n\tb.Insert(\"addForumPermsToForum\").Table(\"forums_permissions\").Columns(\"gid,fid,preset,permissions\").Fields(\"?,?,?,?\").Parse()\n\n\treturn nil\n}\n\nfunc writeUpdates(a qgen.Adapter) error {\n\tb := a.Builder()\n\n\tb.Update(\"updateEmail\").Table(\"emails\").Set(\"email = ?, uid = ?, validated = ?, token = ?\").Where(\"email = ?\").Parse()\n\n\tb.Update(\"setTempGroup\").Table(\"users\").Set(\"temp_group = ?\").Where(\"uid = ?\").Parse()\n\n\tb.Update(\"bumpSync\").Table(\"sync\").Set(\"last_update = UTC_TIMESTAMP()\").Parse()\n\n\treturn nil\n}\n\nfunc writeDeletes(a qgen.Adapter) error {\n\tb := a.Builder()\n\n\t//b.Delete(\"deleteForumPermsByForum\").Table(\"forums_permissions\").Where(\"fid=?\").Parse()\n\n\tb.Delete(\"deleteActivityStreamMatch\").Table(\"activity_stream_matches\").Where(\"watcher=? AND asid=?\").Parse()\n\t//b.Delete(\"deleteActivityStreamMatchesByWatcher\").Table(\"activity_stream_matches\").Where(\"watcher=?\").Parse()\n\n\treturn nil\n}\n\nfunc writeSimpleCounts(a qgen.Adapter) error {\n\treturn nil\n}\n\nfunc writeInsertSelects(a qgen.Adapter) error {\n\t/*a.SimpleInsertSelect(\"addForumPermsToForumAdmins\",\n\t\tqgen.DB_Insert{\"forums_permissions\", \"gid, fid, preset, permissions\", \"\"},\n\t\tqgen.DB_Select{\"users_groups\", \"gid, ? AS fid, ? AS preset, ? AS permissions\", \"is_admin = 1\", \"\", \"\"},\n\t)*/\n\n\t/*a.SimpleInsertSelect(\"addForumPermsToForumStaff\",\n\t\tqgen.DB_Insert{\"forums_permissions\", \"gid, fid, preset, permissions\", \"\"},\n\t\tqgen.DB_Select{\"users_groups\", \"gid, ? AS fid, ? AS preset, ? AS permissions\", \"is_admin = 0 AND is_mod = 1\", \"\", \"\"},\n\t)*/\n\n\t/*a.SimpleInsertSelect(\"addForumPermsToForumMembers\",\n\t\tqgen.DB_Insert{\"forums_permissions\", \"gid, fid, preset, permissions\", \"\"},\n\t\tqgen.DB_Select{\"users_groups\", \"gid, ? AS fid, ? AS preset, ? AS permissions\", \"is_admin = 0 AND is_mod = 0 AND is_banned = 0\", \"\", \"\"},\n\t)*/\n\n\treturn nil\n}\n\n// nolint\nfunc writeInsertLeftJoins(a qgen.Adapter) error {\n\treturn nil\n}\n\nfunc writeInsertInnerJoins(a qgen.Adapter) error {\n\treturn nil\n}\n\nfunc writeFile(name, content string) (err error) {\n\tf, err := os.Create(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.WriteString(content)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = f.Sync()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn f.Close()\n}\n"
  },
  {
    "path": "cmd/query_gen/run.bat",
    "content": "@echo off\necho Building the query generator\ngo build -ldflags=\"-s -w\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho The query generator was successfully built\nquery_gen.exe\npause"
  },
  {
    "path": "cmd/query_gen/spitter.go",
    "content": "package main\n\nimport \"strings\"\nimport \"github.com/Azareal/Gosora/query_gen\"\n\ntype PrimaryKeySpitter struct {\n\tkeys map[string]string\n}\n\nfunc NewPrimaryKeySpitter() *PrimaryKeySpitter {\n\treturn &PrimaryKeySpitter{make(map[string]string)}\n}\n\nfunc (spit *PrimaryKeySpitter) Hook(name string, args ...interface{}) error {\n\tif name == \"CreateTableStart\" {\n\t\tvar found string\n\t\tvar table = args[0].(*qgen.DBInstallTable)\n\t\tfor _, key := range table.Keys {\n\t\t\tif key.Type == \"primary\" {\n\t\t\t\texpl := strings.Split(key.Columns, \",\")\n\t\t\t\tif len(expl) > 1 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfound = key.Columns\n\t\t\t}\n\t\t\tif found != \"\" {\n\t\t\t\ttable := table.Name\n\t\t\t\tspit.keys[table] = found\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (spit *PrimaryKeySpitter) Write() error {\n\tout := `// Generated by Gosora's Query Generator. DO NOT EDIT.\npackage main\n\nvar dbTablePrimaryKeys = map[string]string{\n`\n\tfor table, key := range spit.keys {\n\t\tout += \"\\t\\\"\" + table + \"\\\":\\\"\" + key + \"\\\",\\n\"\n\t}\n\treturn writeFile(\"./gen_tables.go\", out+\"}\\n\")\n}\n"
  },
  {
    "path": "cmd/query_gen/tables.go",
    "content": "package main\n\nimport qgen \"github.com/Azareal/Gosora/query_gen\"\n\nvar mysqlPre = \"utf8mb4\"\nvar mysqlCol = \"utf8mb4_general_ci\"\n\nvar tables []string\n\ntype tblColumn = qgen.DBTableColumn\ntype tC = tblColumn\ntype tblKey = qgen.DBTableKey\n\nfunc createTables(a qgen.Adapter) error {\n\ttables = nil\n\tf := func(table, charset, collation string, cols []tC, keys []tblKey) error {\n\t\ttables = append(tables, table)\n\t\treturn qgen.Install.CreateTable(table, charset, collation, cols, keys)\n\t}\n\treturn createTables2(a, f)\n}\n\nfunc createTables2(a qgen.Adapter, f func(table, charset, collation string, columns []tC, keys []tblKey) error) (err error) {\n\tcreateTable := func(table, charset, collation string, cols []tC, keys []tblKey) {\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = f(table, charset, collation, cols, keys)\n\t}\n\tbcol := func(col string, val bool) qgen.DBTableColumn {\n\t\tif val {\n\t\t\treturn tC{col, \"boolean\", 0, false, false, \"1\"}\n\t\t}\n\t\treturn tC{col, \"boolean\", 0, false, false, \"0\"}\n\t}\n\tccol := func(col string, size int, sdefault string) qgen.DBTableColumn {\n\t\treturn tC{col, \"varchar\", size, false, false, sdefault}\n\t}\n\ttext := func(params ...string) qgen.DBTableColumn {\n\t\tif len(params) == 0 {\n\t\t\treturn tC{\"\", \"text\", 0, false, false, \"\"}\n\t\t}\n\t\tcol, sdefault := params[0], \"\"\n\t\tif len(params) > 1 {\n\t\t\tsdefault = params[1]\n\t\t\tif sdefault == \"\" {\n\t\t\t\tsdefault = \"''\"\n\t\t\t}\n\t\t}\n\t\treturn tC{col, \"text\", 0, false, false, sdefault}\n\t}\n\tcreatedAt := func(coll ...string) qgen.DBTableColumn {\n\t\tvar col string\n\t\tif len(coll) > 0 {\n\t\t\tcol = coll[0]\n\t\t}\n\t\tif col == \"\" {\n\t\t\tcol = \"createdAt\"\n\t\t}\n\t\treturn tC{col, \"createdAt\", 0, false, false, \"\"}\n\t}\n\n\tcreateTable(\"users\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 100, \"\"),\n\t\t\tccol(\"password\", 100, \"\"),\n\n\t\t\tccol(\"salt\", 80, \"''\"),\n\t\t\t{\"group\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tbcol(\"active\", false),\n\t\t\tbcol(\"is_super_admin\", false),\n\t\t\tcreatedAt(),\n\t\t\t{\"lastActiveAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"session\", 200, \"''\"),\n\t\t\t//ccol(\"authToken\", 200, \"''\"),\n\t\t\tccol(\"last_ip\", 200, \"''\"),\n\t\t\t{\"profile_comments\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"who_can_convo\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"enable_embeds\", \"int\", 0, false, false, \"-1\"},\n\t\t\tccol(\"email\", 200, \"''\"),\n\t\t\tccol(\"avatar\", 100, \"''\"),\n\t\t\ttext(\"message\"),\n\n\t\t\t// TODO: Drop these columns?\n\t\t\tccol(\"url_prefix\", 20, \"''\"),\n\t\t\tccol(\"url_name\", 100, \"''\"),\n\t\t\t//text(\"pub_key\"),\n\n\t\t\t{\"level\", \"smallint\", 0, false, false, \"0\"},\n\t\t\t{\"score\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"posts\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"bigposts\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"megaposts\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"topics\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"liked\", \"int\", 0, false, false, \"0\"},\n\n\t\t\t// These two are to bound liked queries with little bits of information we know about the user to reduce the server load\n\t\t\t{\"oldestItemLikedCreatedAt\", \"datetime\", 0, false, false, \"\"}, // For internal use only, semantics may change\n\t\t\t{\"lastLiked\", \"datetime\", 0, false, false, \"\"},                // For internal use only, semantics may change\n\n\t\t\t//{\"penalty_count\",\"int\",0,false,false,\"0\"},\n\t\t\t{\"temp_group\", \"int\", 0, false, false, \"0\"}, // For temporary groups, set this to zero when a temporary group isn't in effect\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t\t{\"name\", \"unique\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"users_groups\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"gid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 100, \"\"),\n\t\t\ttext(\"permissions\"),\n\t\t\ttext(\"plugin_perms\"),\n\t\t\tbcol(\"is_mod\", false),\n\t\t\tbcol(\"is_admin\", false),\n\t\t\tbcol(\"is_banned\", false),\n\t\t\t{\"user_count\", \"int\", 0, false, false, \"0\"}, // TODO: Implement this\n\n\t\t\tccol(\"tag\", 50, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"gid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"users_groups_promotions\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"from_gid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"to_gid\", \"int\", 0, false, false, \"\"},\n\t\t\tbcol(\"two_way\", false), // If a user no longer meets the requirements for this promotion then they will be demoted if this flag is set\n\n\t\t\t// Requirements\n\t\t\t{\"level\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"posts\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"minTime\", \"int\", 0, false, false, \"\"},        // How long someone needs to have been in their current group before being promoted\n\t\t\t{\"registeredFor\", \"int\", 0, false, false, \"0\"}, // minutes\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\t/*\n\t\tcreateTable(\"users_groups_promotions_scheduled\",\"\",\"\",\n\t\t\t[]tC{\n\t\t\t\t{\"prid\",\"int\",0,false,false,\"\"},\n\t\t\t\t{\"uid\",\"int\",0,false,false,\"\"},\n\t\t\t\t{\"runAt\",\"datetime\",0,false,false,\"\"},\n\t\t\t},\n\t\t\t[]tK{\n\t\t\t\t// TODO: Test to see that the compound primary key works\n\t\t\t\t{\"prid,uid\", \"primary\", \"\", false},\n\t\t\t},\n\t\t)\n\t*/\n\n\tcreateTable(\"users_2fa_keys\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"secret\", 100, \"\"),\n\t\t\tccol(\"scratch1\", 50, \"\"),\n\t\t\tccol(\"scratch2\", 50, \"\"),\n\t\t\tccol(\"scratch3\", 50, \"\"),\n\t\t\tccol(\"scratch4\", 50, \"\"),\n\t\t\tccol(\"scratch5\", 50, \"\"),\n\t\t\tccol(\"scratch6\", 50, \"\"),\n\t\t\tccol(\"scratch7\", 50, \"\"),\n\t\t\tccol(\"scratch8\", 50, \"\"),\n\t\t\t{\"createdAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\t// What should we do about global penalties? Put them on the users table for speed? Or keep them here?\n\t// Should we add IP Penalties? No, that's a stupid idea, just implement IP Bans properly. What about shadowbans?\n\t// TODO: Perm overrides\n\t// TODO: Add a mod-queue and other basic auto-mod features. This is needed for awaiting activation and the mod_queue penalty flag\n\t// TODO: Add a penalty type where a user is stopped from creating plugin_guilds social groups\n\t// TODO: Shadow bans. We will probably have a CanShadowBan permission for this, as we *really* don't want people using this lightly.\n\t/*createTable(\"users_penalties\",\"\",\"\",\n\t\t[]tC{\n\t\t\t{\"uid\",\"int\",0,false,false,\"\"},\n\t\t\t{\"element_id\",\"int\",0,false,false,\"\"},\n\t\t\tccol(\"element_type\",50,\"\"), //forum, profile?, and social_group. Leave blank for global.\n\t\t\ttext(\"overrides\",\"{}\"),\n\n\t\t\tbcol(\"mod_queue\",false),\n\t\t\tbcol(\"shadow_ban\",false),\n\t\t\tbcol(\"no_avatar\",false), // Coming Soon. Should this be a perm override instead?\n\n\t\t\t// Do we *really* need rate-limit penalty types? Are we going to be allowing bots or something?\n\t\t\t//{\"posts_per_hour\",\"int\",0,false,false,\"0\"},\n\t\t\t//{\"topics_per_hour\",\"int\",0,false,false,\"0\"},\n\t\t\t//{\"posts_count\",\"int\",0,false,false,\"0\"},\n\t\t\t//{\"topic_count\",\"int\",0,false,false,\"0\"},\n\t\t\t//{\"last_hour\",\"int\",0,false,false,\"0\"}, // UNIX Time, as we don't need to do anything too fancy here. When an hour has elapsed since that time, reset the hourly penalty counters.\n\n\t\t\t{\"issued_by\",\"int\",0,false,false,\"\"},\n\t\t\tcreatedAt(\"issued_at\"),\n\t\t\t{\"expires_at\",\"datetime\",0,false,false,\"\"},\n\t\t}, nil,\n\t)*/\n\n\tcreateTable(\"users_groups_scheduler\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"set_group\", \"int\", 0, false, false, \"\"},\n\n\t\t\t{\"issued_by\", \"int\", 0, false, false, \"\"},\n\t\t\tcreatedAt(\"issued_at\"),\n\t\t\t{\"revert_at\", \"datetime\", 0, false, false, \"\"},\n\t\t\t{\"temporary\", \"boolean\", 0, false, false, \"\"}, // special case for permanent bans to do the necessary bookkeeping, might be removed in the future\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\t// TODO: Can we use a piece of software dedicated to persistent queues for this rather than relying on the database for it?\n\tcreateTable(\"users_avatar_queue\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\t// TODO: Should we add a users prefix to this table to fit the \"unofficial convention\"?\n\t// TODO: Add an autoincrement key?\n\tcreateTable(\"emails\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"email\", 200, \"\"),\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tbcol(\"validated\", false),\n\t\t\tccol(\"token\", 200, \"''\"),\n\t\t}, nil,\n\t)\n\n\t// TODO: Allow for patterns in domains, if the bots try to shake things up there?\n\t/*\n\t\tcreateTable(\"email_domain_blacklist\", \"\", \"\",\n\t\t\t[]tC{\n\t\t\t\tccol(\"domain\", 200, \"\"),\n\t\t\t\tbcol(\"gtld\", false),\n\t\t\t},\n\t\t\t[]tK{\n\t\t\t\t{\"domain\", \"primary\"},\n\t\t\t},\n\t\t)\n\t*/\n\n\t// TODO: Implement password resets\n\tcreateTable(\"password_resets\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"email\", 200, \"\"),\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tccol(\"validated\", 200, \"\"),          // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token\n\t\t\tccol(\"token\", 200, \"\"),\n\t\t\tcreatedAt(),\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"forums\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"fid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 100, \"\"),\n\t\t\tccol(\"desc\", 200, \"\"),\n\t\t\tccol(\"tmpl\", 200, \"''\"),\n\t\t\tbcol(\"active\", true),\n\t\t\t{\"order\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"topicCount\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"preset\", 100, \"''\"),\n\t\t\t{\"parentID\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"parentType\", 50, \"''\"),\n\t\t\t{\"lastTopicID\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"lastReplyerID\", \"int\", 0, false, false, \"0\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"fid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"forums_permissions\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"fid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"gid\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"preset\", 100, \"''\"),\n\t\t\ttext(\"permissions\", \"{}\"),\n\t\t},\n\t\t[]tK{\n\t\t\t// TODO: Test to see that the compound primary key works\n\t\t\t{\"fid,gid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"topics\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"tid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"title\", 100, \"\"), // TODO: Increase the max length to 200?\n\t\t\ttext(\"content\"),\n\t\t\ttext(\"parsed_content\"),\n\t\t\tcreatedAt(),\n\t\t\t{\"lastReplyAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t{\"lastReplyBy\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"lastReplyID\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tbcol(\"is_closed\", false),\n\t\t\tbcol(\"sticky\", false),\n\t\t\t// TODO: Add an index for this\n\t\t\t{\"parentID\", \"int\", 0, false, false, \"2\"},\n\t\t\tccol(\"ip\", 200, \"''\"),\n\t\t\t{\"postCount\", \"int\", 0, false, false, \"1\"},\n\t\t\t{\"likeCount\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"attachCount\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"words\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"views\", \"int\", 0, false, false, \"0\"},\n\t\t\t//{\"dayViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"weekEvenViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"weekOddViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t///{\"weekViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t///{\"lastWeekViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t//{\"monthViews\", \"int\", 0, false, false, \"0\"},\n\t\t\t// ? - A little hacky, maybe we could do something less likely to bite us with huge numbers of topics?\n\t\t\t// TODO: Add an index for this?\n\t\t\t//{\"lastMonth\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"css_class\", 100, \"''\"),\n\t\t\t{\"poll\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"data\", 200, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"tid\", \"primary\", \"\", false},\n\t\t\t{\"title\", \"fulltext\", \"\", false},\n\t\t\t{\"content\", \"fulltext\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"replies\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"rid\", \"int\", 0, false, true, \"\"},  // TODO: Rename to replyID?\n\t\t\t{\"tid\", \"int\", 0, false, false, \"\"}, // TODO: Rename to topicID?\n\t\t\ttext(\"content\"),\n\t\t\ttext(\"parsed_content\"),\n\t\t\tcreatedAt(),\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"lastEdit\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"lastEditBy\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"lastUpdated\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"ip\", 200, \"''\"),\n\t\t\t{\"likeCount\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"attachCount\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"words\", \"int\", 0, false, false, \"1\"}, // ? - replies has a default of 1 and topics has 0? why?\n\t\t\tccol(\"actionType\", 20, \"''\"),\n\t\t\t{\"poll\", \"int\", 0, false, false, \"0\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"rid\", \"primary\", \"\", false},\n\t\t\t{\"content\", \"fulltext\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"attachments\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"attachID\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"sectionID\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"sectionTable\", 200, \"forums\"),\n\t\t\t{\"originID\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"originTable\", 200, \"replies\"),\n\t\t\t{\"uploadedBy\", \"int\", 0, false, false, \"\"}, // TODO; Make this a foreign key\n\t\t\tccol(\"path\", 200, \"\"),\n\t\t\tccol(\"extra\", 200, \"\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"attachID\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"revisions\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"reviseID\", \"int\", 0, false, true, \"\"},\n\t\t\ttext(\"content\"),\n\t\t\t{\"contentID\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"contentType\", 100, \"replies\"),\n\t\t\tcreatedAt(),\n\t\t\t// TODO: Add a createdBy column?\n\t\t},\n\t\t[]tK{\n\t\t\t{\"reviseID\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"polls\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"pollID\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"parentID\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"parentTable\", 100, \"topics\"), // topics, replies\n\t\t\t{\"type\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"options\", \"json\", 0, false, false, \"\"},\n\t\t\t{\"votes\", \"int\", 0, false, false, \"0\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pollID\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"polls_options\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"pollID\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"option\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"votes\", \"int\", 0, false, false, \"0\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"polls_votes\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"pollID\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"option\", \"int\", 0, false, false, \"0\"},\n\t\t\tcreatedAt(\"castAt\"),\n\t\t\tccol(\"ip\", 200, \"''\"),\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"users_replies\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"rid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\ttext(\"content\"),\n\t\t\ttext(\"parsed_content\"),\n\t\t\tcreatedAt(),\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"lastEdit\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"lastEditBy\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"ip\", 200, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"rid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"likes\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"weight\", \"tinyint\", 0, false, false, \"1\"},\n\t\t\t{\"targetItem\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"targetType\", 50, \"replies\"),\n\t\t\t{\"sentBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tcreatedAt(),\n\t\t\t{\"recalc\", \"tinyint\", 0, false, false, \"0\"},\n\t\t}, nil,\n\t)\n\n\t//columns(\"participants,createdBy,createdAt,lastReplyBy,lastReplyAt\").Where(\"cid=?\")\n\tcreateTable(\"conversations\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"cid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tcreatedAt(),\n\t\t\t{\"lastReplyAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t{\"lastReplyBy\", \"int\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"cid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"conversations_posts\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"cid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"body\", 50, \"\"),\n\t\t\tccol(\"post\", 50, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"conversations_participants\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"cid\", \"int\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\t/*\n\t\tcreateTable(\"users_friends\", \"\", \"\",\n\t\t\t[]tC{\n\t\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\t\t{\"uid2\", \"int\", 0, false, false, \"\"},\n\t\t\t}, nil,\n\t\t)\n\t\tcreateTable(\"users_friends_invites\", \"\", \"\",\n\t\t\t[]tC{\n\t\t\t\t{\"requester\", \"int\", 0, false, false, \"\"},\n\t\t\t\t{\"target\", \"int\", 0, false, false, \"\"},\n\t\t\t}, nil,\n\t\t)\n\t*/\n\n\tcreateTable(\"users_blocks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"blocker\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"blockedUser\", \"int\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"activity_stream_matches\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"watcher\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"asid\", \"int\", 0, false, false, \"\"},    // TODO: Make this a foreign key\n\t\t},\n\t\t[]tK{\n\t\t\t{\"asid,asid\", \"foreign\", \"activity_stream\", true},\n\t\t},\n\t)\n\n\tcreateTable(\"activity_stream\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"asid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"actor\", \"int\", 0, false, false, \"\"},      /* the one doing the act */ // TODO: Make this a foreign key\n\t\t\t{\"targetUser\", \"int\", 0, false, false, \"\"}, /* the user who created the item the actor is acting on, some items like forums may lack a targetUser field */\n\t\t\tccol(\"event\", 50, \"\"),                      /* mention, like, reply (as in the act of replying to an item, not the reply item type, you can \"reply\" to a forum by making a topic in it), friend_invite */\n\t\t\tccol(\"elementType\", 50, \"\"),                /* topic, post (calling it post here to differentiate it from the 'reply' event), forum, user */\n\n\t\t\t// replacement for elementType\n\t\t\ttC{\"elementTable\", \"int\", 0, false, false, \"0\"},\n\n\t\t\t{\"elementID\", \"int\", 0, false, false, \"\"}, /* the ID of the element being acted upon */\n\t\t\tcreatedAt(),\n\t\t\tccol(\"extra\", 200, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"asid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"activity_subscriptions\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"user\", \"int\", 0, false, false, \"\"},     // TODO: Make this a foreign key\n\t\t\t{\"targetID\", \"int\", 0, false, false, \"\"}, /* the ID of the element being acted upon */\n\t\t\tccol(\"targetType\", 50, \"\"),               /* topic, post (calling it post here to differentiate it from the 'reply' event), forum, user */\n\t\t\t{\"level\", \"int\", 0, false, false, \"0\"},   /* 0: Mentions (aka the global default for any post), 1: Replies To You, 2: All Replies*/\n\t\t}, nil,\n\t)\n\n\t/* Due to MySQL's design, we have to drop the unique keys for table settings, plugins, and themes down from 200 to 180 or it will error */\n\tcreateTable(\"settings\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"name\", 180, \"\"),\n\t\t\tccol(\"content\", 250, \"\"),\n\t\t\tccol(\"type\", 50, \"\"),\n\t\t\tccol(\"constraints\", 200, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"name\", \"unique\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"word_filters\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"wfid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"find\", 200, \"\"),\n\t\t\tccol(\"replacement\", 200, \"\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"wfid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"plugins\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"uname\", 180, \"\"),\n\t\t\tbcol(\"active\", false),\n\t\t\tbcol(\"installed\", false),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uname\", \"unique\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"themes\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"uname\", 180, \"\"),\n\t\t\tbcol(\"default\", false),\n\t\t\t//text(\"profileUserVars\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uname\", \"unique\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"widgets\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"wid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"position\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"side\", 100, \"\"),\n\t\t\tccol(\"type\", 100, \"\"),\n\t\t\tbcol(\"active\", false),\n\t\t\tccol(\"location\", 100, \"\"),\n\t\t\ttext(\"data\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"wid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"menus\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"mid\", \"int\", 0, false, true, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"mid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"menu_items\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"miid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"mid\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"name\", 200, \"''\"),\n\t\t\tccol(\"htmlID\", 200, \"''\"),\n\t\t\tccol(\"cssClass\", 200, \"''\"),\n\t\t\tccol(\"position\", 100, \"\"),\n\t\t\tccol(\"path\", 200, \"''\"),\n\t\t\tccol(\"aria\", 200, \"''\"),\n\t\t\tccol(\"tooltip\", 200, \"''\"),\n\t\t\tccol(\"tmplName\", 200, \"''\"),\n\t\t\t{\"order\", \"int\", 0, false, false, \"0\"},\n\n\t\t\tbcol(\"guestOnly\", false),\n\t\t\tbcol(\"memberOnly\", false),\n\t\t\tbcol(\"staffOnly\", false),\n\t\t\tbcol(\"adminOnly\", false),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"miid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"pages\", mysqlPre, mysqlCol,\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\t//ccol(\"path\", 200, \"\"),\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t\tccol(\"title\", 200, \"\"),\n\t\t\ttext(\"body\"),\n\t\t\t// TODO: Make this a table?\n\t\t\ttext(\"allowedGroups\"),\n\t\t\t{\"menuID\", \"int\", 0, false, false, \"-1\"}, // simple sidebar menu\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"registration_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"rlid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"username\", 100, \"\"),\n\t\t\t{\"email\", \"varchar\", 100, false, false, \"\"},\n\t\t\tccol(\"failureReason\", 100, \"\"),\n\t\t\tbcol(\"success\", false), // Did this attempt succeed?\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\tcreatedAt(\"doneAt\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"rlid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"login_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"lid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\n\t\t\tbcol(\"success\", false), // Did this attempt succeed?\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\tcreatedAt(\"doneAt\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"lid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\n\tcreateTable(\"moderation_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"action\", 100, \"\"),\n\t\t\t{\"elementID\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"elementType\", 100, \"\"),\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\t{\"actorID\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"doneAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\ttext(\"extra\"),\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"administration_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"action\", 100, \"\"),\n\t\t\t{\"elementID\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"elementType\", 100, \"\"),\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\t{\"actorID\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"doneAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\ttext(\"extra\"),\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"avg\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"route\", 200, \"\"), // TODO: set a default empty here\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks_agents\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"browser\", 200, \"\"), // googlebot, firefox, opera, etc.\n\t\t\t//ccol(\"version\",0,\"\"), // the version of the browser or bot\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks_systems\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"system\", 200, \"\"), // windows, android, unknown, etc.\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks_langs\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"lang\", 200, \"\"), // en, ru, etc.\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks_referrers\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\tccol(\"domain\", 200, \"\"),\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"viewchunks_forums\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t{\"forum\", \"int\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"topicchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t// TODO: Add a column for the parent forum?\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"postchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t// TODO: Add a column for the parent topic / profile?\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"memchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"stack\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"heap\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"perfchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"low\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"high\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"avg\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"sync\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"last_update\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"updates\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"dbVersion\", \"int\", 0, false, false, \"0\"},\n\t\t}, nil,\n\t)\n\n\tcreateTable(\"meta\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t\tccol(\"value\", 200, \"\"),\n\t\t}, nil,\n\t)\n\n\t/*createTable(\"tables\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"id\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"id\", \"primary\", \"\", false},\n\t\t\t{\"name\", \"unique\", \"\", false},\n\t\t},\n\t)*/\n\n\treturn err\n}\n"
  },
  {
    "path": "common/activity_stream.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Activity ActivityStream\n\ntype ActivityStream interface {\n\tAdd(a Alert) (int, error)\n\tGet(id int) (Alert, error)\n\tDelete(id int) error\n\tDeleteByParams(event string, targetID int, targetType string) error\n\tDeleteByParamsExtra(event string, targetID int, targetType, extra string) error\n\tAidsByParams(event string, elementID int, elementType string) (aids []int, err error)\n\tAidsByParamsExtra(event string, elementID int, elementType, extra string) (aids []int, err error)\n\tCount() (count int)\n}\n\ntype DefaultActivityStream struct {\n\tadd                 *sql.Stmt\n\tget                 *sql.Stmt\n\tdelete              *sql.Stmt\n\tdeleteByParams      *sql.Stmt\n\tdeleteByParamsExtra *sql.Stmt\n\taidsByParams        *sql.Stmt\n\taidsByParamsExtra   *sql.Stmt\n\tcount               *sql.Stmt\n}\n\nfunc NewDefaultActivityStream(acc *qgen.Accumulator) (*DefaultActivityStream, error) {\n\tas := \"activity_stream\"\n\tcols := \"actor,targetUser,event,elementType,elementID,createdAt,extra\"\n\treturn &DefaultActivityStream{\n\t\tadd:                 acc.Insert(as).Columns(cols).Fields(\"?,?,?,?,?,UTC_TIMESTAMP(),?\").Prepare(),\n\t\tget:                 acc.Select(as).Columns(cols).Where(\"asid=?\").Prepare(),\n\t\tdelete:              acc.Delete(as).Where(\"asid=?\").Prepare(),\n\t\tdeleteByParams:      acc.Delete(as).Where(\"event=? AND elementID=? AND elementType=?\").Prepare(),\n\t\tdeleteByParamsExtra: acc.Delete(as).Where(\"event=? AND elementID=? AND elementType=? AND extra=?\").Prepare(),\n\t\taidsByParams:        acc.Select(as).Columns(\"asid\").Where(\"event=? AND elementID=? AND elementType=?\").Prepare(),\n\t\taidsByParamsExtra:   acc.Select(as).Columns(\"asid\").Where(\"event=? AND elementID=? AND elementType=? AND extra=?\").Prepare(),\n\t\tcount:               acc.Count(as).Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultActivityStream) Add(a Alert) (int, error) {\n\tres, err := s.add.Exec(a.ActorID, a.TargetUserID, a.Event, a.ElementType, a.ElementID, a.Extra)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n\nfunc (s *DefaultActivityStream) Get(id int) (Alert, error) {\n\ta := Alert{ASID: id}\n\terr := s.get.QueryRow(id).Scan(&a.ActorID, &a.TargetUserID, &a.Event, &a.ElementType, &a.ElementID, &a.CreatedAt, &a.Extra)\n\treturn a, err\n}\n\nfunc (s *DefaultActivityStream) Delete(id int) error {\n\t_, err := s.delete.Exec(id)\n\treturn err\n}\n\nfunc (s *DefaultActivityStream) DeleteByParams(event string, elementID int, elementType string) error {\n\t_, err := s.deleteByParams.Exec(event, elementID, elementType)\n\treturn err\n}\n\nfunc (s *DefaultActivityStream) DeleteByParamsExtra(event string, elementID int, elementType, extra string) error {\n\t_, err := s.deleteByParamsExtra.Exec(event, elementID, elementType, extra)\n\treturn err\n}\n\nfunc (s *DefaultActivityStream) AidsByParams(event string, elementID int, elementType string) (aids []int, err error) {\n\trows, err := s.aidsByParams.Query(event, elementID, elementType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar aid int\n\t\tif err := rows.Scan(&aid); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taids = append(aids, aid)\n\t}\n\treturn aids, rows.Err()\n}\n\nfunc (s *DefaultActivityStream) AidsByParamsExtra(event string, elementID int, elementType, extra string) (aids []int, e error) {\n\trows, e := s.aidsByParamsExtra.Query(event, elementID, elementType, extra)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar aid int\n\t\tif e := rows.Scan(&aid); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\taids = append(aids, aid)\n\t}\n\treturn aids, rows.Err()\n}\n\n// Count returns the total number of activity stream items\nfunc (s *DefaultActivityStream) Count() (count int) {\n\treturn Count(s.count)\n}\n"
  },
  {
    "path": "common/activity_stream_matches.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar ActivityMatches ActivityStreamMatches\n\ntype ActivityStreamMatches interface {\n\tAdd(watcher, asid int) error\n\tDelete(watcher, asid int) error\n\tDeleteAndCountChanged(watcher, asid int) (int, error)\n\tCountAsid(asid int) int\n}\n\ntype DefaultActivityStreamMatches struct {\n\tadd       *sql.Stmt\n\tdelete    *sql.Stmt\n\tcountAsid *sql.Stmt\n}\n\nfunc NewDefaultActivityStreamMatches(acc *qgen.Accumulator) (*DefaultActivityStreamMatches, error) {\n\tasm := \"activity_stream_matches\"\n\treturn &DefaultActivityStreamMatches{\n\t\tadd:       acc.Insert(asm).Columns(\"watcher,asid\").Fields(\"?,?\").Prepare(),\n\t\tdelete:    acc.Delete(asm).Where(\"watcher=? AND asid=?\").Prepare(),\n\t\tcountAsid: acc.Count(asm).Where(\"asid=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultActivityStreamMatches) Add(watcher, asid int) error {\n\t_, e := s.add.Exec(watcher, asid)\n\treturn e\n}\n\nfunc (s *DefaultActivityStreamMatches) Delete(watcher, asid int) error {\n\t_, e := s.delete.Exec(watcher, asid)\n\treturn e\n}\n\nfunc (s *DefaultActivityStreamMatches) DeleteAndCountChanged(watcher, asid int) (int, error) {\n\tres, e := s.delete.Exec(watcher, asid)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tc64, e := res.RowsAffected()\n\treturn int(c64), e\n}\n\nfunc (s *DefaultActivityStreamMatches) CountAsid(asid int) int {\n\treturn Countf(s.countAsid, asid)\n}\n"
  },
  {
    "path": "common/alerts/tmpls.go",
    "content": "package alerts\n\n// TODO: Move the other alert related stuff to package alerts, maybe move notification logic here too?\n\ntype AlertItem struct {\n\tASID    int\n\tPath    string\n\tMessage string\n\tAvatar  string\n}\n"
  },
  {
    "path": "common/alerts.go",
    "content": "/*\n*\n* Gosora Alerts System\n* Copyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t//\"fmt\"\n\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype Alert struct {\n\tASID         int\n\tActorID      int\n\tTargetUserID int\n\tEvent        string\n\tElementType  string\n\tElementID    int\n\tCreatedAt    time.Time\n\tExtra        string\n\n\tActor *User\n}\n\ntype AlertStmts struct {\n\tnotifyWatchers *sql.Stmt\n\tgetWatchers    *sql.Stmt\n}\n\nvar alertStmts AlertStmts\n\n// TODO: Move these statements into some sort of activity abstraction\n// TODO: Rewrite the alerts logic\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\talertStmts = AlertStmts{\n\t\t\tnotifyWatchers: acc.SimpleInsertInnerJoin(\n\t\t\t\tqgen.DBInsert{\"activity_stream_matches\", \"watcher,asid\", \"\"},\n\t\t\t\tqgen.DBJoin{\"activity_stream\", \"activity_subscriptions\", \"activity_subscriptions.user, activity_stream.asid\", \"activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor\", \"asid=?\", \"\", \"\"},\n\t\t\t),\n\t\t\tgetWatchers: acc.SimpleInnerJoin(\"activity_stream\", \"activity_subscriptions\", \"activity_subscriptions.user\", \"activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor\", \"asid=?\", \"\", \"\"),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nconst AlertsGrowHint = len(`{\"msgs\":[],\"count\":,\"tc\":}`) + 1 + 10\n\n// TODO: See if we can json.Marshal instead?\nfunc escapeTextInJson(in string) string {\n\tin = strings.Replace(in, \"\\\"\", \"\\\\\\\"\", -1)\n\treturn strings.Replace(in, \"/\", \"\\\\/\", -1)\n}\n\nfunc BuildAlert(a Alert, user User /* The current user */) (out string, err error) {\n\tvar targetUser *User\n\tif a.Actor == nil {\n\t\ta.Actor, err = Users.Get(a.ActorID)\n\t\tif err != nil {\n\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_actor\"))\n\t\t}\n\t}\n\n\t/*if a.ElementType != \"forum\" {\n\t\ttargetUser, err = users.Get(a.TargetUserID)\n\t\tif err != nil {\n\t\t\tLocalErrorJS(\"Unable to find the target user\",w,r)\n\t\t\treturn\n\t\t}\n\t}*/\n\tif a.Event == \"friend_invite\" {\n\t\treturn buildAlertString(\".new_friend_invite\", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID), nil\n\t}\n\n\t// Not that many events for us to handle in a forum\n\tif a.ElementType == \"forum\" {\n\t\tif a.Event == \"reply\" {\n\t\t\ttopic, err := Topics.Get(a.ElementID)\n\t\t\tif err != nil {\n\t\t\t\tDebugLogf(\"Unable to find linked topic %d\", a.ElementID)\n\t\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic\"))\n\t\t\t}\n\t\t\t// Store the forum ID in the targetUser column instead of making a new one? o.O\n\t\t\t// Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now...\n\t\t\treturn buildAlertString(\".forum_new_topic\", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID), nil\n\t\t}\n\t\treturn buildAlertString(\".forum_unknown_action\", []string{a.Actor.Name}, \"\", a.Actor.Avatar, a.ASID), nil\n\t}\n\n\tvar url, area, phraseName string\n\town := false\n\t// TODO: Avoid loading a bit of data twice\n\tswitch a.ElementType {\n\tcase \"convo\":\n\t\tconvo, err := Convos.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked convo %d\", a.ElementID)\n\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_convo\"))\n\t\t}\n\t\turl = convo.Link\n\tcase \"topic\":\n\t\ttopic, err := Topics.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked topic %d\", a.ElementID)\n\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic\"))\n\t\t}\n\t\turl = topic.Link\n\t\tarea = topic.Title\n\t\town = a.TargetUserID == user.ID\n\tcase \"user\":\n\t\ttargetUser, err = Users.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find target user %d\", a.ElementID)\n\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_target_user\"))\n\t\t}\n\t\tarea = targetUser.Name\n\t\turl = targetUser.Link\n\t\town = a.TargetUserID == user.ID\n\tcase \"post\":\n\t\ttopic, err := TopicByReplyID(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked topic by reply ID %d\", a.ElementID)\n\t\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic_by_reply\"))\n\t\t}\n\t\turl = topic.Link\n\t\tarea = topic.Title\n\t\town = a.TargetUserID == user.ID\n\tdefault:\n\t\treturn \"\", errors.New(phrases.GetErrorPhrase(\"alerts_invalid_elementtype\"))\n\t}\n\n\tbadEv := false\n\tswitch a.Event {\n\tcase \"create\", \"like\", \"mention\", \"reply\":\n\t\t// skip\n\tdefault:\n\t\tbadEv = true\n\t}\n\n\tif own && !badEv {\n\t\tphraseName = \".\" + a.ElementType + \"_own_\" + a.Event\n\t} else if !badEv {\n\t\tphraseName = \".\" + a.ElementType + \"_\" + a.Event\n\t} else if own {\n\t\tphraseName = \".\" + a.ElementType + \"_own\"\n\t} else {\n\t\tphraseName = \".\" + a.ElementType\n\t}\n\n\treturn buildAlertString(phraseName, []string{a.Actor.Name, area}, url, a.Actor.Avatar, a.ASID), nil\n}\n\nfunc buildAlertString(msg string, sub []string, path, avatar string, asid int) string {\n\tvar sb strings.Builder\n\tbuildAlertSb(&sb, msg, sub, path, avatar, asid)\n\treturn sb.String()\n}\n\nconst AlertsGrowHint2 = len(`{\"msg\":\"\",\"sub\":[],\"path\":\"\",\"img\":\"\",\"id\":}`) + 5 + 3 + 1 + 1 + 1\n\n// TODO: Use a string builder?\nfunc buildAlertSb(sb *strings.Builder, msg string, sub []string, path, avatar string, asid int) {\n\tsb.WriteString(`{\"msg\":\"`)\n\tsb.WriteString(escapeTextInJson(msg))\n\tsb.WriteString(`\",\"sub\":[`)\n\tfor i, it := range sub {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(\",\\\"\")\n\t\t} else {\n\t\t\tsb.WriteString(\"\\\"\")\n\t\t}\n\t\tsb.WriteString(escapeTextInJson(it))\n\t\tsb.WriteString(\"\\\"\")\n\t}\n\tsb.WriteString(`],\"path\":\"`)\n\tsb.WriteString(escapeTextInJson(path))\n\tsb.WriteString(`\",\"img\":\"`)\n\tsb.WriteString(escapeTextInJson(avatar))\n\tsb.WriteString(`\",\"id\":`)\n\tsb.WriteString(strconv.Itoa(asid))\n\tsb.WriteRune('}')\n}\n\nfunc BuildAlertSb(sb *strings.Builder, a *Alert, u *User /* The current user */) (err error) {\n\tvar targetUser *User\n\tif a.Actor == nil {\n\t\ta.Actor, err = Users.Get(a.ActorID)\n\t\tif err != nil {\n\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_actor\"))\n\t\t}\n\t}\n\n\t/*if a.ElementType != \"forum\" {\n\t\ttargetUser, err = users.Get(a.TargetUserID)\n\t\tif err != nil {\n\t\t\tLocalErrorJS(\"Unable to find the target user\",w,r)\n\t\t\treturn\n\t\t}\n\t}*/\n\tif a.Event == \"friend_invite\" {\n\t\tbuildAlertSb(sb, \".new_friend_invite\", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID)\n\t\treturn nil\n\t}\n\n\t// Not that many events for us to handle in a forum\n\tif a.ElementType == \"forum\" {\n\t\tif a.Event == \"reply\" {\n\t\t\ttopic, err := Topics.Get(a.ElementID)\n\t\t\tif err != nil {\n\t\t\t\tDebugLogf(\"Unable to find linked topic %d\", a.ElementID)\n\t\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic\"))\n\t\t\t}\n\t\t\t// Store the forum ID in the targetUser column instead of making a new one? o.O\n\t\t\t// Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now...\n\t\t\tbuildAlertSb(sb, \".forum_new_topic\", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID)\n\t\t\treturn nil\n\t\t}\n\t\tbuildAlertSb(sb, \".forum_unknown_action\", []string{a.Actor.Name}, \"\", a.Actor.Avatar, a.ASID)\n\t\treturn nil\n\t}\n\n\tvar url, area string\n\town := false\n\t// TODO: Avoid loading a bit of data twice\n\tswitch a.ElementType {\n\tcase \"convo\":\n\t\tconvo, err := Convos.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked convo %d\", a.ElementID)\n\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_convo\"))\n\t\t}\n\t\turl = convo.Link\n\tcase \"topic\":\n\t\ttopic, err := Topics.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked topic %d\", a.ElementID)\n\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic\"))\n\t\t}\n\t\turl = topic.Link\n\t\tarea = topic.Title\n\t\town = a.TargetUserID == u.ID\n\tcase \"user\":\n\t\ttargetUser, err = Users.Get(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find target user %d\", a.ElementID)\n\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_target_user\"))\n\t\t}\n\t\tarea = targetUser.Name\n\t\turl = targetUser.Link\n\t\town = a.TargetUserID == u.ID\n\tcase \"post\":\n\t\tt, err := TopicByReplyID(a.ElementID)\n\t\tif err != nil {\n\t\t\tDebugLogf(\"Unable to find linked topic by reply ID %d\", a.ElementID)\n\t\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_no_linked_topic_by_reply\"))\n\t\t}\n\t\turl = t.Link\n\t\tarea = t.Title\n\t\town = a.TargetUserID == u.ID\n\tdefault:\n\t\treturn errors.New(phrases.GetErrorPhrase(\"alerts_invalid_elementtype\"))\n\t}\n\n\tsb.WriteString(`{\"msg\":\".`)\n\tsb.WriteString(a.ElementType)\n\tif own {\n\t\tsb.WriteString(\"_own_\")\n\t} else {\n\t\tsb.WriteRune('_')\n\t}\n\tswitch a.Event {\n\tcase \"create\", \"like\", \"mention\", \"reply\":\n\t\tsb.WriteString(a.Event)\n\t}\n\n\tsb.WriteString(`\",\"sub\":[\"`)\n\tsb.WriteString(escapeTextInJson(a.Actor.Name))\n\tsb.WriteString(\"\\\",\\\"\")\n\tsb.WriteString(escapeTextInJson(area))\n\tsb.WriteString(`\"],\"path\":\"`)\n\tsb.WriteString(escapeTextInJson(url))\n\tsb.WriteString(`\",\"img\":\"`)\n\tsb.WriteString(escapeTextInJson(a.Actor.Avatar))\n\tsb.WriteString(`\",\"id\":`)\n\tsb.WriteString(strconv.Itoa(a.ASID))\n\tsb.WriteRune('}')\n\n\treturn nil\n}\n\n//const AlertsGrowHint3 = len(`{\"msg\":\"._\",\"sub\":[\"\",\"\"],\"path\":\"\",\"img\":\"\",\"id\":}`) + 3 + 2 + 2 + 2 + 2 + 1\n\n// TODO: Create a notifier structure?\nfunc AddActivityAndNotifyAll(a Alert) error {\n\tid, err := Activity.Add(a)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn NotifyWatchers(id)\n}\n\n// TODO: Create a notifier structure?\nfunc AddActivityAndNotifyTarget(a Alert) error {\n\tid, err := Activity.Add(a)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = ActivityMatches.Add(a.TargetUserID, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\ta.ASID = id\n\n\t// Live alerts, if the target is online and WebSockets is enabled\n\tif EnableWebsockets {\n\t\tgo func() {\n\t\t\tdefer EatPanics()\n\t\t\t_ = WsHub.pushAlert(a.TargetUserID, a)\n\t\t\t//fmt.Println(\"err:\",err)\n\t\t}()\n\t}\n\treturn nil\n}\n\n// TODO: Create a notifier structure?\nfunc NotifyWatchers(asid int) error {\n\t_, err := alertStmts.notifyWatchers.Exec(asid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Alert the subscribers about this without blocking us from doing something else\n\tif EnableWebsockets {\n\t\tgo func() {\n\t\t\tdefer EatPanics()\n\t\t\tnotifyWatchers(asid)\n\t\t}()\n\t}\n\treturn nil\n}\n\nfunc notifyWatchers(asid int) {\n\trows, e := alertStmts.getWatchers.Query(asid)\n\tif e != nil && e != ErrNoRows {\n\t\tLogError(e)\n\t\treturn\n\t}\n\tdefer rows.Close()\n\n\tvar uid int\n\tvar uids []int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&uid); e != nil {\n\t\t\tLogError(e)\n\t\t\treturn\n\t\t}\n\t\tuids = append(uids, uid)\n\t}\n\tif e = rows.Err(); e != nil {\n\t\tLogError(e)\n\t\treturn\n\t}\n\n\talert, e := Activity.Get(asid)\n\tif e != nil && e != ErrNoRows {\n\t\tLogError(e)\n\t\treturn\n\t}\n\t_ = WsHub.pushAlerts(uids, alert)\n}\n\nfunc DismissAlert(uid, aid int) {\n\t_ = WsHub.PushMessage(uid, `{\"event\":\"dismiss-alert\",\"id\":`+strconv.Itoa(aid)+`}`)\n}\n"
  },
  {
    "path": "common/analytics.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Analytics AnalyticsStore\n\ntype AnalyticsTimeRange struct {\n\tQuantity   int\n\tUnit       string\n\tSlices     int\n\tSliceWidth int\n\tRange      string\n}\n\ntype AnalyticsStore interface {\n\tFillViewMap(tbl string, tr *AnalyticsTimeRange, labelList []int64, viewMap map[int64]int64, param string, args ...interface{}) (map[int64]int64, error)\n}\n\ntype DefaultAnalytics struct {\n}\n\nfunc NewDefaultAnalytics() *DefaultAnalytics {\n\treturn &DefaultAnalytics{}\n}\n\n/*\n\trows, e := qgen.NewAcc().Select(\"viewchunks_systems\").Columns(\"count,createdAt\").Where(\"system=?\").DateCutoff(\"createdAt\", timeRange.Quantity, timeRange.Unit).Query(system)\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewMap, e = c.AnalyticsRowsToViewMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n*/\n\nfunc (s *DefaultAnalytics) FillViewMap(tbl string, tr *AnalyticsTimeRange, labelList []int64, viewMap map[int64]int64, param string, args ...interface{}) (map[int64]int64, error) {\n\tac := qgen.NewAcc().Select(tbl).Columns(\"count,createdAt\")\n\tif param != \"\" {\n\t\tac = ac.Where(param + \"=?\")\n\t}\n\trows, e := ac.DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query(args...)\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn nil, e\n\t}\n\treturn AnalyticsRowsToViewMap(rows, labelList, viewMap)\n}\n\n// TODO: Clamp it rather than using an offset off the current time to avoid chaotic changes in stats as adjacent sets converge and diverge?\nfunc AnalyticsTimeRangeToLabelList(tr *AnalyticsTimeRange) (revLabelList []int64, labelList []int64, viewMap map[int64]int64) {\n\tviewMap = make(map[int64]int64)\n\tcurrentTime := time.Now().Unix()\n\tfor i := 1; i <= tr.Slices; i++ {\n\t\tlabel := currentTime - int64(i*tr.SliceWidth)\n\t\trevLabelList = append(revLabelList, label)\n\t\tviewMap[label] = 0\n\t}\n\tlabelList = append(labelList, revLabelList...)\n\treturn revLabelList, labelList, viewMap\n}\n\nfunc AnalyticsRowsToViewMap(rows *sql.Rows, labelList []int64, viewMap map[int64]int64) (map[int64]int64, error) {\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar count int64\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&count, &createdAt)\n\t\tif e != nil {\n\t\t\treturn viewMap, e\n\t\t}\n\t\tunixCreatedAt := createdAt.Unix()\n\t\t// TODO: Bulk log this\n\t\tif Dev.SuperDebug {\n\t\t\tlog.Print(\"count: \", count)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\t\tfor _, value := range labelList {\n\t\t\tif unixCreatedAt > value {\n\t\t\t\tviewMap[value] += count\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\treturn viewMap, rows.Err()\n}\n"
  },
  {
    "path": "common/attachments.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\n\t//\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Attachments AttachmentStore\n\nvar ErrCorruptAttachPath = errors.New(\"corrupt attachment path\")\n\ntype MiniAttachment struct {\n\tID         int\n\tSectionID  int\n\tOriginID   int\n\tUploadedBy int\n\tPath       string\n\tExtra      string\n\n\tImage bool\n\tExt   string\n}\n\ntype Attachment struct {\n\tID           int\n\tSectionTable string\n\tSectionID    int\n\tOriginTable  string\n\tOriginID     int\n\tUploadedBy   int\n\tPath         string\n\tExtra        string\n\n\tImage bool\n\tExt   string\n}\n\ntype AttachmentStore interface {\n\tGetForRenderRoute(filename string, sid int, sectionTable string) (*Attachment, error)\n\tFGet(id int) (*Attachment, error)\n\tGet(id int) (*MiniAttachment, error)\n\tMiniGetList(originTable string, originID int) (alist []*MiniAttachment, err error)\n\tBulkMiniGetList(originTable string, ids []int) (amap map[int][]*MiniAttachment, err error)\n\tAdd(sectionID int, sectionTable string, originID int, originTable string, uploadedBy int, path, extra string) (int, error)\n\tMoveTo(sectionID, originID int, originTable string) error\n\tMoveToByExtra(sectionID int, originTable, extra string) error\n\tCount() int\n\tCountIn(originTable string, oid int) int\n\tCountInPath(path string) int\n\tDelete(id int) error\n\n\tAddLinked(otable string, oid int) (err error)\n\tRemoveLinked(otable string, oid int) (err error)\n}\n\ntype DefaultAttachmentStore struct {\n\tgetForRenderRoute *sql.Stmt\n\n\tfget        *sql.Stmt\n\tget         *sql.Stmt\n\tgetByObj    *sql.Stmt\n\tadd         *sql.Stmt\n\tcount       *sql.Stmt\n\tcountIn     *sql.Stmt\n\tcountInPath *sql.Stmt\n\tmove        *sql.Stmt\n\tmoveByExtra *sql.Stmt\n\tdelete      *sql.Stmt\n\n\treplyUpdateAttachs *sql.Stmt\n\ttopicUpdateAttachs *sql.Stmt\n}\n\nfunc NewDefaultAttachmentStore(acc *qgen.Accumulator) (*DefaultAttachmentStore, error) {\n\ta := \"attachments\"\n\treturn &DefaultAttachmentStore{\n\t\tgetForRenderRoute: acc.Select(a).Columns(\"sectionTable, originID, originTable, uploadedBy, path\").Where(\"path=? AND sectionID=? AND sectionTable=?\").Prepare(),\n\n\t\tfget:        acc.Select(a).Columns(\"originTable, originID, sectionTable, sectionID, uploadedBy, path, extra\").Where(\"attachID=?\").Prepare(),\n\t\tget:         acc.Select(a).Columns(\"originID, sectionID, uploadedBy, path, extra\").Where(\"attachID=?\").Prepare(),\n\t\tgetByObj:    acc.Select(a).Columns(\"attachID, sectionID, uploadedBy, path, extra\").Where(\"originTable=? AND originID=?\").Prepare(),\n\t\tadd:         acc.Insert(a).Columns(\"sectionID, sectionTable, originID, originTable, uploadedBy, path, extra\").Fields(\"?,?,?,?,?,?,?\").Prepare(),\n\t\tcount:       acc.Count(a).Prepare(),\n\t\tcountIn:     acc.Count(a).Where(\"originTable=? and originID=?\").Prepare(),\n\t\tcountInPath: acc.Count(a).Where(\"path=?\").Prepare(),\n\t\tmove:        acc.Update(a).Set(\"sectionID=?\").Where(\"originID=? AND originTable=?\").Prepare(),\n\t\tmoveByExtra: acc.Update(a).Set(\"sectionID=?\").Where(\"originTable=? AND extra=?\").Prepare(),\n\t\tdelete:      acc.Delete(a).Where(\"attachID=?\").Prepare(),\n\n\t\t// TODO: Less race-y attachment count updates\n\t\treplyUpdateAttachs: acc.Update(\"replies\").Set(\"attachCount=?\").Where(\"rid=?\").Prepare(),\n\t\ttopicUpdateAttachs: acc.Update(\"topics\").Set(\"attachCount=?\").Where(\"tid=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Revamp this to make it less of a copy-paste from the original code in the route\n// ! Lacks some attachment initialisation code\nfunc (s *DefaultAttachmentStore) GetForRenderRoute(filename string, sid int, sectionTable string) (*Attachment, error) {\n\ta := &Attachment{SectionID: sid}\n\te := s.getForRenderRoute.QueryRow(filename, sid, sectionTable).Scan(&a.SectionTable, &a.OriginID, &a.OriginTable, &a.UploadedBy, &a.Path)\n\t// TODO: Initialise attachment struct fields?\n\treturn a, e\n}\n\nfunc (s *DefaultAttachmentStore) MiniGetList(originTable string, originID int) (alist []*MiniAttachment, err error) {\n\trows, err := s.getByObj.Query(originTable, originID)\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\ta := &MiniAttachment{OriginID: originID}\n\t\terr := rows.Scan(&a.ID, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ta.Ext = strings.TrimPrefix(filepath.Ext(a.Path), \".\")\n\t\tif len(a.Ext) == 0 {\n\t\t\treturn nil, ErrCorruptAttachPath\n\t\t}\n\t\ta.Image = ImageFileExts.Contains(a.Ext)\n\t\talist = append(alist, a)\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\tif len(alist) == 0 {\n\t\terr = sql.ErrNoRows\n\t}\n\treturn alist, err\n}\n\nfunc (s *DefaultAttachmentStore) BulkMiniGetList(originTable string, ids []int) (amap map[int][]*MiniAttachment, err error) {\n\tif len(ids) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\tif len(ids) == 1 {\n\t\tres, err := s.MiniGetList(originTable, ids[0])\n\t\treturn map[int][]*MiniAttachment{ids[0]: res}, err\n\t}\n\n\tamap = make(map[int][]*MiniAttachment)\n\tvar buffer []*MiniAttachment\n\tvar currentID int\n\trows, err := qgen.NewAcc().Select(\"attachments\").Columns(\"attachID,sectionID,originID,uploadedBy,path\").Where(\"originTable=?\").In(\"originID\", ids).Orderby(\"originID ASC\").Query(originTable)\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\ta := &MiniAttachment{}\n\t\terr := rows.Scan(&a.ID, &a.SectionID, &a.OriginID, &a.UploadedBy, &a.Path)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ta.Ext = strings.TrimPrefix(filepath.Ext(a.Path), \".\")\n\t\tif len(a.Ext) == 0 {\n\t\t\treturn nil, ErrCorruptAttachPath\n\t\t}\n\t\ta.Image = ImageFileExts.Contains(a.Ext)\n\t\tif currentID == 0 {\n\t\t\tcurrentID = a.OriginID\n\t\t}\n\t\tif a.OriginID != currentID {\n\t\t\tif len(buffer) > 0 {\n\t\t\t\tamap[currentID] = buffer\n\t\t\t\tcurrentID = a.OriginID\n\t\t\t\tbuffer = nil\n\t\t\t}\n\t\t}\n\t\tbuffer = append(buffer, a)\n\t}\n\tif len(buffer) > 0 {\n\t\tamap[currentID] = buffer\n\t}\n\treturn amap, rows.Err()\n}\n\nfunc (s *DefaultAttachmentStore) FGet(id int) (*Attachment, error) {\n\ta := &Attachment{ID: id}\n\te := s.fget.QueryRow(id).Scan(&a.OriginTable, &a.OriginID, &a.SectionTable, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\ta.Ext = strings.TrimPrefix(filepath.Ext(a.Path), \".\")\n\tif len(a.Ext) == 0 {\n\t\treturn nil, ErrCorruptAttachPath\n\t}\n\ta.Image = ImageFileExts.Contains(a.Ext)\n\treturn a, nil\n}\n\nfunc (s *DefaultAttachmentStore) Get(id int) (*MiniAttachment, error) {\n\ta := &MiniAttachment{ID: id}\n\terr := s.get.QueryRow(id).Scan(&a.OriginID, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ta.Ext = strings.TrimPrefix(filepath.Ext(a.Path), \".\")\n\tif len(a.Ext) == 0 {\n\t\treturn nil, ErrCorruptAttachPath\n\t}\n\ta.Image = ImageFileExts.Contains(a.Ext)\n\treturn a, nil\n}\n\nfunc (s *DefaultAttachmentStore) Add(sectionID int, sectionTable string, originID int, originTable string, uploadedBy int, path, extra string) (int, error) {\n\tres, err := s.add.Exec(sectionID, sectionTable, originID, originTable, uploadedBy, path, extra)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlid, err := res.LastInsertId()\n\treturn int(lid), err\n}\n\nfunc (s *DefaultAttachmentStore) MoveTo(sectionID, originID int, originTable string) error {\n\t_, err := s.move.Exec(sectionID, originID, originTable)\n\treturn err\n}\n\nfunc (s *DefaultAttachmentStore) MoveToByExtra(sectionID int, originTable, extra string) error {\n\t_, err := s.moveByExtra.Exec(sectionID, originTable, extra)\n\treturn err\n}\n\nfunc (s *DefaultAttachmentStore) Count() (count int) {\n\te := s.count.QueryRow().Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultAttachmentStore) CountIn(originTable string, oid int) (count int) {\n\te := s.countIn.QueryRow(originTable, oid).Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultAttachmentStore) CountInPath(path string) (count int) {\n\te := s.countInPath.QueryRow(path).Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultAttachmentStore) Delete(id int) error {\n\t_, e := s.delete.Exec(id)\n\treturn e\n}\n\n// TODO: Split this out of this store\nfunc (s *DefaultAttachmentStore) AddLinked(otable string, oid int) (err error) {\n\tswitch otable {\n\tcase \"topics\":\n\t\t_, err = s.topicUpdateAttachs.Exec(s.CountIn(otable, oid), oid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = Topics.Reload(oid)\n\tcase \"replies\":\n\t\t_, err = s.replyUpdateAttachs.Exec(s.CountIn(otable, oid), oid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = Rstore.GetCache().Remove(oid)\n\t}\n\tif err == sql.ErrNoRows {\n\t\terr = nil\n\t}\n\treturn err\n}\n\n// TODO: Split this out of this store\nfunc (s *DefaultAttachmentStore) RemoveLinked(otable string, oid int) (err error) {\n\tswitch otable {\n\tcase \"topics\":\n\t\t_, err = s.topicUpdateAttachs.Exec(s.CountIn(otable, oid), oid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif tc := Topics.GetCache(); tc != nil {\n\t\t\ttc.Remove(oid)\n\t\t}\n\tcase \"replies\":\n\t\t_, err = s.replyUpdateAttachs.Exec(s.CountIn(otable, oid), oid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = Rstore.GetCache().Remove(oid)\n\t}\n\treturn err\n}\n\n// TODO: Add a table for the files and lock the file row when performing tasks related to the file\nfunc DeleteAttachment(aid int) error {\n\ta, err := Attachments.FGet(aid)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = deleteAttachment(a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_ = Attachments.RemoveLinked(a.OriginTable, a.OriginID)\n\treturn nil\n}\n\nfunc deleteAttachment(a *Attachment) error {\n\terr := Attachments.Delete(a.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcount := Attachments.CountInPath(a.Path)\n\tif count == 0 {\n\t\terr := os.Remove(\"./attachs/\" + a.Path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "common/audit_logs.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar ModLogs LogStore\nvar AdminLogs LogStore\n\ntype LogItem struct {\n\tAction      string\n\tElementID   int\n\tElementType string\n\tIP          string\n\tActorID     int\n\tDoneAt      string\n\tExtra       string\n}\n\ntype LogStore interface {\n\tCreate(action string, elementID int, elementType, ip string, actorID int) (err error)\n\tCreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error)\n\tCount() int\n\tGetOffset(offset, perPage int) (logs []LogItem, err error)\n}\n\ntype SQLModLogStore struct {\n\tcreate    *sql.Stmt\n\tcount     *sql.Stmt\n\tgetOffset *sql.Stmt\n}\n\nfunc NewModLogStore(acc *qgen.Accumulator) (*SQLModLogStore, error) {\n\tml := \"moderation_logs\"\n\t// TODO: Shorten name of ipaddress column to ip\n\tcols := \"action, elementID, elementType, ipaddress, actorID, doneAt, extra\"\n\treturn &SQLModLogStore{\n\t\tcreate:    acc.Insert(ml).Columns(cols).Fields(\"?,?,?,?,?,UTC_TIMESTAMP(),?\").Prepare(),\n\t\tcount:     acc.Count(ml).Prepare(),\n\t\tgetOffset: acc.Select(ml).Columns(cols).Orderby(\"doneAt DESC\").Limit(\"?,?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Make a store for this?\nfunc (s *SQLModLogStore) Create(action string, elementID int, elementType, ip string, actorID int) (err error) {\n\treturn s.CreateExtra(action, elementID, elementType, ip, actorID, \"\")\n}\n\nfunc (s *SQLModLogStore) CreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error) {\n\t_, err = s.create.Exec(action, elementID, elementType, ip, actorID, extra)\n\treturn err\n}\n\nfunc (s *SQLModLogStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\nfunc buildLogList(rows *sql.Rows) (logs []LogItem, err error) {\n\tfor rows.Next() {\n\t\tvar l LogItem\n\t\tvar doneAt time.Time\n\t\terr := rows.Scan(&l.Action, &l.ElementID, &l.ElementType, &l.IP, &l.ActorID, &doneAt, &l.Extra)\n\t\tif err != nil {\n\t\t\treturn logs, err\n\t\t}\n\t\tl.DoneAt = doneAt.Format(\"2006-01-02 15:04:05\")\n\t\tlogs = append(logs, l)\n\t}\n\treturn logs, rows.Err()\n}\n\nfunc (s *SQLModLogStore) GetOffset(offset, perPage int) (logs []LogItem, err error) {\n\trows, err := s.getOffset.Query(offset, perPage)\n\tif err != nil {\n\t\treturn logs, err\n\t}\n\tdefer rows.Close()\n\treturn buildLogList(rows)\n}\n\ntype SQLAdminLogStore struct {\n\tcreate    *sql.Stmt\n\tcount     *sql.Stmt\n\tgetOffset *sql.Stmt\n}\n\nfunc NewAdminLogStore(acc *qgen.Accumulator) (*SQLAdminLogStore, error) {\n\tal := \"administration_logs\"\n\tcols := \"action, elementID, elementType, ipaddress, actorID, doneAt, extra\"\n\treturn &SQLAdminLogStore{\n\t\tcreate:    acc.Insert(al).Columns(cols).Fields(\"?,?,?,?,?,UTC_TIMESTAMP(),?\").Prepare(),\n\t\tcount:     acc.Count(al).Prepare(),\n\t\tgetOffset: acc.Select(al).Columns(cols).Orderby(\"doneAt DESC\").Limit(\"?,?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Make a store for this?\nfunc (s *SQLAdminLogStore) Create(action string, elementID int, elementType, ip string, actorID int) (err error) {\n\treturn s.CreateExtra(action, elementID, elementType, ip, actorID, \"\")\n}\n\nfunc (s *SQLAdminLogStore) CreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error) {\n\t_, err = s.create.Exec(action, elementID, elementType, ip, actorID, extra)\n\treturn err\n}\n\nfunc (s *SQLAdminLogStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\nfunc (s *SQLAdminLogStore) GetOffset(offset, perPage int) (logs []LogItem, err error) {\n\trows, err := s.getOffset.Query(offset, perPage)\n\tif err != nil {\n\t\treturn logs, err\n\t}\n\tdefer rows.Close()\n\treturn buildLogList(rows)\n}\n"
  },
  {
    "path": "common/auth.go",
    "content": "/*\n*\n* Gosora Authentication Interface\n* Copyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"crypto/sha256\"\n\t\"crypto/subtle\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/common/gauth\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\n\t//\"golang.org/x/crypto/argon2\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// TODO: Write more authentication tests\nvar Auth AuthInt\n\nconst SaltLength int = 32\nconst SessionLength int = 80\n\n// ErrMismatchedHashAndPassword is thrown whenever a hash doesn't match it's unhashed password\nvar ErrMismatchedHashAndPassword = bcrypt.ErrMismatchedHashAndPassword\n\n// nolint\nvar ErrHashNotExist = errors.New(\"We don't recognise that hashing algorithm\")\nvar ErrTooFewHashParams = errors.New(\"You haven't provided enough hash parameters\")\n\n// ErrPasswordTooLong is silly, but we don't want bcrypt to bork on us\nvar ErrPasswordTooLong = errors.New(\"The password you selected is too long\")\nvar ErrWrongPassword = errors.New(\"That's not the correct password.\")\nvar ErrBadMFAToken = errors.New(\"I'm not sure where you got that from, but that's not a valid 2FA token\")\nvar ErrWrongMFAToken = errors.New(\"That 2FA token isn't correct\")\nvar ErrNoMFAToken = errors.New(\"This user doesn't have 2FA setup\")\nvar ErrSecretError = errors.New(\"There was a glitch in the system. Please contact your local administrator.\")\nvar ErrNoUserByName = errors.New(\"We couldn't find an account with that username.\")\nvar DefaultHashAlgo = \"bcrypt\" // Override this in the configuration file, not here\n\n//func(realPassword string, password string, salt string) (err error)\nvar CheckPasswordFuncs = map[string]func(string, string, string) error{\n\t\"bcrypt\": BcryptCheckPassword,\n\t//\"argon2\": Argon2CheckPassword,\n}\n\n//func(password string) (hashedPassword string, salt string, err error)\nvar GeneratePasswordFuncs = map[string]func(string) (string, string, error){\n\t\"bcrypt\": BcryptGeneratePassword,\n\t//\"argon2\": Argon2GeneratePassword,\n}\n\n// TODO: Redirect 2b to bcrypt too?\nvar HashPrefixes = map[string]string{\n\t\"$2a$\": \"bcrypt\",\n\t//\"argon2$\": \"argon2\",\n}\n\n// AuthInt is the main authentication interface.\ntype AuthInt interface {\n\tAuthenticate(name, password string) (uid int, err error, requiresExtraAuth bool)\n\tValidateMFAToken(mfaToken string, uid int) error\n\tLogout(w http.ResponseWriter, uid int)\n\tForceLogout(uid int) error\n\tSetCookies(w http.ResponseWriter, uid int, session string)\n\tSetProvisionalCookies(w http.ResponseWriter, uid int, session, signedSession string) // To avoid logging someone in until they've passed the MFA check\n\tGetCookies(r *http.Request) (uid int, session string, err error)\n\tSessionCheck(w http.ResponseWriter, r *http.Request) (u *User, halt bool)\n\tCreateSession(uid int) (session string, err error)\n\tCreateProvisionalSession(uid int) (provSession, signedSession string, err error) // To avoid logging someone in until they've passed the MFA check\n}\n\n// DefaultAuth is the default authenticator used by Gosora, may be swapped with an alternate authenticator in some situations. E.g. To support LDAP.\ntype DefaultAuth struct {\n\tlogin         *sql.Stmt\n\tlogout        *sql.Stmt\n\tupdateSession *sql.Stmt\n}\n\n// NewDefaultAuth is a factory for spitting out DefaultAuths\nfunc NewDefaultAuth() (*DefaultAuth, error) {\n\tacc := qgen.NewAcc()\n\treturn &DefaultAuth{\n\t\tlogin:         acc.Select(\"users\").Columns(\"uid, password, salt\").Where(\"name = ?\").Prepare(),\n\t\tlogout:        acc.Update(\"users\").Set(\"session = ''\").Where(\"uid = ?\").Prepare(),\n\t\tupdateSession: acc.Update(\"users\").Set(\"session = ?\").Where(\"uid = ?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// Authenticate checks if a specific username and password is valid and returns the UID for the corresponding user, if so. Otherwise, a user safe error.\n// IF MFA is enabled, then pass it back a flag telling the caller that authentication isn't complete yet\n// TODO: Find a better way of handling errors we don't want to reach the user\nfunc (auth *DefaultAuth) Authenticate(name, password string) (uid int, err error, requiresExtraAuth bool) {\n\tvar realPassword, salt string\n\terr = auth.login.QueryRow(name).Scan(&uid, &realPassword, &salt)\n\tif err == ErrNoRows {\n\t\treturn 0, ErrNoUserByName, false\n\t} else if err != nil {\n\t\tLogError(err)\n\t\treturn 0, ErrSecretError, false\n\t}\n\n\terr = CheckPassword(realPassword, password, salt)\n\tif err == ErrMismatchedHashAndPassword {\n\t\treturn 0, ErrWrongPassword, false\n\t} else if err != nil {\n\t\tLogError(err)\n\t\treturn 0, ErrSecretError, false\n\t}\n\n\t_, err = MFAstore.Get(uid)\n\tif err != sql.ErrNoRows && err != nil {\n\t\tLogError(err)\n\t\treturn 0, ErrSecretError, false\n\t}\n\tif err != ErrNoRows {\n\t\treturn uid, nil, true\n\t}\n\n\treturn uid, nil, false\n}\n\nfunc (auth *DefaultAuth) ValidateMFAToken(mfaToken string, uid int) error {\n\tmfaItem, err := MFAstore.Get(uid)\n\tif err != sql.ErrNoRows && err != nil {\n\t\tLogError(err)\n\t\treturn ErrSecretError\n\t}\n\tif err == ErrNoRows {\n\t\treturn ErrNoMFAToken\n\t}\n\n\tok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken)\n\tif err != nil {\n\t\treturn ErrBadMFAToken\n\t}\n\tif ok {\n\t\treturn nil\n\t}\n\n\tfor i, scratch := range mfaItem.Scratch {\n\t\tif subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 {\n\t\t\terr = mfaItem.BurnScratch(i)\n\t\t\tif err != nil {\n\t\t\t\tLogError(err)\n\t\t\t\treturn ErrSecretError\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn ErrWrongMFAToken\n}\n\n// ForceLogout logs the user out of every computer, not just the one they logged out of\nfunc (auth *DefaultAuth) ForceLogout(uid int) error {\n\t_, err := auth.logout.Exec(uid)\n\tif err != nil {\n\t\tLogError(err)\n\t\treturn ErrSecretError\n\t}\n\n\t// Flush the user out of the cache\n\tif uc := Users.GetCache(); uc != nil {\n\t\tuc.Remove(uid)\n\t}\n\treturn nil\n}\n\nfunc setCookie(w http.ResponseWriter, cookie *http.Cookie, sameSite string) {\n\tif v := cookie.String(); v != \"\" {\n\t\tswitch sameSite {\n\t\tcase \"lax\":\n\t\t\tv = v + \"; SameSite=lax\"\n\t\tcase \"strict\":\n\t\t\tv = v + \"; SameSite\"\n\t\t}\n\t\tw.Header().Add(\"Set-Cookie\", v)\n\t}\n}\n\nfunc deleteCookie(w http.ResponseWriter, cookie *http.Cookie) {\n\tcookie.MaxAge = -1\n\thttp.SetCookie(w, cookie)\n}\n\n// Logout logs you out of the computer you requested the logout for, but not the other computers you're logged in with\nfunc (auth *DefaultAuth) Logout(w http.ResponseWriter, _ int) {\n\tcookie := http.Cookie{Name: \"uid\", Value: \"\", Path: \"/\"}\n\tdeleteCookie(w, &cookie)\n\tcookie = http.Cookie{Name: \"session\", Value: \"\", Path: \"/\"}\n\tdeleteCookie(w, &cookie)\n}\n\n// TODO: Set the cookie domain\n// SetCookies sets the two cookies required for the current user to be recognised as a specific user in future requests\nfunc (auth *DefaultAuth) SetCookies(w http.ResponseWriter, uid int, session string) {\n\tcookie := http.Cookie{Name: \"uid\", Value: strconv.Itoa(uid), Path: \"/\", MaxAge: int(Year)}\n\tsetCookie(w, &cookie, \"lax\")\n\tcookie = http.Cookie{Name: \"session\", Value: session, Path: \"/\", MaxAge: int(Year)}\n\tsetCookie(w, &cookie, \"lax\")\n}\n\n// TODO: Set the cookie domain\n// SetProvisionalCookies sets the two cookies required for guests to be recognised as having passed the initial login but not having passed the additional checks (e.g. multi-factor authentication)\nfunc (auth *DefaultAuth) SetProvisionalCookies(w http.ResponseWriter, uid int, provSession, signedSession string) {\n\tcookie := http.Cookie{Name: \"uid\", Value: strconv.Itoa(uid), Path: \"/\", MaxAge: int(Year)}\n\tsetCookie(w, &cookie, \"lax\")\n\tcookie = http.Cookie{Name: \"provSession\", Value: provSession, Path: \"/\", MaxAge: int(Year)}\n\tsetCookie(w, &cookie, \"lax\")\n\tcookie = http.Cookie{Name: \"signedSession\", Value: signedSession, Path: \"/\", MaxAge: int(Year)}\n\tsetCookie(w, &cookie, \"lax\")\n}\n\n// GetCookies fetches the current user's session cookies\nfunc (auth *DefaultAuth) GetCookies(r *http.Request) (uid int, session string, err error) {\n\t// Are there any session cookies..?\n\tcookie, err := r.Cookie(\"uid\")\n\tif err != nil {\n\t\treturn 0, \"\", err\n\t}\n\tuid, err = strconv.Atoi(cookie.Value)\n\tif err != nil {\n\t\treturn 0, \"\", err\n\t}\n\tcookie, err = r.Cookie(\"session\")\n\tif err != nil {\n\t\treturn 0, \"\", err\n\t}\n\treturn uid, cookie.Value, err\n}\n\n// SessionCheck checks if a user has session cookies and whether they're valid\nfunc (auth *DefaultAuth) SessionCheck(w http.ResponseWriter, r *http.Request) (user *User, halt bool) {\n\tuid, session, err := auth.GetCookies(r)\n\tif err != nil {\n\t\treturn &GuestUser, false\n\t}\n\n\t// Is this session valid..?\n\tuser, err = Users.Get(uid)\n\tif err == ErrNoRows {\n\t\treturn &GuestUser, false\n\t} else if err != nil {\n\t\tInternalError(err, w, r)\n\t\treturn &GuestUser, true\n\t}\n\n\t// We need to do a constant time compare, otherwise someone might be able to deduce the session character by character based on how long it takes to do the comparison. Change this at your own peril.\n\tif user.Session == \"\" || subtle.ConstantTimeCompare([]byte(session), []byte(user.Session)) != 1 {\n\t\treturn &GuestUser, false\n\t}\n\n\treturn user, false\n}\n\n// CreateSession generates a new session to allow a remote client to stay logged in as a specific user\nfunc (auth *DefaultAuth) CreateSession(uid int) (session string, err error) {\n\tsession, err = GenerateSafeString(SessionLength)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t_, err = auth.updateSession.Exec(session, uid)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Flush the user data from the cache\n\tucache := Users.GetCache()\n\tif ucache != nil {\n\t\tucache.Remove(uid)\n\t}\n\treturn session, nil\n}\n\nfunc (auth *DefaultAuth) CreateProvisionalSession(uid int) (provSession, signedSession string, err error) {\n\tprovSession, err = GenerateSafeString(SessionLength)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\th := sha256.New()\n\th.Write([]byte(SessionSigningKeyBox.Load().(string)))\n\th.Write([]byte(provSession))\n\th.Write([]byte(strconv.Itoa(uid)))\n\treturn provSession, hex.EncodeToString(h.Sum(nil)), nil\n}\n\nfunc CheckPassword(realPassword, password, salt string) (err error) {\n\tblasted := strings.Split(realPassword, \"$\")\n\tprefix := blasted[0]\n\tif len(blasted) > 1 {\n\t\tprefix += \"$\" + blasted[1] + \"$\"\n\t}\n\talgo, ok := HashPrefixes[prefix]\n\tif !ok {\n\t\treturn ErrHashNotExist\n\t}\n\tchecker := CheckPasswordFuncs[algo]\n\treturn checker(realPassword, password, salt)\n}\n\nfunc GeneratePassword(password string) (hash, salt string, err error) {\n\tgen, ok := GeneratePasswordFuncs[DefaultHashAlgo]\n\tif !ok {\n\t\treturn \"\", \"\", ErrHashNotExist\n\t}\n\treturn gen(password)\n}\n\nfunc BcryptCheckPassword(realPassword, password, salt string) (err error) {\n\treturn bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password+salt))\n}\n\n// Note: The salt is in the hash, therefore the salt parameter is blank\nfunc BcryptGeneratePassword(password string) (hash, salt string, err error) {\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn string(hashedPassword), salt, nil\n}\n\n/*const (\n\targon2Time    uint32 = 3\n\targon2Memory  uint32 = 32 * 1024\n\targon2Threads uint8  = 4\n\targon2KeyLen  uint32 = 32\n)\n\nfunc Argon2CheckPassword(realPassword, password, salt string) (err error) {\n\tsplit := strings.Split(realPassword, \"$\")\n\t// TODO: Better validation\n\tif len(split) < 5 {\n\t\treturn ErrTooFewHashParams\n\t}\n\trealKey, _ := base64.StdEncoding.DecodeString(split[len(split)-1])\n\ttime, _ := strconv.Atoi(split[1])\n\tmemory, _ := strconv.Atoi(split[2])\n\tthreads, _ := strconv.Atoi(split[3])\n\tkeyLen, _ := strconv.Atoi(split[4])\n\tkey := argon2.Key([]byte(password), []byte(salt), uint32(time), uint32(memory), uint8(threads), uint32(keyLen))\n\tif subtle.ConstantTimeCompare(realKey, key) != 1 {\n\t\treturn ErrMismatchedHashAndPassword\n\t}\n\treturn nil\n}\n\nfunc Argon2GeneratePassword(password string) (hash, salt string, err error) {\n\tsbytes := make([]byte, SaltLength)\n\t_, err = rand.Read(sbytes)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tkey := argon2.Key([]byte(password), sbytes, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)\n\thash = base64.StdEncoding.EncodeToString(key)\n\treturn fmt.Sprintf(\"argon2$%d%d%d%d%s%s\", argon2Time, argon2Memory, argon2Threads, argon2KeyLen, salt, hash), string(sbytes), nil\n}\n*/\n\n// TODO: Test this with Google Authenticator proper\nfunc FriendlyGAuthSecret(secret string) (out string) {\n\tfor i, char := range secret {\n\t\tout += string(char)\n\t\tif (i+1)%4 == 0 {\n\t\t\tout += \" \"\n\t\t}\n\t}\n\treturn strings.TrimSpace(out)\n}\nfunc GenerateGAuthSecret() (string, error) {\n\treturn GenerateStd32SafeString(14)\n}\nfunc VerifyGAuthToken(secret, token string) (bool, error) {\n\ttrueToken, err := gauth.GetTOTPToken(secret)\n\treturn subtle.ConstantTimeCompare([]byte(trueToken), []byte(token)) == 1, err\n}\n"
  },
  {
    "path": "common/cache.go",
    "content": "package common\n\nimport \"errors\"\n\n// nolint\n// ErrCacheDesync is thrown whenever a piece of data, for instance, a user is out of sync with the database. Currently unused.\nvar ErrCacheDesync = errors.New(\"The cache is out of sync with the database.\") // TODO: A cross-server synchronisation mechanism\n\n// ErrStoreCapacityOverflow is thrown whenever a datastore reaches it's maximum hard capacity. I'm not sure if this error is actually used. It might be, we should check\nvar ErrStoreCapacityOverflow = errors.New(\"This datastore has reached it's maximum capacity.\") // nolint\n\n// nolint\ntype DataStore interface {\n\tDirtyGet(id int) interface{}\n\tGet(id int) (interface{}, error)\n\tBypassGet(id int) (interface{}, error)\n\t//Count() int\n}\n\n// nolint\ntype DataCache interface {\n\tCacheGet(id int) (interface{}, error)\n\tCacheGetUnsafe(id int) (interface{}, error)\n\tCacheSet(item interface{}) error\n\tCacheAdd(item interface{}) error\n\tCacheAddUnsafe(item interface{}) error\n\tCacheRemove(id int) error\n\tCacheRemoveUnsafe(id int) error\n\tReload(id int) error\n\tFlush()\n\tLength() int\n\tSetCapacity(capacity int)\n\tGetCapacity() int\n}\n"
  },
  {
    "path": "common/common.go",
    "content": "/*\n*\n*\tGosora Common Resources\n*\tCopyright Azareal 2018 - 2020\n*\n */\npackage common // import \"github.com/Azareal/Gosora/common\"\n\nimport (\n\t\"database/sql\"\n\t\"io\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tmeta \"github.com/Azareal/Gosora/common/meta\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar SoftwareVersion = Version{Major: 0, Minor: 3, Patch: 0, Tag: \"dev\"}\n\nvar Meta meta.MetaStore\n\n// nolint I don't want to write comments for each of these o.o\nconst Hour int = 60 * 60\nconst Day = Hour * 24\nconst Week = Day * 7\nconst Month = Day * 30\nconst Year = Day * 365\nconst Kilobyte int = 1024\nconst Megabyte = Kilobyte * 1024\nconst Gigabyte = Megabyte * 1024\nconst Terabyte = Gigabyte * 1024\nconst Petabyte = Terabyte * 1024\n\nvar StartTime time.Time\nvar GzipStartEtag string\nvar StartEtag string\nvar TmplPtrMap = make(map[string]interface{})\n\n// Anti-spam token with rotated key\nvar JSTokenBox atomic.Value              // TODO: Move this and some of these other globals somewhere else\nvar SessionSigningKeyBox atomic.Value    // For MFA to avoid hitting the database unneccessarily\nvar OldSessionSigningKeyBox atomic.Value // Just in case we've signed with a key that's about to go stale so we don't annoy the user too much\nvar IsDBDown int32 = 0                   // 0 = false, 1 = true. this is value which should be manipulated with package atomic for representing whether the database is down so we don't spam the log with lots of redundant errors\n\n// ErrNoRows is an alias of sql.ErrNoRows, just in case we end up with non-database/sql datastores\nvar ErrNoRows = sql.ErrNoRows\n\n//var StrSlicePool sync.Pool\n\n// ? - Make this more customisable?\n/*var ExternalSites = map[string]string{\n\t\"YT\": \"https://www.youtube.com/\",\n}*/\n\n// TODO: Make this more customisable\nvar SpammyDomainBits = []string{\"porn\", \"sex\", \"acup\", \"nude\", \"milf\", \"tits\", \"vape\", \"busty\", \"kink\", \"lingerie\", \"strapon\", \"problog\", \"fet\", \"xblog\", \"blogin\", \"blognetwork\", \"relayblog\"}\n\nvar Chrome, Firefox int // ! Temporary Hack for http push\nvar SimpleBots []int    // ! Temporary hack to stop semrush, ahrefs, python bots and other from wasting resources\n\ntype StringList []string\n\n// ? - Should we allow users to upload .php or .go files? It could cause security issues. We could store them with a mangled extension to render them inert\n// TODO: Let admins manage this from the Control Panel\n// apng is commented out for now, as we have no way of re-encoding it into a smaller file\nvar AllowedFileExts = StringList{\n\t\"png\", \"jpg\", \"jpe\", \"jpeg\", \"jif\", \"jfi\", \"jfif\", \"svg\", \"bmp\", \"gif\", \"tiff\", \"tif\", \"webp\", \"apng\", \"avif\", \"flif\", \"heif\", \"heic\", \"bpg\", // images (encodable) + apng (browser support) + bpg + avif + flif + heif / heic\n\n\t\"txt\", \"xml\", \"json\", \"yaml\", \"toml\", \"ini\", \"md\", \"html\", \"rtf\", \"js\", \"py\", \"rb\", \"css\", \"scss\", \"less\", \"eqcss\", \"pcss\", \"java\", \"ts\", \"cs\", \"c\", \"cc\", \"cpp\", \"cxx\", \"C\", \"c++\", \"h\", \"hh\", \"hpp\", \"hxx\", \"h++\", \"rs\", \"rlib\", \"htaccess\", \"gitignore\", /*\"go\",\"php\",*/ // text\n\n\t\"wav\", \"mp3\", \"oga\", \"m4a\", \"flac\", \"ac3\", \"aac\", \"opus\", // audio\n\n\t\"mp4\", \"avi\", \"ogg\", \"ogv\", \"ogx\", \"wmv\", \"webm\", \"flv\", \"f4v\", \"xvid\", \"mov\", \"movie\", \"qt\", // video\n\n\t\"otf\", \"woff2\", \"woff\", \"ttf\", \"eot\", // fonts\n\n\t\"bz2\", \"zip\", \"zipx\", \"gz\", \"7z\", \"tar\", \"cab\", \"rar\", \"kgb\", \"pea\", \"xz\", \"zz\", \"tgz\", \"xpi\", // archives\n\n\t\"docx\", \"pdf\", // documents\n}\nvar ImageFileExts = StringList{\n\t\"png\", \"jpg\", \"jpe\", \"jpeg\", \"jif\", \"jfi\", \"jfif\", \"svg\", \"bmp\", \"gif\", \"tiff\", \"tif\", \"webp\", /* \"apng\", \"bpg\", \"avif\", */\n}\nvar TextFileExts = StringList{\n\t\"txt\", \"xml\", \"json\", \"yaml\", \"toml\", \"ini\", \"md\", \"html\", \"rtf\", \"js\", \"py\", \"rb\", \"css\", \"scss\", \"less\", \"eqcss\", \"pcss\", \"java\", \"ts\", \"cs\", \"c\", \"cc\", \"cpp\", \"cxx\", \"C\", \"c++\", \"h\", \"hh\", \"hpp\", \"hxx\", \"h++\", \"rs\", \"rlib\", \"htaccess\", \"gitignore\", /*\"go\",\"php\",*/\n}\nvar VideoFileExts = StringList{\n\t\"mp4\", \"avi\", \"ogg\", \"ogv\", \"ogx\", \"wmv\", \"webm\", \"flv\", \"f4v\", \"xvid\", \"mov\", \"movie\", \"qt\",\n}\nvar WebVideoFileExts = StringList{\n\t\"mp4\", \"avi\", \"ogg\", \"ogv\", \"webm\",\n}\nvar WebAudioFileExts = StringList{\n\t\"wav\", \"mp3\", \"oga\", \"m4a\", \"flac\",\n}\nvar ArchiveFileExts = StringList{\n\t\"bz2\", \"zip\", \"zipx\", \"gz\", \"7z\", \"tar\", \"cab\", \"rar\", \"kgb\", \"pea\", \"xz\", \"zz\", \"tgz\", \"xpi\",\n}\nvar ExecutableFileExts = StringList{\n\t\"exe\", \"jar\", \"phar\", \"shar\", \"iso\", \"apk\", \"deb\",\n}\n\nfunc init() {\n\tJSTokenBox.Store(\"\")\n\tSessionSigningKeyBox.Store(\"\")\n\tOldSessionSigningKeyBox.Store(\"\")\n}\n\n// TODO: Write a test for this\nfunc (sl StringList) Contains(needle string) bool {\n\tfor _, it := range sl {\n\t\tif it == needle {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n/*var DbTables []string\nvar TableToID = make(map[string]int)\nvar IDToTable = make(map[int]string)\n\nfunc InitTables(acc *qgen.Accumulator) error {\n\tstmt := acc.Select(\"tables\").Columns(\"id,name\").Prepare()\n\tif e := acc.FirstError(); e != nil {\n\t\treturn e\n\t}\n\treturn eachall(stmt, func(r *sql.Rows) error {\n\t\tvar id int\n\t\tvar name string\n\t\tif e := r.Scan(&id, &name); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tTableToID[name] = id\n\t\tIDToTable[id] = name\n\t\treturn nil\n\t})\n}*/\n\ntype dbInits []func(acc *qgen.Accumulator) error\n\nvar DbInits dbInits\n\nfunc (inits dbInits) Run() error {\n\tfor _, i := range inits {\n\t\tif e := i(qgen.NewAcc()); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (inits dbInits) Add(i ...func(acc *qgen.Accumulator) error) {\n\tDbInits = dbInits(append(DbInits, i...))\n}\n\n// TODO: Add a graceful shutdown function\nfunc StoppedServer(msg ...interface{}) {\n\t//log.Print(\"stopped server\")\n\tStopServerChan <- msg\n}\n\nvar StopServerChan = make(chan []interface{})\n\nvar LogWriter = io.MultiWriter(os.Stdout)\nvar ErrLogWriter = io.MultiWriter(os.Stderr)\nvar ErrLogger = log.New(os.Stderr, \"\", log.LstdFlags)\n\nfunc DebugDetail(args ...interface{}) {\n\tif Dev.SuperDebug {\n\t\tlog.Print(args...)\n\t}\n}\n\nfunc DebugDetailf(str string, args ...interface{}) {\n\tif Dev.SuperDebug {\n\t\tlog.Printf(str, args...)\n\t}\n}\n\nfunc DebugLog(args ...interface{}) {\n\tif Dev.DebugMode {\n\t\tlog.Print(args...)\n\t}\n}\n\nfunc DebugLogf(str string, args ...interface{}) {\n\tif Dev.DebugMode {\n\t\tlog.Printf(str, args...)\n\t}\n}\n\nfunc Log(args ...interface{}) {\n\tlog.Print(args...)\n}\nfunc Logf(str string, args ...interface{}) {\n\tlog.Printf(str, args...)\n}\nfunc Err(args ...interface{}) {\n\tErrLogger.Print(args...)\n}\n\nfunc Count(stmt *sql.Stmt) (count int) {\n\te := stmt.QueryRow().Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\nfunc Countf(stmt *sql.Stmt, args ...interface{}) (count int) {\n\te := stmt.QueryRow(args...).Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\nfunc Createf(stmt *sql.Stmt, args ...interface{}) (id int, e error) {\n\tres, e := stmt.Exec(args...)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tid64, e := res.LastInsertId()\n\treturn int(id64), e\n}\n\nfunc eachall(stmt *sql.Stmt, f func(r *sql.Rows) error) error {\n\trows, e := stmt.Query()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tif e := f(rows); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nvar qcache = []string{0: \"?\", 1: \"?,?\", 2: \"?,?,?\", 3: \"?,?,?,?\", 4: \"?,?,?,?,?\", 5: \"?,?,?,?,?,?\", 6: \"?,?,?,?,?,?,?\", 7: \"?,?,?,?,?,?,?,?\", 8: \"?,?,?,?,?,?,?,?,?\"}\n\nfunc inqbuild(ids []int) ([]interface{}, string) {\n\tif len(ids) < 8 {\n\t\tidList := make([]interface{}, len(ids))\n\t\tfor i, id := range ids {\n\t\t\tidList[i] = strconv.Itoa(id)\n\t\t}\n\t\treturn idList, qcache[len(ids)-1]\n\t}\n\n\tvar sb strings.Builder\n\tsb.Grow((len(ids) * 2) - 1)\n\tidList := make([]interface{}, len(ids))\n\tfor i, id := range ids {\n\t\tidList[i] = strconv.Itoa(id)\n\t\tif i == 0 {\n\t\t\tsb.WriteRune('?')\n\t\t} else {\n\t\t\tsb.WriteString(\",?\")\n\t\t}\n\t}\n\treturn idList, sb.String()\n}\n\nfunc inqbuild2(count int) string {\n\tif count <= 8 {\n\t\treturn qcache[count-1]\n\t}\n\tvar sb strings.Builder\n\tsb.Grow((count * 2) - 1)\n\tfor i := 0; i < count; i++ {\n\t\tif i == 0 {\n\t\t\tsb.WriteRune('?')\n\t\t} else {\n\t\t\tsb.WriteString(\",?\")\n\t\t}\n\t}\n\treturn sb.String()\n}\n\nfunc inqbuildstr(strs []string) ([]interface{}, string) {\n\tif len(strs) < 8 {\n\t\tidList := make([]interface{}, len(strs))\n\t\tfor i, id := range strs {\n\t\t\tidList[i] = id\n\t\t}\n\t\treturn idList, qcache[len(strs)-1]\n\t}\n\n\tvar sb strings.Builder\n\tsb.Grow((len(strs) * 2) - 1)\n\tidList := make([]interface{}, len(strs))\n\tfor i, id := range strs {\n\t\tidList[i] = id\n\t\tif i == 0 {\n\t\t\tsb.WriteRune('?')\n\t\t} else {\n\t\t\tsb.WriteString(\",?\")\n\t\t}\n\t}\n\treturn idList, sb.String()\n}\n\nvar ConnWatch = &ConnWatcher{}\n\ntype ConnWatcher struct {\n\tn int64\n}\n\nfunc (cw *ConnWatcher) StateChange(conn net.Conn, state http.ConnState) {\n\tswitch state {\n\tcase http.StateNew:\n\t\tatomic.AddInt64(&cw.n, 1)\n\tcase http.StateHijacked, http.StateClosed:\n\t\tatomic.AddInt64(&cw.n, -1)\n\t}\n}\n\nfunc (cw *ConnWatcher) Count() int {\n\treturn int(atomic.LoadInt64(&cw.n))\n}\n\nfunc EatPanics() {\n\tif r := recover(); r != nil {\n\t\tlog.Print(r)\n\t\tdebug.PrintStack()\n\t\tlog.Fatal(\"Fatal error.\")\n\t}\n}\n"
  },
  {
    "path": "common/common_easyjson.tgo",
    "content": "// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.\n\npackage common\n\nimport (\n\tjson \"encoding/json\"\n\teasyjson \"github.com/mailru/easyjson\"\n\tjlexer \"github.com/mailru/easyjson/jlexer\"\n\tjwriter \"github.com/mailru/easyjson/jwriter\"\n)\n\n// suppress unused package warning\nvar (\n\t_ *json.RawMessage\n\t_ *jlexer.Lexer\n\t_ *jwriter.Writer\n\t_ easyjson.Marshaler\n)\n\nfunc easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(in *jlexer.Lexer, out *WsTopicList) {\n\tisTopLevel := in.IsStart()\n\tif in.IsNull() {\n\t\tif isTopLevel {\n\t\t\tin.Consumed()\n\t\t}\n\t\tin.Skip()\n\t\treturn\n\t}\n\tin.Delim('{')\n\tfor !in.IsDelim('}') {\n\t\tkey := in.UnsafeString()\n\t\tin.WantColon()\n\t\tif in.IsNull() {\n\t\t\tin.Skip()\n\t\t\tin.WantComma()\n\t\t\tcontinue\n\t\t}\n\t\tswitch key {\n\t\tcase \"Topics\":\n\t\t\tif in.IsNull() {\n\t\t\t\tin.Skip()\n\t\t\t\tout.Topics = nil\n\t\t\t} else {\n\t\t\t\tin.Delim('[')\n\t\t\t\tif out.Topics == nil {\n\t\t\t\t\tif !in.IsDelim(']') {\n\t\t\t\t\t\tout.Topics = make([]*WsTopicsRow, 0, 8)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tout.Topics = []*WsTopicsRow{}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tout.Topics = (out.Topics)[:0]\n\t\t\t\t}\n\t\t\t\tfor !in.IsDelim(']') {\n\t\t\t\t\tvar v1 *WsTopicsRow\n\t\t\t\t\tif in.IsNull() {\n\t\t\t\t\t\tin.Skip()\n\t\t\t\t\t\tv1 = nil\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif v1 == nil {\n\t\t\t\t\t\t\tv1 = new(WsTopicsRow)\n\t\t\t\t\t\t}\n\t\t\t\t\t\teasyjsonC803d3e7DecodeGithubComAzarealGosoraCommon1(in, v1)\n\t\t\t\t\t}\n\t\t\t\t\tout.Topics = append(out.Topics, v1)\n\t\t\t\t\tin.WantComma()\n\t\t\t\t}\n\t\t\t\tin.Delim(']')\n\t\t\t}\n\t\tcase \"LastPage\":\n\t\t\tout.LastPage = int(in.Int())\n\t\tcase \"LastUpdate\":\n\t\t\tout.LastUpdate = int64(in.Int64())\n\t\tdefault:\n\t\t\tin.SkipRecursive()\n\t\t}\n\t\tin.WantComma()\n\t}\n\tin.Delim('}')\n\tif isTopLevel {\n\t\tin.Consumed()\n\t}\n}\nfunc easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(out *jwriter.Writer, in WsTopicList) {\n\tout.RawByte('{')\n\tfirst := true\n\t_ = first\n\t{\n\t\tconst prefix string = \",\\\"Topics\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tif in.Topics == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {\n\t\t\tout.RawString(\"null\")\n\t\t} else {\n\t\t\tout.RawByte('[')\n\t\t\tfor v2, v3 := range in.Topics {\n\t\t\t\tif v2 > 0 {\n\t\t\t\t\tout.RawByte(',')\n\t\t\t\t}\n\t\t\t\tif v3 == nil {\n\t\t\t\t\tout.RawString(\"null\")\n\t\t\t\t} else {\n\t\t\t\t\teasyjsonC803d3e7EncodeGithubComAzarealGosoraCommon1(out, *v3)\n\t\t\t\t}\n\t\t\t}\n\t\t\tout.RawByte(']')\n\t\t}\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastPage\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.LastPage))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastUpdate\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int64(int64(in.LastUpdate))\n\t}\n\tout.RawByte('}')\n}\n\n// MarshalJSON supports json.Marshaler interface\nfunc (v WsTopicList) MarshalJSON() ([]byte, error) {\n\tw := jwriter.Writer{}\n\teasyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(&w, v)\n\treturn w.Buffer.BuildBytes(), w.Error\n}\n\n// MarshalEasyJSON supports easyjson.Marshaler interface\nfunc (v WsTopicList) MarshalEasyJSON(w *jwriter.Writer) {\n\teasyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(w, v)\n}\n\n// UnmarshalJSON supports json.Unmarshaler interface\nfunc (v *WsTopicList) UnmarshalJSON(data []byte) error {\n\tr := jlexer.Lexer{Data: data}\n\teasyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(&r, v)\n\treturn r.Error()\n}\n\n// UnmarshalEasyJSON supports easyjson.Unmarshaler interface\nfunc (v *WsTopicList) UnmarshalEasyJSON(l *jlexer.Lexer) {\n\teasyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(l, v)\n}\nfunc easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon1(in *jlexer.Lexer, out *WsTopicsRow) {\n\tisTopLevel := in.IsStart()\n\tif in.IsNull() {\n\t\tif isTopLevel {\n\t\t\tin.Consumed()\n\t\t}\n\t\tin.Skip()\n\t\treturn\n\t}\n\tin.Delim('{')\n\tfor !in.IsDelim('}') {\n\t\tkey := in.UnsafeString()\n\t\tin.WantColon()\n\t\tif in.IsNull() {\n\t\t\tin.Skip()\n\t\t\tin.WantComma()\n\t\t\tcontinue\n\t\t}\n\t\tswitch key {\n\t\tcase \"ID\":\n\t\t\tout.ID = int(in.Int())\n\t\tcase \"Link\":\n\t\t\tout.Link = string(in.String())\n\t\tcase \"Title\":\n\t\t\tout.Title = string(in.String())\n\t\tcase \"CreatedBy\":\n\t\t\tout.CreatedBy = int(in.Int())\n\t\tcase \"IsClosed\":\n\t\t\tout.IsClosed = bool(in.Bool())\n\t\tcase \"Sticky\":\n\t\t\tout.Sticky = bool(in.Bool())\n\t\tcase \"CreatedAt\":\n\t\t\tif data := in.Raw(); in.Ok() {\n\t\t\t\tin.AddError((out.CreatedAt).UnmarshalJSON(data))\n\t\t\t}\n\t\tcase \"LastReplyAt\":\n\t\t\tif data := in.Raw(); in.Ok() {\n\t\t\t\tin.AddError((out.LastReplyAt).UnmarshalJSON(data))\n\t\t\t}\n\t\tcase \"RelativeLastReplyAt\":\n\t\t\tout.RelativeLastReplyAt = string(in.String())\n\t\tcase \"LastReplyBy\":\n\t\t\tout.LastReplyBy = int(in.Int())\n\t\tcase \"LastReplyID\":\n\t\t\tout.LastReplyID = int(in.Int())\n\t\tcase \"ParentID\":\n\t\t\tout.ParentID = int(in.Int())\n\t\tcase \"ViewCount\":\n\t\t\tout.ViewCount = int64(in.Int64())\n\t\tcase \"PostCount\":\n\t\t\tout.PostCount = int(in.Int())\n\t\tcase \"LikeCount\":\n\t\t\tout.LikeCount = int(in.Int())\n\t\tcase \"AttachCount\":\n\t\t\tout.AttachCount = int(in.Int())\n\t\tcase \"ClassName\":\n\t\t\tout.ClassName = string(in.String())\n\t\tcase \"Creator\":\n\t\t\tif in.IsNull() {\n\t\t\t\tin.Skip()\n\t\t\t\tout.Creator = nil\n\t\t\t} else {\n\t\t\t\tif out.Creator == nil {\n\t\t\t\t\tout.Creator = new(WsJSONUser)\n\t\t\t\t}\n\t\t\t\teasyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in, out.Creator)\n\t\t\t}\n\t\tcase \"LastUser\":\n\t\t\tif in.IsNull() {\n\t\t\t\tin.Skip()\n\t\t\t\tout.LastUser = nil\n\t\t\t} else {\n\t\t\t\tif out.LastUser == nil {\n\t\t\t\t\tout.LastUser = new(WsJSONUser)\n\t\t\t\t}\n\t\t\t\teasyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in, out.LastUser)\n\t\t\t}\n\t\tcase \"ForumName\":\n\t\t\tout.ForumName = string(in.String())\n\t\tcase \"ForumLink\":\n\t\t\tout.ForumLink = string(in.String())\n\t\tdefault:\n\t\t\tin.SkipRecursive()\n\t\t}\n\t\tin.WantComma()\n\t}\n\tin.Delim('}')\n\tif isTopLevel {\n\t\tin.Consumed()\n\t}\n}\nfunc easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon1(out *jwriter.Writer, in WsTopicsRow) {\n\tout.RawByte('{')\n\tfirst := true\n\t_ = first\n\t{\n\t\tconst prefix string = \",\\\"ID\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.ID))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Link\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.Link))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Title\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.Title))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"CreatedBy\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.CreatedBy))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"IsClosed\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Bool(bool(in.IsClosed))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Sticky\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Bool(bool(in.Sticky))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"CreatedAt\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Raw((in.CreatedAt).MarshalJSON())\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastReplyAt\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Raw((in.LastReplyAt).MarshalJSON())\n\t}\n\t{\n\t\tconst prefix string = \",\\\"RelativeLastReplyAt\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.RelativeLastReplyAt))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastReplyBy\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.LastReplyBy))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastReplyID\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.LastReplyID))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"ParentID\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.ParentID))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"ViewCount\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int64(int64(in.ViewCount))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"PostCount\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.PostCount))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LikeCount\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.LikeCount))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"AttachCount\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.AttachCount))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"ClassName\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.ClassName))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Creator\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tif in.Creator == nil {\n\t\t\tout.RawString(\"null\")\n\t\t} else {\n\t\t\teasyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out, *in.Creator)\n\t\t}\n\t}\n\t{\n\t\tconst prefix string = \",\\\"LastUser\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tif in.LastUser == nil {\n\t\t\tout.RawString(\"null\")\n\t\t} else {\n\t\t\teasyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out, *in.LastUser)\n\t\t}\n\t}\n\t{\n\t\tconst prefix string = \",\\\"ForumName\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.ForumName))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"ForumLink\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.ForumLink))\n\t}\n\tout.RawByte('}')\n}\nfunc easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in *jlexer.Lexer, out *WsJSONUser) {\n\tisTopLevel := in.IsStart()\n\tif in.IsNull() {\n\t\tif isTopLevel {\n\t\t\tin.Consumed()\n\t\t}\n\t\tin.Skip()\n\t\treturn\n\t}\n\tin.Delim('{')\n\tfor !in.IsDelim('}') {\n\t\tkey := in.UnsafeString()\n\t\tin.WantColon()\n\t\tif in.IsNull() {\n\t\t\tin.Skip()\n\t\t\tin.WantComma()\n\t\t\tcontinue\n\t\t}\n\t\tswitch key {\n\t\tcase \"ID\":\n\t\t\tout.ID = int(in.Int())\n\t\tcase \"Link\":\n\t\t\tout.Link = string(in.String())\n\t\tcase \"Name\":\n\t\t\tout.Name = string(in.String())\n\t\tcase \"Group\":\n\t\t\tout.Group = int(in.Int())\n\t\tcase \"IsMod\":\n\t\t\tout.IsMod = bool(in.Bool())\n\t\tcase \"Avatar\":\n\t\t\tout.Avatar = string(in.String())\n\t\tcase \"MicroAvatar\":\n\t\t\tout.MicroAvatar = string(in.String())\n\t\tcase \"Level\":\n\t\t\tout.Level = int(in.Int())\n\t\tcase \"Score\":\n\t\t\tout.Score = int(in.Int())\n\t\tcase \"Liked\":\n\t\t\tout.Liked = int(in.Int())\n\t\tdefault:\n\t\t\tin.SkipRecursive()\n\t\t}\n\t\tin.WantComma()\n\t}\n\tin.Delim('}')\n\tif isTopLevel {\n\t\tin.Consumed()\n\t}\n}\nfunc easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out *jwriter.Writer, in WsJSONUser) {\n\tout.RawByte('{')\n\tfirst := true\n\t_ = first\n\t{\n\t\tconst prefix string = \",\\\"ID\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.ID))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Link\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.Link))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Name\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.Name))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Group\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.Group))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"IsMod\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Bool(bool(in.IsMod))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Avatar\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.Avatar))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"MicroAvatar\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.String(string(in.MicroAvatar))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Level\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.Level))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Score\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.Score))\n\t}\n\t{\n\t\tconst prefix string = \",\\\"Liked\\\":\"\n\t\tif first {\n\t\t\tfirst = false\n\t\t\tout.RawString(prefix[1:])\n\t\t} else {\n\t\t\tout.RawString(prefix)\n\t\t}\n\t\tout.Int(int(in.Liked))\n\t}\n\tout.RawByte('}')\n}\n"
  },
  {
    "path": "common/conversations.go",
    "content": "package common\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t//\"log\"\n\n\t\"database/sql\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Convos ConversationStore\nvar convoStmts ConvoStmts\n\ntype ConvoStmts struct {\n\tfetchPost  *sql.Stmt\n\tgetPosts   *sql.Stmt\n\tcountPosts *sql.Stmt\n\tedit       *sql.Stmt\n\tcreate     *sql.Stmt\n\tdelete     *sql.Stmt\n\thas        *sql.Stmt\n\n\teditPost   *sql.Stmt\n\tcreatePost *sql.Stmt\n\tdeletePost *sql.Stmt\n\n\tgetUsers *sql.Stmt\n}\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tcpo := \"conversations_posts\"\n\t\tconvoStmts = ConvoStmts{\n\t\t\tfetchPost:  acc.Select(cpo).Columns(\"cid,body,post,createdBy\").Where(\"pid=?\").Prepare(),\n\t\t\tgetPosts:   acc.Select(cpo).Columns(\"pid,body,post,createdBy\").Where(\"cid=?\").Limit(\"?,?\").Prepare(),\n\t\t\tcountPosts: acc.Count(cpo).Where(\"cid=?\").Prepare(),\n\t\t\tedit:       acc.Update(\"conversations\").Set(\"lastReplyBy=?,lastReplyAt=?\").Where(\"cid=?\").Prepare(),\n\t\t\tcreate:     acc.Insert(\"conversations\").Columns(\"createdAt,lastReplyAt\").Fields(\"UTC_TIMESTAMP(),UTC_TIMESTAMP()\").Prepare(),\n\t\t\thas:        acc.Count(\"conversations_participants\").Where(\"uid=? AND cid=?\").Prepare(),\n\n\t\t\teditPost:   acc.Update(cpo).Set(\"body=?,post=?\").Where(\"pid=?\").Prepare(),\n\t\t\tcreatePost: acc.Insert(cpo).Columns(\"cid,body,post,createdBy\").Fields(\"?,?,?,?\").Prepare(),\n\t\t\tdeletePost: acc.Delete(cpo).Where(\"pid=?\").Prepare(),\n\n\t\t\tgetUsers: acc.Select(\"conversations_participants\").Columns(\"uid\").Where(\"cid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\ntype Conversation struct {\n\tID          int\n\tLink        string\n\tCreatedBy   int\n\tCreatedAt   time.Time\n\tLastReplyBy int\n\tLastReplyAt time.Time\n}\n\nfunc (co *Conversation) Posts(offset, itemsPerPage int) (posts []*ConversationPost, err error) {\n\trows, err := convoStmts.getPosts.Query(co.ID, offset, itemsPerPage)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tp := &ConversationPost{CID: co.ID}\n\t\terr := rows.Scan(&p.ID, &p.Body, &p.Post, &p.CreatedBy)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tp, err = ConvoPostProcess.OnLoad(p)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tposts = append(posts, p)\n\t}\n\n\treturn posts, rows.Err()\n}\n\nfunc (co *Conversation) PostsCount() (count int) {\n\treturn Countf(convoStmts.countPosts, co.ID)\n}\n\nfunc (co *Conversation) Uids() (ids []int, err error) {\n\trows, e := convoStmts.getUsers.Query(co.ID)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar id int\n\t\tif e := rows.Scan(&id); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\treturn ids, rows.Err()\n}\n\nfunc (co *Conversation) Has(uid int) (in bool) {\n\treturn Countf(convoStmts.has, uid, co.ID) > 0\n}\n\nfunc (co *Conversation) Update() error {\n\t_, err := convoStmts.edit.Exec(co.CreatedAt, co.LastReplyBy, co.LastReplyAt, co.ID)\n\treturn err\n}\n\nfunc (co *Conversation) Create() (int, error) {\n\tres, err := convoStmts.create.Exec()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n\nfunc BuildConvoURL(coid int) string {\n\treturn \"/user/convo/\" + strconv.Itoa(coid)\n}\n\ntype ConversationExtra struct {\n\t*Conversation\n\tUsers []*User\n}\n\ntype ConversationStore interface {\n\tGet(id int) (*Conversation, error)\n\tGetUser(uid, offset int) (cos []*Conversation, err error)\n\tGetUserExtra(uid, offset int) (cos []*ConversationExtra, err error)\n\tGetUserCount(uid int) (count int)\n\tDelete(id int) error\n\tCount() (count int)\n\tCreate(content string, createdBy int, participants []int) (int, error)\n}\n\ntype DefaultConversationStore struct {\n\tget                *sql.Stmt\n\tgetUser            *sql.Stmt\n\tgetUserCount       *sql.Stmt\n\tdelete             *sql.Stmt\n\tdeletePosts        *sql.Stmt\n\tdeleteParticipants *sql.Stmt\n\tcreate             *sql.Stmt\n\taddParticipant     *sql.Stmt\n\tcount              *sql.Stmt\n}\n\nfunc NewDefaultConversationStore(acc *qgen.Accumulator) (*DefaultConversationStore, error) {\n\tco := \"conversations\"\n\treturn &DefaultConversationStore{\n\t\tget:                acc.Select(co).Columns(\"createdBy,createdAt,lastReplyBy,lastReplyAt\").Where(\"cid=?\").Prepare(),\n\t\tgetUser:            acc.SimpleInnerJoin(\"conversations_participants AS cp\", \"conversations AS c\", \"cp.cid, c.createdBy, c.createdAt, c.lastReplyBy, c.lastReplyAt\", \"cp.cid=c.cid\", \"cp.uid=?\", \"c.lastReplyAt DESC, c.createdAt DESC, c.cid DESC\", \"?,?\"),\n\t\tgetUserCount:       acc.Count(\"conversations_participants\").Where(\"uid=?\").Prepare(),\n\t\tdelete:             acc.Delete(co).Where(\"cid=?\").Prepare(),\n\t\tdeletePosts:        acc.Delete(\"conversations_posts\").Where(\"cid=?\").Prepare(),\n\t\tdeleteParticipants: acc.Delete(\"conversations_participants\").Where(\"cid=?\").Prepare(),\n\t\tcreate:             acc.Insert(co).Columns(\"createdBy,createdAt,lastReplyBy,lastReplyAt\").Fields(\"?,UTC_TIMESTAMP(),?,UTC_TIMESTAMP()\").Prepare(),\n\t\taddParticipant:     acc.Insert(\"conversations_participants\").Columns(\"uid,cid\").Fields(\"?,?\").Prepare(),\n\t\tcount:              acc.Count(co).Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultConversationStore) Get(id int) (*Conversation, error) {\n\tco := &Conversation{ID: id}\n\terr := s.get.QueryRow(id).Scan(&co.CreatedBy, &co.CreatedAt, &co.LastReplyBy, &co.LastReplyAt)\n\tco.Link = BuildConvoURL(co.ID)\n\treturn co, err\n}\n\nfunc (s *DefaultConversationStore) GetUser(uid, offset int) (cos []*Conversation, err error) {\n\trows, err := s.getUser.Query(uid, offset, Config.ItemsPerPage)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tco := &Conversation{}\n\t\terr := rows.Scan(&co.ID, &co.CreatedBy, &co.CreatedAt, &co.LastReplyBy, &co.LastReplyAt)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tco.Link = BuildConvoURL(co.ID)\n\t\tcos = append(cos, co)\n\t}\n\terr = rows.Err()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(cos) == 0 {\n\t\terr = sql.ErrNoRows\n\t}\n\treturn cos, err\n}\n\nfunc (s *DefaultConversationStore) GetUserExtra(uid, offset int) (cos []*ConversationExtra, err error) {\n\traw, err := s.GetUser(uid, offset)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t//log.Printf(\"raw: %+v\\n\", raw)\n\n\tif len(raw) == 1 {\n\t\t//log.Print(\"r0b2\")\n\t\tuids, err := raw[0].Uids()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t//log.Println(\"r1b2\")\n\t\tumap, err := Users.BulkGetMap(uids)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t//log.Println(\"r2b2\")\n\t\tusers := make([]*User, len(umap))\n\t\tvar i int\n\t\tfor _, user := range umap {\n\t\t\tusers[i] = user\n\t\t\ti++\n\t\t}\n\t\treturn []*ConversationExtra{{raw[0], users}}, nil\n\t}\n\t//log.Println(\"1\")\n\n\tcmap := make(map[int]*ConversationExtra, len(raw))\n\tfor _, co := range raw {\n\t\tcmap[co.ID] = &ConversationExtra{co, nil}\n\t}\n\n\t// TODO: Use inqbuild for this or a similar function\n\tvar q string\n\tidList := make([]interface{}, len(raw))\n\tfor i, co := range raw {\n\t\tif i == 0 {\n\t\t\tq = \"?\"\n\t\t} else {\n\t\t\tq += \",?\"\n\t\t}\n\t\tidList[i] = strconv.Itoa(co.ID)\n\t}\n\n\trows, err := qgen.NewAcc().Select(\"conversations_participants\").Columns(\"uid,cid\").Where(\"cid IN(\" + q + \")\").Query(idList...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\t//log.Println(\"2\")\n\n\tidmap := make(map[int][]int) // cid: []uid\n\tpuidmap := make(map[int]struct{})\n\tfor rows.Next() {\n\t\tvar uid, cid int\n\t\terr := rows.Scan(&uid, &cid)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tidmap[cid] = append(idmap[cid], uid)\n\t\tpuidmap[uid] = struct{}{}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\t//log.Println(\"3\")\n\t//log.Printf(\"idmap: %+v\\n\", idmap)\n\t//log.Printf(\"puidmap: %+v\\n\",puidmap)\n\n\tpuids := make([]int, len(puidmap))\n\tvar i int\n\tfor puid, _ := range puidmap {\n\t\tpuids[i] = puid\n\t\ti++\n\t}\n\tumap, err := Users.BulkGetMap(puids)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t//log.Println(\"4\")\n\t//log.Printf(\"umap: %+v\\n\", umap)\n\tfor cid, uids := range idmap {\n\t\tco := cmap[cid]\n\t\tfor _, uid := range uids {\n\t\t\tco.Users = append(co.Users, umap[uid])\n\t\t}\n\t\t//log.Printf(\"co.Conversation: %+v\\n\", co.Conversation)\n\t\t//log.Printf(\"co.Users: %+v\\n\", co.Users)\n\t\tcmap[cid] = co\n\t}\n\t//log.Printf(\"cmap: %+v\\n\", cmap)\n\tfor _, ra := range raw {\n\t\tcos = append(cos, cmap[ra.ID])\n\t}\n\t//log.Printf(\"cos: %+v\\n\", cos)\n\n\treturn cos, rows.Err()\n}\n\nfunc (s *DefaultConversationStore) GetUserCount(uid int) (count int) {\n\terr := s.getUserCount.QueryRow(uid).Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\n// TODO: Use a foreign key or transaction\nfunc (s *DefaultConversationStore) Delete(id int) error {\n\t_, err := s.delete.Exec(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.deletePosts.Exec(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.deleteParticipants.Exec(id)\n\treturn err\n}\n\nfunc (s *DefaultConversationStore) Create(content string, createdBy int, participants []int) (int, error) {\n\tif len(participants) == 0 {\n\t\treturn 0, errors.New(\"no participants set\")\n\t}\n\tres, err := s.create.Exec(createdBy, createdBy)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tpost := &ConversationPost{CID: int(lastID), Body: content, CreatedBy: createdBy}\n\t_, err = post.Create()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfor _, p := range participants {\n\t\tif p == createdBy {\n\t\t\tcontinue\n\t\t}\n\t\t_, err := s.addParticipant.Exec(p, lastID)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t}\n\t_, err = s.addParticipant.Exec(createdBy, lastID)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn int(lastID), err\n}\n\n// Count returns the total number of topics on these forums\nfunc (s *DefaultConversationStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n"
  },
  {
    "path": "common/convos_posts.go",
    "content": "package common\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"io\"\n)\n\nvar ConvoPostProcess ConvoPostProcessor = NewDefaultConvoPostProcessor()\n\ntype ConvoPostProcessor interface {\n\tOnLoad(co *ConversationPost) (*ConversationPost, error)\n\tOnSave(co *ConversationPost) (*ConversationPost, error)\n}\n\ntype DefaultConvoPostProcessor struct {\n}\n\nfunc NewDefaultConvoPostProcessor() *DefaultConvoPostProcessor {\n\treturn &DefaultConvoPostProcessor{}\n}\n\nfunc (pr *DefaultConvoPostProcessor) OnLoad(co *ConversationPost) (*ConversationPost, error) {\n\treturn co, nil\n}\n\nfunc (pr *DefaultConvoPostProcessor) OnSave(co *ConversationPost) (*ConversationPost, error) {\n\treturn co, nil\n}\n\ntype AesConvoPostProcessor struct {\n}\n\nfunc NewAesConvoPostProcessor() *AesConvoPostProcessor {\n\treturn &AesConvoPostProcessor{}\n}\n\nfunc (pr *AesConvoPostProcessor) OnLoad(co *ConversationPost) (*ConversationPost, error) {\n\tif co.Post != \"aes\" {\n\t\treturn co, nil\n\t}\n\tkey, _ := hex.DecodeString(Config.ConvoKey)\n\n\tciphertext, err := hex.DecodeString(co.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\taesgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnonceSize := aesgcm.NonceSize()\n\tif len(ciphertext) < nonceSize {\n\t\treturn nil, err\n\t}\n\n\tnonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]\n\tplaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlco := *co\n\tlco.Body = string(plaintext)\n\treturn &lco, nil\n}\n\nfunc (pr *AesConvoPostProcessor) OnSave(co *ConversationPost) (*ConversationPost, error) {\n\tkey, _ := hex.DecodeString(Config.ConvoKey)\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnonce := make([]byte, 12)\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn nil, err\n\t}\n\n\taesgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tciphertext := aesgcm.Seal(nil, nonce, []byte(co.Body), nil)\n\n\tlco := *co\n\tlco.Body = hex.EncodeToString(ciphertext)\n\tlco.Post = \"aes\"\n\treturn &lco, nil\n}\n\ntype ConversationPost struct {\n\tID        int\n\tCID       int\n\tBody      string\n\tPost      string // aes, ''\n\tCreatedBy int\n}\n\n// TODO: Should we run OnLoad on this? Or maybe add a FetchMeta method to avoid having to decode the message when it's not necessary?\nfunc (co *ConversationPost) Fetch() error {\n\treturn convoStmts.fetchPost.QueryRow(co.ID).Scan(&co.CID, &co.Body, &co.Post, &co.CreatedBy)\n}\n\nfunc (co *ConversationPost) Update() error {\n\tlco, err := ConvoPostProcess.OnSave(co)\n\tif err != nil {\n\t\treturn err\n\t}\n\t//GetHookTable().VhookNoRet(\"convo_post_update\", lco)\n\t_, err = convoStmts.editPost.Exec(lco.Body, lco.Post, lco.ID)\n\treturn err\n}\n\nfunc (co *ConversationPost) Create() (int, error) {\n\tlco, err := ConvoPostProcess.OnSave(co)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\t//GetHookTable().VhookNoRet(\"convo_post_create\", lco)\n\tres, err := convoStmts.createPost.Exec(lco.CID, lco.Body, lco.Post, lco.CreatedBy)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n\nfunc (co *ConversationPost) Delete() error {\n\t_, err := convoStmts.deletePost.Exec(co.ID)\n\treturn err\n}\n"
  },
  {
    "path": "common/counters/agents.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar AgentViewCounter *DefaultAgentViewCounter\n\ntype DefaultAgentViewCounter struct {\n\tbuckets []int64 //[AgentID]count\n\tinsert  *sql.Stmt\n}\n\nfunc NewDefaultAgentViewCounter(acc *qgen.Accumulator) (*DefaultAgentViewCounter, error) {\n\tco := &DefaultAgentViewCounter{\n\t\tbuckets: make([]int64, len(agentMapEnum)),\n\t\tinsert:  acc.Insert(\"viewchunks_agents\").Columns(\"count,createdAt,browser\").Fields(\"?,UTC_TIMESTAMP(),?\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultAgentViewCounter) Tick() error {\n\tfor id, _ := range co.buckets {\n\t\tcount := atomic.SwapInt64(&co.buckets[id], 0)\n\t\te := co.insertChunk(count, id) // TODO: Bulk insert for speed?\n\t\tif e != nil {\n\t\t\treturn errors.Wrap(errors.WithStack(e), \"agent counter\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultAgentViewCounter) insertChunk(count int64, agent int) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tagentName := reverseAgentMapEnum[agent]\n\tc.DebugLogf(\"Inserting a vchunk with a count of %d for agent %s (%d)\", count, agentName, agent)\n\t_, e := co.insert.Exec(count, agentName)\n\treturn e\n}\n\nfunc (co *DefaultAgentViewCounter) Bump(agent int) {\n\t// TODO: Test this check\n\tc.DebugDetail(\"buckets \", agent, \": \", co.buckets[agent])\n\tif len(co.buckets) <= agent || agent < 0 {\n\t\treturn\n\t}\n\tatomic.AddInt64(&co.buckets[agent], 1)\n}\n"
  },
  {
    "path": "common/counters/common.go",
    "content": "package counters\n\nimport \"sync\"\n\n// TODO: Make a neater API for this\nvar routeMapEnum map[string]int\nvar reverseRouteMapEnum map[int]string\n\nfunc SetRouteMapEnum(rme map[string]int) {\n\trouteMapEnum = rme\n}\n\nfunc SetReverseRouteMapEnum(rrme map[int]string) {\n\treverseRouteMapEnum = rrme\n}\n\nvar agentMapEnum map[string]int\nvar reverseAgentMapEnum map[int]string\n\nfunc SetAgentMapEnum(ame map[string]int) {\n\tagentMapEnum = ame\n}\n\nfunc SetReverseAgentMapEnum(rame map[int]string) {\n\treverseAgentMapEnum = rame\n}\n\nvar osMapEnum map[string]int\nvar reverseOSMapEnum map[int]string\n\nfunc SetOSMapEnum(osme map[string]int) {\n\tosMapEnum = osme\n}\n\nfunc SetReverseOSMapEnum(rosme map[int]string) {\n\treverseOSMapEnum = rosme\n}\n\ntype RWMutexCounterBucket struct {\n\tcounter int\n\tsync.RWMutex\n}\n\ntype MutexCounterBucket struct {\n\tcounter int\n\tsync.Mutex\n}\n\ntype MutexCounter64Bucket struct {\n\tcounter int64\n\tsync.Mutex\n}"
  },
  {
    "path": "common/counters/forums.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar ForumViewCounter *DefaultForumViewCounter\n\n// TODO: Unload forum counters without any views over the past 15 minutes, if the admin has configured the forumstore with a cap and it's been hit?\n// Forums can be reloaded from the database at any time, so we want to keep the counters separate from them\ntype DefaultForumViewCounter struct {\n\toddMap   map[int]*RWMutexCounterBucket // map[fid]struct{counter,sync.RWMutex}\n\tevenMap  map[int]*RWMutexCounterBucket\n\toddLock  sync.RWMutex\n\tevenLock sync.RWMutex\n\n\tinsert *sql.Stmt\n}\n\nfunc NewDefaultForumViewCounter() (*DefaultForumViewCounter, error) {\n\tacc := qgen.NewAcc()\n\tco := &DefaultForumViewCounter{\n\t\toddMap:  make(map[int]*RWMutexCounterBucket),\n\t\tevenMap: make(map[int]*RWMutexCounterBucket),\n\t\tinsert:  acc.Insert(\"viewchunks_forums\").Columns(\"count,createdAt,forum\").Fields(\"?,UTC_TIMESTAMP(),?\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick) // There could be a lot of routes, so we don't want to be running this every second\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultForumViewCounter) Tick() error {\n\tcLoop := func(l *sync.RWMutex, m map[int]*RWMutexCounterBucket) error {\n\t\tl.RLock()\n\t\tfor fid, f := range m {\n\t\t\tl.RUnlock()\n\t\t\tvar count int\n\t\t\tf.RLock()\n\t\t\tcount = f.counter\n\t\t\tf.RUnlock()\n\t\t\t// TODO: Only delete the bucket when it's zero to avoid hitting popular forums?\n\t\t\tl.Lock()\n\t\t\tdelete(m, fid)\n\t\t\tl.Unlock()\n\t\t\te := co.insertChunk(count, fid)\n\t\t\tif e != nil {\n\t\t\t\treturn errors.Wrap(errors.WithStack(e), \"forum counter\")\n\t\t\t}\n\t\t\tl.RLock()\n\t\t}\n\t\tl.RUnlock()\n\t\treturn nil\n\t}\n\te := cLoop(&co.oddLock, co.oddMap)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn cLoop(&co.evenLock, co.evenMap)\n}\n\nfunc (co *DefaultForumViewCounter) insertChunk(count, forum int) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tc.DebugLogf(\"Inserting a vchunk with a count of %d for forum %d\", count, forum)\n\t_, e := co.insert.Exec(count, forum)\n\treturn e\n}\n\nfunc (co *DefaultForumViewCounter) Bump(fid int) {\n\t// Is the ID even?\n\tif fid%2 == 0 {\n\t\tco.evenLock.RLock()\n\t\tf, ok := co.evenMap[fid]\n\t\tco.evenLock.RUnlock()\n\t\tif ok {\n\t\t\tf.Lock()\n\t\t\tf.counter++\n\t\t\tf.Unlock()\n\t\t} else {\n\t\t\tco.evenLock.Lock()\n\t\t\tco.evenMap[fid] = &RWMutexCounterBucket{counter: 1}\n\t\t\tco.evenLock.Unlock()\n\t\t}\n\t\treturn\n\t}\n\n\tco.oddLock.RLock()\n\tf, ok := co.oddMap[fid]\n\tco.oddLock.RUnlock()\n\tif ok {\n\t\tf.Lock()\n\t\tf.counter++\n\t\tf.Unlock()\n\t} else {\n\t\tco.oddLock.Lock()\n\t\tco.oddMap[fid] = &RWMutexCounterBucket{counter: 1}\n\t\tco.oddLock.Unlock()\n\t}\n}\n\n// TODO: Add a forum counter backed by two maps which grow as forums are created but never shrinks\n"
  },
  {
    "path": "common/counters/langs.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar LangViewCounter *DefaultLangViewCounter\n\nvar langCodes = []string{\n\t\"unknown\",\n\t\"\",\n\t\"af\",\n\t\"ar\",\n\t\"az\",\n\t\"be\",\n\t\"bg\",\n\t\"bs\",\n\t\"ca\",\n\t\"cs\",\n\t\"cy\",\n\t\"da\",\n\t\"de\",\n\t\"dv\",\n\t\"el\",\n\t\"en\",\n\t\"eo\",\n\t\"es\",\n\t\"et\",\n\t\"eu\",\n\t\"fa\",\n\t\"fi\",\n\t\"fo\",\n\t\"fr\",\n\t\"gl\",\n\t\"gu\",\n\t\"he\",\n\t\"hi\",\n\t\"hr\",\n\t\"hu\",\n\t\"hy\",\n\t\"id\",\n\t\"is\",\n\t\"it\",\n\t\"ja\",\n\t\"ka\",\n\t\"kk\",\n\t\"kn\",\n\t\"ko\",\n\t\"kok\",\n\t\"kw\",\n\t\"ky\",\n\t\"lt\",\n\t\"lv\",\n\t\"mi\",\n\t\"mk\",\n\t\"mn\",\n\t\"mr\",\n\t\"ms\",\n\t\"mt\",\n\t\"nb\",\n\t\"nl\",\n\t\"nn\",\n\t\"ns\",\n\t\"pa\",\n\t\"pl\",\n\t\"ps\",\n\t\"pt\",\n\t\"qu\",\n\t\"ro\",\n\t\"ru\",\n\t\"sa\",\n\t\"se\",\n\t\"sk\",\n\t\"sl\",\n\t\"sq\",\n\t\"sr\",\n\t\"sv\",\n\t\"sw\",\n\t\"syr\",\n\t\"ta\",\n\t\"te\",\n\t\"th\",\n\t\"tl\",\n\t\"tn\",\n\t\"tr\",\n\t\"tt\",\n\t\"ts\",\n\t\"uk\",\n\t\"ur\",\n\t\"uz\",\n\t\"vi\",\n\t\"xh\",\n\t\"zh\",\n\t\"zu\",\n}\n\ntype DefaultLangViewCounter struct {\n\t//buckets        []*MutexCounterBucket //[OSID]count\n\tbuckets        []int64 //[OSID]count\n\tcodesToIndices map[string]int\n\n\tinsert *sql.Stmt\n}\n\nfunc NewDefaultLangViewCounter(acc *qgen.Accumulator) (*DefaultLangViewCounter, error) {\n\tcodesToIndices := make(map[string]int, len(langCodes))\n\tfor index, code := range langCodes {\n\t\tcodesToIndices[code] = index\n\t}\n\tco := &DefaultLangViewCounter{\n\t\tbuckets:        make([]int64, len(langCodes)),\n\t\tcodesToIndices: codesToIndices,\n\t\tinsert:         acc.Insert(\"viewchunks_langs\").Columns(\"count,createdAt,lang\").Fields(\"?,UTC_TIMESTAMP(),?\").Prepare(),\n\t}\n\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultLangViewCounter) Tick() error {\n\tfor id := 0; id < len(co.buckets); id++ {\n\t\tcount := atomic.SwapInt64(&co.buckets[id], 0)\n\t\te := co.insertChunk(count, id) // TODO: Bulk insert for speed?\n\t\tif e != nil {\n\t\t\treturn errors.Wrap(errors.WithStack(e), \"langview counter\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultLangViewCounter) insertChunk(count int64, id int) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tlangCode := langCodes[id]\n\tif langCode == \"\" {\n\t\tlangCode = \"none\"\n\t}\n\tc.DebugLogf(\"Inserting a vchunk with a count of %d for lang %s (%d)\", count, langCode, id)\n\t_, e := co.insert.Exec(count, langCode)\n\treturn e\n}\n\nfunc (co *DefaultLangViewCounter) Bump(langCode string) (validCode bool) {\n\tvalidCode = true\n\tid, ok := co.codesToIndices[langCode]\n\tif !ok {\n\t\t// TODO: Tell the caller that the code's invalid\n\t\tid = 0 // Unknown\n\t\tvalidCode = false\n\t}\n\n\t// TODO: Test this check\n\tc.DebugDetail(\"buckets \", id, \": \", co.buckets[id])\n\tif len(co.buckets) <= id || id < 0 {\n\t\treturn validCode\n\t}\n\tatomic.AddInt64(&co.buckets[id], 1)\n\n\treturn validCode\n}\n\nfunc (co *DefaultLangViewCounter) Bump2(id int) {\n\t// TODO: Test this check\n\tc.DebugDetail(\"bucket \", id, \": \", co.buckets[id])\n\tif len(co.buckets) <= id || id < 0 {\n\t\treturn\n\t}\n\tatomic.AddInt64(&co.buckets[id], 1)\n}\n"
  },
  {
    "path": "common/counters/memory.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar MemoryCounter *DefaultMemoryCounter\n\ntype DefaultMemoryCounter struct {\n\tinsert *sql.Stmt\n\n\ttotMem     uint64\n\ttotCount   uint64\n\tstackMem   uint64\n\tstackCount uint64\n\theapMem    uint64\n\theapCount  uint64\n\n\tsync.Mutex\n}\n\nfunc NewMemoryCounter(acc *qgen.Accumulator) (*DefaultMemoryCounter, error) {\n\tco := &DefaultMemoryCounter{\n\t\tinsert: acc.Insert(\"memchunks\").Columns(\"count,stack,heap,createdAt\").Fields(\"?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\tticker := time.NewTicker(time.Minute)\n\tgo func() {\n\t\tdefer c.EatPanics()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ticker.C:\n\t\t\t\tvar m runtime.MemStats\n\t\t\t\truntime.ReadMemStats(&m)\n\t\t\t\tco.Lock()\n\t\t\t\tco.totCount++\n\t\t\t\tco.totMem += m.Sys\n\t\t\t\tco.stackCount++\n\t\t\t\tco.stackMem += m.StackInuse\n\t\t\t\tco.heapCount++\n\t\t\t\tco.heapMem += m.HeapAlloc\n\t\t\t\tco.Unlock()\n\t\t\t}\n\t\t}\n\t}()\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultMemoryCounter) Tick() (e error) {\n\tvar m runtime.MemStats\n\truntime.ReadMemStats(&m)\n\tvar rTotMem, rTotCount, rStackMem, rStackCount, rHeapMem, rHeapCount uint64\n\n\tco.Lock()\n\n\trTotMem = co.totMem\n\trTotCount = co.totCount\n\trStackMem = co.stackMem\n\trStackCount = co.stackCount\n\trHeapMem = co.heapMem\n\trHeapCount = co.heapCount\n\n\tco.totMem = 0\n\tco.totCount = 0\n\tco.stackMem = 0\n\tco.stackCount = 0\n\tco.heapMem = 0\n\tco.heapCount = 0\n\n\tco.Unlock()\n\n\tvar avgMem, avgStack, avgHeap uint64\n\tavgMem = (rTotMem + m.Sys) / (rTotCount + 1)\n\tavgStack = (rStackMem + m.StackInuse) / (rStackCount + 1)\n\tavgHeap = (rHeapMem + m.HeapAlloc) / (rHeapCount + 1)\n\n\tc.DebugLogf(\"Inserting a memchunk with a value of %d - %d - %d\", avgMem, avgStack, avgHeap)\n\t_, e = co.insert.Exec(avgMem, avgStack, avgHeap)\n\tif e != nil {\n\t\treturn errors.Wrap(errors.WithStack(e), \"mem counter\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/counters/performance.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"math\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar PerfCounter *DefaultPerfCounter\n\ntype PerfCounterBucket struct {\n\tlow  *MutexCounter64Bucket\n\thigh *MutexCounter64Bucket\n\tavg  *MutexCounter64Bucket\n}\n\n// TODO: Track perf on a per route basis\ntype DefaultPerfCounter struct {\n\tbuckets []*PerfCounterBucket\n\n\tinsert *sql.Stmt\n}\n\nfunc NewDefaultPerfCounter(acc *qgen.Accumulator) (*DefaultPerfCounter, error) {\n\tco := &DefaultPerfCounter{\n\t\tbuckets: []*PerfCounterBucket{\n\t\t\t{\n\t\t\t\tlow:  &MutexCounter64Bucket{counter: math.MaxInt64},\n\t\t\t\thigh: &MutexCounter64Bucket{counter: 0},\n\t\t\t\tavg:  &MutexCounter64Bucket{counter: 0},\n\t\t\t},\n\t\t},\n\t\tinsert: acc.Insert(\"perfchunks\").Columns(\"low,high,avg,createdAt\").Fields(\"?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t}\n\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultPerfCounter) Tick() error {\n\tgetCounter := func(b *MutexCounter64Bucket) (c int64) {\n\t\tb.Lock()\n\t\tc = b.counter\n\t\tb.counter = 0\n\t\tb.Unlock()\n\t\treturn c\n\t}\n\tvar low int64\n\thTbl := c.GetHookTable()\n\tfor _, b := range co.buckets {\n\t\tb.low.Lock()\n\t\tlow, b.low.counter = b.low.counter, math.MaxInt64\n\t\tb.low.Unlock()\n\t\tif low == math.MaxInt64 {\n\t\t\tlow = 0\n\t\t}\n\t\thigh := getCounter(b.high)\n\t\tavg := getCounter(b.avg)\n\t\tc.H_counters_perf_tick_row_hook(hTbl, low, high, avg)\n\t\tif e := co.insertChunk(low, high, avg); e != nil { // TODO: Bulk insert for speed?\n\t\t\treturn errors.Wrap(errors.WithStack(e), \"perf counter\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultPerfCounter) insertChunk(low, high, avg int64) error {\n\tif low == 0 && high == 0 && avg == 0 {\n\t\treturn nil\n\t}\n\tc.DebugLogf(\"Inserting a pchunk with low %d, high %d, avg %d\", low, high, avg)\n\tif c.Dev.LogNewLongRoute && high > (5*1000*1000) {\n\t\tc.Logf(\"pchunk high %d\", high)\n\t}\n\t_, e := co.insert.Exec(low, high, avg)\n\treturn e\n}\n\nfunc (co *DefaultPerfCounter) Push(dur time.Duration /*,_ bool*/) {\n\tid := 0\n\tb := co.buckets[id]\n\t//c.DebugDetail(\"buckets \", id, \": \", b)\n\tmicro := dur.Microseconds()\n\tif micro >= math.MaxInt32 {\n\t\tc.LogWarning(errors.New(\"dur should not be int32 max or higher\"))\n\t}\n\n\tlow := b.low\n\tlow.Lock()\n\tif micro < low.counter {\n\t\tlow.counter = micro\n\t}\n\tlow.Unlock()\n\n\thigh := b.high\n\thigh.Lock()\n\tif micro > high.counter {\n\t\thigh.counter = micro\n\t}\n\thigh.Unlock()\n\n\tavg := b.avg\n\tavg.Lock()\n\tif micro != avg.counter {\n\t\tif avg.counter == 0 {\n\t\t\tavg.counter = micro\n\t\t} else {\n\t\t\tavg.counter = (micro + avg.counter) / 2\n\t\t}\n\t}\n\tavg.Unlock()\n}\n"
  },
  {
    "path": "common/counters/posts.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar PostCounter *DefaultPostCounter\n\ntype DefaultPostCounter struct {\n\tbuckets       [2]int64\n\tcurrentBucket int64\n\n\tinsert *sql.Stmt\n}\n\nfunc NewPostCounter() (*DefaultPostCounter, error) {\n\tacc := qgen.NewAcc()\n\tco := &DefaultPostCounter{\n\t\tcurrentBucket: 0,\n\t\tinsert:        acc.Insert(\"postchunks\").Columns(\"count,createdAt\").Fields(\"?,UTC_TIMESTAMP()\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultPostCounter) Tick() (err error) {\n\toldBucket := co.currentBucket\n\tvar nextBucket int64 // 0\n\tif co.currentBucket == 0 {\n\t\tnextBucket = 1\n\t}\n\tatomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket])\n\tatomic.StoreInt64(&co.buckets[nextBucket], 0)\n\tatomic.StoreInt64(&co.currentBucket, nextBucket)\n\n\tpreviousViewChunk := co.buckets[oldBucket]\n\tatomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk)\n\terr = co.insertChunk(previousViewChunk)\n\tif err != nil {\n\t\treturn errors.Wrap(errors.WithStack(err), \"post counter\")\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultPostCounter) Bump() {\n\tatomic.AddInt64(&co.buckets[co.currentBucket], 1)\n}\n\nfunc (co *DefaultPostCounter) insertChunk(count int64) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tc.DebugLogf(\"Inserting a postchunk with a count of %d\", count)\n\t_, err := co.insert.Exec(count)\n\treturn err\n}\n"
  },
  {
    "path": "common/counters/referrers.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar ReferrerTracker *DefaultReferrerTracker\n\n// Add ReferrerItems here after they've had zero views for a while\nvar referrersToDelete = make(map[string]*ReferrerItem)\n\ntype ReferrerItem struct {\n\tCount int64\n}\n\n// ? We'll track referrer domains here rather than the exact URL they arrived from for now, we'll think about expanding later\n// ? Referrers are fluid and ever-changing so we have to use string keys rather than 'enum' ints\ntype DefaultReferrerTracker struct {\n\todd      map[string]*ReferrerItem\n\teven     map[string]*ReferrerItem\n\toddLock  sync.RWMutex\n\tevenLock sync.RWMutex\n\n\tinsert *sql.Stmt\n}\n\nfunc NewDefaultReferrerTracker() (*DefaultReferrerTracker, error) {\n\tacc := qgen.NewAcc()\n\trefTracker := &DefaultReferrerTracker{\n\t\todd:    make(map[string]*ReferrerItem),\n\t\teven:   make(map[string]*ReferrerItem),\n\t\tinsert: acc.Insert(\"viewchunks_referrers\").Columns(\"count,createdAt,domain\").Fields(\"?,UTC_TIMESTAMP(),?\").Prepare(), // TODO: Do something more efficient than doing a query for each referrer\n\t}\n\tc.Tasks.FifteenMin.Add(refTracker.Tick)\n\t//c.Tasks.Sec.Add(refTracker.Tick)\n\tc.Tasks.Shutdown.Add(refTracker.Tick)\n\treturn refTracker, acc.FirstError()\n}\n\n// TODO: Move this and the other view tickers out of the main task loop to avoid blocking other tasks?\nfunc (ref *DefaultReferrerTracker) Tick() (err error) {\n\tfor referrer, counter := range referrersToDelete {\n\t\t// Handle views which squeezed through the gaps at the last moment\n\t\tcount := counter.Count\n\t\tif count != 0 {\n\t\t\terr := ref.insertChunk(referrer, count) // TODO: Bulk insert for speed?\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(errors.WithStack(err), \"ref counter\")\n\t\t\t}\n\t\t}\n\t\tdelete(referrersToDelete, referrer)\n\t}\n\n\t//  Run the queries and schedule zero view refs for deletion from memory\n\trefLoop := func(l *sync.RWMutex, m map[string]*ReferrerItem) error {\n\t\tl.Lock()\n\t\tdefer l.Unlock()\n\t\tfor referrer, counter := range m {\n\t\t\tif counter.Count == 0 {\n\t\t\t\treferrersToDelete[referrer] = counter\n\t\t\t\tdelete(m, referrer)\n\t\t\t}\n\t\t\tcount := atomic.SwapInt64(&counter.Count, 0)\n\t\t\terr := ref.insertChunk(referrer, count) // TODO: Bulk insert for speed?\n\t\t\tif err != nil {\n\t\t\t\treturn errors.Wrap(errors.WithStack(err), \"ref counter\")\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\terr = refLoop(&ref.oddLock, ref.odd)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn refLoop(&ref.evenLock, ref.even)\n}\n\nfunc (ref *DefaultReferrerTracker) insertChunk(referrer string, count int64) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tc.DebugDetailf(\"Inserting a vchunk with a count of %d for ref %s\", count, referrer)\n\t_, err := ref.insert.Exec(count, referrer)\n\treturn err\n}\n\nfunc (ref *DefaultReferrerTracker) Bump(referrer string) {\n\tif referrer == \"\" {\n\t\treturn\n\t}\n\tvar refItem *ReferrerItem\n\n\t// Slightly crude and rudimentary, but it should give a basic degree of sharding\n\tif referrer[0]%2 == 0 {\n\t\tref.evenLock.RLock()\n\t\trefItem = ref.even[referrer]\n\t\tref.evenLock.RUnlock()\n\t\tif refItem != nil {\n\t\t\tatomic.AddInt64(&refItem.Count, 1)\n\t\t} else {\n\t\t\tref.evenLock.Lock()\n\t\t\tref.even[referrer] = &ReferrerItem{Count: 1}\n\t\t\tref.evenLock.Unlock()\n\t\t}\n\t} else {\n\t\tref.oddLock.RLock()\n\t\trefItem = ref.odd[referrer]\n\t\tref.oddLock.RUnlock()\n\t\tif refItem != nil {\n\t\t\tatomic.AddInt64(&refItem.Count, 1)\n\t\t} else {\n\t\t\tref.oddLock.Lock()\n\t\t\tref.odd[referrer] = &ReferrerItem{Count: 1}\n\t\t\tref.oddLock.Unlock()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "common/counters/requests.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\n// TODO: Rename this?\nvar GlobalViewCounter *DefaultViewCounter\n\n// TODO: Rename this and shard it?\ntype DefaultViewCounter struct {\n\tbuckets       [2]int64\n\tcurrentBucket int64\n\n\tinsert *sql.Stmt\n}\n\nfunc NewGlobalViewCounter(acc *qgen.Accumulator) (*DefaultViewCounter, error) {\n\tco := &DefaultViewCounter{\n\t\tcurrentBucket: 0,\n\t\tinsert:        acc.Insert(\"viewchunks\").Columns(\"count,createdAt,route\").Fields(\"?,UTC_TIMESTAMP(),''\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick) // This is run once every fifteen minutes to match the frequency of the RouteViewCounter\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\n// TODO: Simplify the atomics used here\nfunc (co *DefaultViewCounter) Tick() (err error) {\n\toldBucket := co.currentBucket\n\tvar nextBucket int64 // 0\n\tif co.currentBucket == 0 {\n\t\tnextBucket = 1\n\t}\n\tatomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket])\n\tatomic.StoreInt64(&co.buckets[nextBucket], 0)\n\tatomic.StoreInt64(&co.currentBucket, nextBucket)\n\n\tpreviousViewChunk := co.buckets[oldBucket]\n\tatomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk)\n\terr = co.insertChunk(previousViewChunk)\n\tif err != nil {\n\t\treturn errors.Wrap(errors.WithStack(err), \"req counter\")\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultViewCounter) Bump() {\n\tatomic.AddInt64(&co.buckets[co.currentBucket], 1)\n}\n\nfunc (co *DefaultViewCounter) insertChunk(count int64) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tc.DebugLogf(\"Inserting a vchunk with a count of %d\", count)\n\t_, err := co.insert.Exec(count)\n\treturn err\n}\n"
  },
  {
    "path": "common/counters/routes.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/pkg/errors\"\n)\n\nvar RouteViewCounter *DefaultRouteViewCounter\n\ntype RVBucket struct {\n\tcounter int64\n\tavg     int\n\n\tsync.Mutex\n}\n\n// TODO: Make this lockless?\ntype DefaultRouteViewCounter struct {\n\tbuckets []*RVBucket //[RouteID]count\n\tinsert  *sql.Stmt\n\tinsert5 *sql.Stmt\n}\n\nfunc NewDefaultRouteViewCounter(acc *qgen.Accumulator) (*DefaultRouteViewCounter, error) {\n\trouteBuckets := make([]*RVBucket, len(routeMapEnum))\n\tfor bucketID, _ := range routeBuckets {\n\t\trouteBuckets[bucketID] = &RVBucket{counter: 0, avg: 0}\n\t}\n\n\tfields := \"?,?,UTC_TIMESTAMP(),?\"\n\tco := &DefaultRouteViewCounter{\n\t\tbuckets: routeBuckets,\n\t\tinsert:  acc.Insert(\"viewchunks\").Columns(\"count,avg,createdAt,route\").Fields(fields).Prepare(),\n\t\tinsert5: acc.BulkInsert(\"viewchunks\").Columns(\"count,avg,createdAt,route\").Fields(fields, fields, fields, fields, fields).Prepare(),\n\t}\n\tif !c.Config.DisableAnalytics {\n\t\tc.Tasks.FifteenMin.Add(co.Tick) // There could be a lot of routes, so we don't want to be running this every second\n\t\t//c.Tasks.Sec.Add(co.Tick)\n\t\tc.Tasks.Shutdown.Add(co.Tick)\n\t}\n\treturn co, acc.FirstError()\n}\n\ntype RVCount struct {\n\tRouteID int\n\tCount   int64\n\tAvg     int\n}\n\nfunc (co *DefaultRouteViewCounter) Tick() (err error) {\n\tvar tb []RVCount\n\tfor routeID, b := range co.buckets {\n\t\tvar avg int\n\t\tcount := atomic.SwapInt64(&b.counter, 0)\n\t\tb.Lock()\n\t\tavg = b.avg\n\t\tb.avg = 0\n\t\tb.Unlock()\n\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\ttb = append(tb, RVCount{routeID, count, avg})\n\t}\n\n\t// TODO: Expand on this?\n\tvar i int\n\tif len(tb) >= 5 {\n\t\tfor ; len(tb) > (i + 5); i += 5 {\n\t\t\terr := co.insert5Chunk(tb[i : i+5])\n\t\t\tif err != nil {\n\t\t\t\tc.DebugLogf(\"tb: %+v\\n\", tb)\n\t\t\t\tc.DebugLog(\"i: \", i)\n\t\t\t\treturn errors.Wrap(errors.WithStack(err), \"route counter x 5\")\n\t\t\t}\n\t\t}\n\t}\n\n\tfor ; len(tb) > i; i++ {\n\t\tit := tb[i]\n\t\terr = co.insertChunk(it.Count, it.Avg, it.RouteID)\n\t\tif err != nil {\n\t\t\tc.DebugLogf(\"tb: %+v\\n\", tb)\n\t\t\tc.DebugLog(\"i: \", i)\n\t\t\treturn errors.Wrap(errors.WithStack(err), \"route counter\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (co *DefaultRouteViewCounter) insertChunk(count int64, avg, route int) error {\n\trouteName := reverseRouteMapEnum[route]\n\tc.DebugLogf(\"Inserting vchunk with count %d, avg %d for route %s (%d)\", count, avg, routeName, route)\n\t_, err := co.insert.Exec(count, avg, routeName)\n\treturn err\n}\n\nfunc (co *DefaultRouteViewCounter) insert5Chunk(rvs []RVCount) error {\n\targs := make([]interface{}, len(rvs)*3)\n\ti := 0\n\tfor _, rv := range rvs {\n\t\trouteName := reverseRouteMapEnum[rv.RouteID]\n\t\tif rv.Avg == 0 {\n\t\t\tc.DebugLogf(\"Queueing vchunk with count %d for routes %s (%d)\", rv.Count, routeName, rv.RouteID)\n\t\t} else {\n\t\t\tc.DebugLogf(\"Queueing vchunk with count %d, avg %d for routes %s (%d)\", rv.Count, rv.Avg, routeName, rv.RouteID)\n\t\t}\n\t\targs[i] = rv.Count\n\t\targs[i+1] = rv.Avg\n\t\targs[i+2] = routeName\n\t\ti += 3\n\t}\n\tc.DebugDetailf(\"args: %+v\\n\", args)\n\t_, err := co.insert5.Exec(args...)\n\treturn err\n}\n\nfunc (co *DefaultRouteViewCounter) Bump(route int) {\n\tif c.Config.DisableAnalytics {\n\t\treturn\n\t}\n\t// TODO: Test this check\n\tb := co.buckets[route]\n\tc.DebugDetail(\"bucket \", route, \": \", b)\n\tif len(co.buckets) <= route || route < 0 {\n\t\treturn\n\t}\n\tatomic.AddInt64(&b.counter, 1)\n}\n\n// TODO: Eliminate the lock?\nfunc (co *DefaultRouteViewCounter) Bump2(route int, t time.Time) {\n\tif c.Config.DisableAnalytics {\n\t\treturn\n\t}\n\t// TODO: Test this check\n\tb := co.buckets[route]\n\tc.DebugDetail(\"bucket \", route, \": \", b)\n\tif len(co.buckets) <= route || route < 0 {\n\t\treturn\n\t}\n\tmicro := int(time.Since(t).Microseconds())\n\t//co.PerfCounter.Push(since, true)\n\tatomic.AddInt64(&b.counter, 1)\n\tb.Lock()\n\tif micro != b.avg {\n\t\tif b.avg == 0 {\n\t\t\tb.avg = micro\n\t\t} else {\n\t\t\tb.avg = (micro + b.avg) / 2\n\t\t}\n\t}\n\tb.Unlock()\n}\n\n// TODO: Eliminate the lock?\nfunc (co *DefaultRouteViewCounter) Bump3(route int, nano int64) {\n\tif c.Config.DisableAnalytics {\n\t\treturn\n\t}\n\t// TODO: Test this check\n\tb := co.buckets[route]\n\tc.DebugDetail(\"bucket \", route, \": \", b)\n\tif len(co.buckets) <= route || route < 0 {\n\t\treturn\n\t}\n\tmicro := int((uutils.Nanotime() - nano) / 1000)\n\t//co.PerfCounter.Push(since, true)\n\tatomic.AddInt64(&b.counter, 1)\n\tb.Lock()\n\tif micro != b.avg {\n\t\tif b.avg == 0 {\n\t\t\tb.avg = micro\n\t\t} else {\n\t\t\tb.avg = (micro + b.avg) / 2\n\t\t}\n\t}\n\tb.Unlock()\n}\n"
  },
  {
    "path": "common/counters/systems.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar OSViewCounter *DefaultOSViewCounter\n\ntype DefaultOSViewCounter struct {\n\tbuckets []int64 //[OSID]count\n\tinsert  *sql.Stmt\n}\n\nfunc NewDefaultOSViewCounter(acc *qgen.Accumulator) (*DefaultOSViewCounter, error) {\n\tco := &DefaultOSViewCounter{\n\t\tbuckets: make([]int64, len(osMapEnum)),\n\t\tinsert:  acc.Insert(\"viewchunks_systems\").Columns(\"count,createdAt,system\").Fields(\"?,UTC_TIMESTAMP(),?\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultOSViewCounter) Tick() error {\n\tfor id, _ := range co.buckets {\n\t\tcount := atomic.SwapInt64(&co.buckets[id], 0)\n\t\tif e := co.insertChunk(count, id); e != nil { // TODO: Bulk insert for speed?\n\t\t\treturn errors.Wrap(errors.WithStack(e), \"system counter\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultOSViewCounter) insertChunk(count int64, os int) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tosName := reverseOSMapEnum[os]\n\tc.DebugLogf(\"Inserting a vchunk with a count of %d for OS %s (%d)\", count, osName, os)\n\t_, err := co.insert.Exec(count, osName)\n\treturn err\n}\n\nfunc (co *DefaultOSViewCounter) Bump(id int) {\n\t// TODO: Test this check\n\tc.DebugDetail(\"bucket \", id, \": \", co.buckets[id])\n\tif len(co.buckets) <= id || id < 0 {\n\t\treturn\n\t}\n\tatomic.AddInt64(&co.buckets[id], 1)\n}\n"
  },
  {
    "path": "common/counters/topics.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar TopicCounter *DefaultTopicCounter\n\ntype DefaultTopicCounter struct {\n\tbuckets       [2]int64\n\tcurrentBucket int64\n\n\tinsert *sql.Stmt\n}\n\nfunc NewTopicCounter() (*DefaultTopicCounter, error) {\n\tacc := qgen.NewAcc()\n\tco := &DefaultTopicCounter{\n\t\tcurrentBucket: 0,\n\t\tinsert:        acc.Insert(\"topicchunks\").Columns(\"count,createdAt\").Fields(\"?,UTC_TIMESTAMP()\").Prepare(),\n\t}\n\tc.Tasks.FifteenMin.Add(co.Tick)\n\t//c.Tasks.Sec.Add(co.Tick)\n\tc.Tasks.Shutdown.Add(co.Tick)\n\treturn co, acc.FirstError()\n}\n\nfunc (co *DefaultTopicCounter) Tick() (e error) {\n\toldBucket := co.currentBucket\n\tvar nextBucket int64 // 0\n\tif co.currentBucket == 0 {\n\t\tnextBucket = 1\n\t}\n\tatomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket])\n\tatomic.StoreInt64(&co.buckets[nextBucket], 0)\n\tatomic.StoreInt64(&co.currentBucket, nextBucket)\n\n\tpreviousViewChunk := co.buckets[oldBucket]\n\tatomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk)\n\te = co.insertChunk(previousViewChunk)\n\tif e != nil {\n\t\treturn errors.Wrap(errors.WithStack(e), \"topics counter\")\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultTopicCounter) Bump() {\n\tatomic.AddInt64(&co.buckets[co.currentBucket], 1)\n}\n\nfunc (co *DefaultTopicCounter) insertChunk(count int64) error {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\tc.DebugLogf(\"Inserting a topicchunk with a count of %d\", count)\n\t_, e := co.insert.Exec(count)\n\treturn e\n}\n"
  },
  {
    "path": "common/counters/topics_views.go",
    "content": "package counters\n\nimport (\n\t\"database/sql\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nvar TopicViewCounter *DefaultTopicViewCounter\n\n// TODO: Use two odd-even maps for now, and move to something more concurrent later, maybe a sharded map?\ntype DefaultTopicViewCounter struct {\n\toddTopics  map[int]*RWMutexCounterBucket // map[tid]struct{counter,sync.RWMutex}\n\tevenTopics map[int]*RWMutexCounterBucket\n\toddLock    sync.RWMutex\n\tevenLock   sync.RWMutex\n\n\tweekState byte\n\n\tupdate    *sql.Stmt\n\tresetOdd  *sql.Stmt\n\tresetEven *sql.Stmt\n\tresetBoth *sql.Stmt\n\n\tinsertListBuf []TopicViewInsert\n\tsaveTick      *SavedTick\n}\n\nfunc NewDefaultTopicViewCounter() (*DefaultTopicViewCounter, error) {\n\tacc := qgen.NewAcc()\n\tt := \"topics\"\n\tco := &DefaultTopicViewCounter{\n\t\toddTopics:  make(map[int]*RWMutexCounterBucket),\n\t\tevenTopics: make(map[int]*RWMutexCounterBucket),\n\n\t\t//update:     acc.Update(t).Set(\"views=views+?\").Where(\"tid=?\").Prepare(),\n\t\tupdate:    acc.Update(t).Set(\"views=views+?,weekEvenViews=weekEvenViews+?,weekOddViews=weekOddViews+?\").Where(\"tid=?\").Prepare(),\n\t\tresetOdd:  acc.Update(t).Set(\"weekOddViews=0\").Prepare(),\n\t\tresetEven: acc.Update(t).Set(\"weekEvenViews=0\").Prepare(),\n\t\tresetBoth: acc.Update(t).Set(\"weekOddViews=0,weekEvenViews=0\").Prepare(),\n\n\t\t//insertListBuf: make([]TopicViewInsert, 1024),\n\t}\n\te := co.WeekResetInit()\n\tif e != nil {\n\t\treturn co, e\n\t}\n\n\ttick := func(f func() error) {\n\t\tc.Tasks.FifteenMin.Add(f) // Who knows how many topics we have queued up, we probably don't want this running too frequently\n\t\t//c.Tasks.Sec.Add(f)\n\t\tc.Tasks.Shutdown.Add(f)\n\t}\n\ttick(co.Tick)\n\ttick(co.WeekResetTick)\n\n\treturn co, acc.FirstError()\n}\n\ntype TopicViewInsert struct {\n\tCount   int\n\tTopicID int\n}\n\ntype SavedTick struct {\n\tI  int\n\tI2 int\n}\n\nfunc (co *DefaultTopicViewCounter) handleInsertListBuf(i, i2 int) error {\n\tilb := co.insertListBuf\n\tvar lastSuccess int\n\tfor i3 := i2; i3 < i; i3++ {\n\t\tiitem := ilb[i3]\n\t\tif e := co.insertChunk(iitem.Count, iitem.TopicID); e != nil {\n\t\t\tco.saveTick = &SavedTick{I: i, I2: lastSuccess + 1}\n\t\t\tfor i3 := i2; i3 < i && i3 <= lastSuccess; i3++ {\n\t\t\t\tilb[i3].Count, ilb[i3].TopicID = 0, 0\n\t\t\t}\n\t\t\treturn errors.Wrap(errors.WithStack(e), \"topicview counter\")\n\t\t}\n\t\tlastSuccess = i3\n\t}\n\tfor i3 := i2; i3 < i; i3++ {\n\t\tilb[i3].Count, ilb[i3].TopicID = 0, 0\n\t}\n\treturn nil\n}\n\nfunc (co *DefaultTopicViewCounter) Tick() error {\n\t// TODO: Fold multiple 1 view topics into one query\n\n\t/*if co.saveTick != nil {\n\t\te := co.handleInsertListBuf(co.saveTick.I, co.saveTick.I2)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tco.saveTick = nil\n\t}*/\n\n\tcLoop := func(l *sync.RWMutex, m map[int]*RWMutexCounterBucket) error {\n\t\t//i := 0\n\t\tl.RLock()\n\t\tfor topicID, topic := range m {\n\t\t\tl.RUnlock()\n\t\t\tvar count int\n\t\t\ttopic.RLock()\n\t\t\tcount = topic.counter\n\t\t\ttopic.RUnlock()\n\t\t\t// TODO: Only delete the bucket when it's zero to avoid hitting popular topics?\n\t\t\tl.Lock()\n\t\t\tdelete(m, topicID)\n\t\t\tl.Unlock()\n\t\t\t/*if len(co.insertListBuf) >= i {\n\t\t\t\tco.insertListBuf[i].Count = count\n\t\t\t\tco.insertListBuf[i].TopicID = topicID\n\t\t\t\ti++\n\t\t\t} else if i < 4096 {\n\t\t\t\tco.insertListBuf = append(co.insertListBuf, TopicViewInsert{count, topicID})\n\t\t\t} else */if e := co.insertChunk(count, topicID); e != nil {\n\t\t\t\treturn errors.Wrap(errors.WithStack(e), \"topicview counter\")\n\t\t\t}\n\t\t\tl.RLock()\n\t\t}\n\t\tl.RUnlock()\n\t\treturn nil //co.handleInsertListBuf(i, 0)\n\t}\n\te := cLoop(&co.oddLock, co.oddTopics)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn cLoop(&co.evenLock, co.evenTopics)\n}\n\nfunc (co *DefaultTopicViewCounter) WeekResetInit() error {\n\tlastWeekResetStr, e := c.Meta.Get(\"lastWeekReset\")\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn e\n\t}\n\n\tspl := strings.Split(lastWeekResetStr, \"-\")\n\tif len(spl) <= 1 {\n\t\treturn nil\n\t}\n\tweekState, e := strconv.Atoi(spl[1])\n\tif e != nil {\n\t\treturn e\n\t}\n\tco.weekState = byte(weekState)\n\n\tunixLastWeekReset, e := strconv.ParseInt(spl[0], 10, 64)\n\tif e != nil {\n\t\treturn e\n\t}\n\tresetTime := time.Unix(unixLastWeekReset, 0)\n\tif time.Since(resetTime).Hours() >= (24 * 7) {\n\t\t_, e = co.resetBoth.Exec()\n\t}\n\treturn e\n}\n\nfunc (co *DefaultTopicViewCounter) WeekResetTick() (e error) {\n\tnow := time.Now()\n\t_, week := now.ISOWeek()\n\tif week != int(co.weekState) {\n\t\tif week%2 == 0 { // is even?\n\t\t\t_, e = co.resetOdd.Exec()\n\t\t} else {\n\t\t\t_, e = co.resetEven.Exec()\n\t\t}\n\t\tco.weekState = byte(week)\n\t}\n\t// TODO: Retry?\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn c.Meta.Set(\"lastWeekReset\", strconv.FormatInt(now.Unix(), 10)+\"-\"+strconv.Itoa(week))\n}\n\n// TODO: Optimise this further. E.g. Using IN() on every one view topic. Rinse and repeat for two views, three views, four views and five views.\nfunc (co *DefaultTopicViewCounter) insertChunk(count, topicID int) (err error) {\n\tif count == 0 {\n\t\treturn nil\n\t}\n\n\tc.DebugLogf(\"Inserting %d views into topic %d\", count, topicID)\n\teven, odd := 0, 0\n\t_, week := time.Now().ISOWeek()\n\tif week%2 == 0 { // is even?\n\t\teven += count\n\t} else {\n\t\todd += count\n\t}\n\n\tif true {\n\t\t_, err = co.update.Exec(count, even, odd, topicID)\n\t} else {\n\t\t_, err = co.update.Exec(count, topicID)\n\t}\n\tif err == sql.ErrNoRows {\n\t\treturn nil\n\t} else if err != nil {\n\t\treturn err\n\t}\n\n\t// TODO: Add a way to disable this for extra speed ;)\n\ttc := c.Topics.GetCache()\n\tif tc != nil {\n\t\tt, err := tc.Get(topicID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn nil\n\t\t} else if err != nil {\n\t\t\treturn err\n\t\t}\n\t\tatomic.AddInt64(&t.ViewCount, int64(count))\n\t}\n\n\treturn nil\n}\n\nfunc (co *DefaultTopicViewCounter) Bump(topicID int) {\n\t// Is the ID even?\n\tif topicID%2 == 0 {\n\t\tco.evenLock.RLock()\n\t\tt, ok := co.evenTopics[topicID]\n\t\tco.evenLock.RUnlock()\n\t\tif ok {\n\t\t\tt.Lock()\n\t\t\tt.counter++\n\t\t\tt.Unlock()\n\t\t} else {\n\t\t\tco.evenLock.Lock()\n\t\t\tco.evenTopics[topicID] = &RWMutexCounterBucket{counter: 1}\n\t\t\tco.evenLock.Unlock()\n\t\t}\n\t\treturn\n\t}\n\n\tco.oddLock.RLock()\n\tt, ok := co.oddTopics[topicID]\n\tco.oddLock.RUnlock()\n\tif ok {\n\t\tt.Lock()\n\t\tt.counter++\n\t\tt.Unlock()\n\t} else {\n\t\tco.oddLock.Lock()\n\t\tco.oddTopics[topicID] = &RWMutexCounterBucket{counter: 1}\n\t\tco.oddLock.Unlock()\n\t}\n}\n"
  },
  {
    "path": "common/disk.go",
    "content": "package common\n\nimport (\n\t\"path/filepath\"\n\t\"os\"\n)\n\nfunc DirSize(path string) (int, error) {\n\tvar size int64\n\terr := filepath.Walk(path, func(_ string, file os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !file.IsDir() {\n\t\t\tsize += file.Size()\n\t\t}\n\t\treturn err\n\t})\n\treturn int(size), err\n}"
  },
  {
    "path": "common/email.go",
    "content": "package common\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/mail\"\n\t\"net/smtp\"\n\t\"strings\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc SendActivationEmail(username, email, token string) error {\n\tschema := \"http\"\n\tif Config.SslSchema {\n\t\tschema += \"s\"\n\t}\n\t// TODO: Move these to the phrase system\n\tsubject := \"Account Activation - \" + Site.Name\n\tmsg := \"Dear \" + username + \", to complete your registration on our forums, we need you to validate your email, so that we can confirm that this email actually belongs to you.\\n\\nClick on the following link to do so. \" + schema + \"://\" + Site.URL + \"/user/edit/token/\" + token + \"\\n\\nIf you haven't created an account here, then please feel free to ignore this email.\\nWe're sorry for the inconvenience this may have caused.\"\n\treturn SendEmail(email, subject, msg)\n}\n\nfunc SendValidationEmail(username, email, token string) error {\n\tschema := \"http\"\n\tif Config.SslSchema {\n\t\tschema += \"s\"\n\t}\n\tr := func(body *string) func(name, val string) {\n\t\treturn func(name, val string) {\n\t\t\t*body = strings.Replace(*body, \"{{\"+name+\"}}\", val, -1)\n\t\t}\n\t}\n\tsubject := p.GetAccountPhrase(\"ValidateEmailSubject\")\n\tr1 := r(&subject)\n\tr1(\"name\", Site.Name)\n\tbody := p.GetAccountPhrase(\"ValidateEmailBody\")\n\tr2 := r(&body)\n\tr2(\"username\", username)\n\tr2(\"schema\", schema)\n\tr2(\"url\", Site.URL)\n\tr2(\"token\", token)\n\treturn SendEmail(email, subject, body)\n}\n\n// TODO: Refactor this\nfunc SendEmail(email, subject, msg string) (err error) {\n\t// This hook is useful for plugin_sendmail or for testing tools. Possibly to hook it into some sort of mail server?\n\tret, hasHook := GetHookTable().VhookNeedHook(\"email_send_intercept\", email, subject, msg)\n\tif hasHook {\n\t\treturn ret.(error)\n\t}\n\n\tfrom := mail.Address{\"\", Site.Email}\n\tto := mail.Address{\"\", email}\n\theaders := make(map[string]string)\n\theaders[\"From\"] = from.String()\n\theaders[\"To\"] = to.String()\n\theaders[\"Subject\"] = subject\n\n\tbody := \"\"\n\tfor k, v := range headers {\n\t\tbody += fmt.Sprintf(\"%s: %s\\r\\n\", k, v)\n\t}\n\tbody += \"\\r\\n\" + msg\n\n\tvar c *smtp.Client\n\tvar conn *tls.Conn\n\tif Config.SMTPEnableTLS {\n\t\ttlsconfig := &tls.Config{\n\t\t\tInsecureSkipVerify: true,\n\t\t\tServerName:         Config.SMTPServer,\n\t\t}\n\t\tconn, err = tls.Dial(\"tcp\", Config.SMTPServer+\":\"+Config.SMTPPort, tlsconfig)\n\t\tif err != nil {\n\t\t\tLogWarning(err)\n\t\t\treturn err\n\t\t}\n\t\tc, err = smtp.NewClient(conn, Config.SMTPServer)\n\t} else {\n\t\tc, err = smtp.Dial(Config.SMTPServer + \":\" + Config.SMTPPort)\n\t}\n\tif err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\n\tif Config.SMTPUsername != \"\" {\n\t\tauth := smtp.PlainAuth(\"\", Config.SMTPUsername, Config.SMTPPassword, Config.SMTPServer)\n\t\terr = c.Auth(auth)\n\t\tif err != nil {\n\t\t\tLogWarning(err)\n\t\t\treturn err\n\t\t}\n\t}\n\tif err = c.Mail(from.Address); err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\tif err = c.Rcpt(to.Address); err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\n\tw, err := c.Data()\n\tif err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\t_, err = w.Write([]byte(body))\n\tif err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\tif err = w.Close(); err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\tif err = c.Quit(); err != nil {\n\t\tLogWarning(err)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "common/email_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Emails EmailStore\n\ntype Email struct {\n\tUserID    int\n\tEmail     string\n\tValidated bool\n\tPrimary   bool\n\tToken     string\n}\n\ntype EmailStore interface {\n\t// TODO: Add an autoincrement key\n\tGet(u *User, email string) (Email, error)\n\tGetEmailsByUser(u *User) (emails []Email, err error)\n\tAdd(uid int, email, token string) error\n\tDelete(uid int, email string) error\n\tVerifyEmail(email string) error\n}\n\ntype DefaultEmailStore struct {\n\tget             *sql.Stmt\n\tgetEmailsByUser *sql.Stmt\n\tadd             *sql.Stmt\n\tdelete          *sql.Stmt\n\tverifyEmail     *sql.Stmt\n}\n\nfunc NewDefaultEmailStore(acc *qgen.Accumulator) (*DefaultEmailStore, error) {\n\te := \"emails\"\n\treturn &DefaultEmailStore{\n\t\tget:             acc.Select(e).Columns(\"email,validated,token\").Where(\"uid=? AND email=?\").Prepare(),\n\t\tgetEmailsByUser: acc.Select(e).Columns(\"email,validated,token\").Where(\"uid=?\").Prepare(),\n\t\tadd:             acc.Insert(e).Columns(\"uid,email,validated,token\").Fields(\"?,?,?,?\").Prepare(),\n\t\tdelete:          acc.Delete(e).Where(\"uid=? AND email=?\").Prepare(),\n\n\t\t// Need to fix this: Empty string isn't working, it gets set to 1 instead x.x -- Has this been fixed?\n\t\tverifyEmail: acc.Update(e).Set(\"validated=1,token=''\").Where(\"email=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultEmailStore) Get(user *User, email string) (Email, error) {\n\te := Email{UserID: user.ID, Primary: email != \"\" && user.Email == email}\n\terr := s.get.QueryRow(user.ID, email).Scan(&e.Email, &e.Validated, &e.Token)\n\treturn e, err\n}\n\nfunc (s *DefaultEmailStore) GetEmailsByUser(user *User) (emails []Email, err error) {\n\te := Email{UserID: user.ID}\n\trows, err := s.getEmailsByUser.Query(user.ID)\n\tif err != nil {\n\t\treturn emails, err\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\terr := rows.Scan(&e.Email, &e.Validated, &e.Token)\n\t\tif err != nil {\n\t\t\treturn emails, err\n\t\t}\n\t\tif e.Email == user.Email {\n\t\t\te.Primary = true\n\t\t}\n\t\temails = append(emails, e)\n\t}\n\treturn emails, rows.Err()\n}\n\nfunc (s *DefaultEmailStore) Add(uid int, email, token string) error {\n\temail = CanonEmail(SanitiseSingleLine(email))\n\t_, err := s.add.Exec(uid, email, 0, token)\n\treturn err\n}\n\nfunc (s *DefaultEmailStore) Delete(uid int, email string) error {\n\t_, err := s.delete.Exec(uid, email)\n\treturn err\n}\n\nfunc (s *DefaultEmailStore) VerifyEmail(email string) error {\n\temail = CanonEmail(SanitiseSingleLine(email))\n\t_, err := s.verifyEmail.Exec(email)\n\treturn err\n}\n"
  },
  {
    "path": "common/errors.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"runtime/debug\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\ntype ErrorItem struct {\n\terror\n\tStack []byte\n}\n\n// ! The errorBuffer uses o(n) memory, we should probably do something about that\n// TODO: Use the errorBuffer variable to construct the system log in the Control Panel. Should we log errors caused by users too? Or just collect statistics on those or do nothing? Intercept recover()? Could we intercept the logger instead here? We might get too much information, if we intercept the logger, maybe make it part of the Debug page?\n// ? - Should we pass Header / HeaderLite rather than forcing the errors to pull the global Header instance?\nvar errorBufferMutex sync.RWMutex\n//var errorBuffer []ErrorItem\nvar ErrorCountSinceStartup int64\n\n//var notfoundCountPerSecond int\n//var nopermsCountPerSecond int\n\n// A blank list to fill out that parameter in Page for routes which don't use it\nvar tList []interface{}\n\n// WIP, a new system to propagate errors up from routes\ntype RouteError interface {\n\tType() string\n\tError() string\n\tCause() string\n\tJSON() bool\n\tHandled() bool\n\n\tWrap(string)\n}\n\ntype RouteErrorImpl struct {\n\tuserText string\n\tsysText  string\n\tsystem   bool\n\tjson     bool\n\thandled  bool\n}\n\nfunc (err *RouteErrorImpl) Type() string {\n\t// System errors may contain sensitive information we don't want the user to see\n\tif err.system {\n\t\treturn \"system\"\n\t}\n\treturn \"user\"\n}\n\nfunc (err *RouteErrorImpl) Error() string {\n\treturn err.userText\n}\n\nfunc (err *RouteErrorImpl) Cause() string {\n\tif err.sysText == \"\" {\n\t\treturn err.Error()\n\t}\n\treturn err.sysText\n}\n\n// Respond with JSON?\nfunc (err *RouteErrorImpl) JSON() bool {\n\treturn err.json\n}\n\n// Has this error been dealt with elsewhere?\nfunc (err *RouteErrorImpl) Handled() bool {\n\treturn err.handled\n}\n\n// Move the current error into the system error slot and add a new one to the user error slot to show the user\nfunc (err *RouteErrorImpl) Wrap(userErr string) {\n\terr.sysText = err.userText\n\terr.userText = userErr\n}\n\nfunc HandledRouteError() RouteError {\n\treturn &RouteErrorImpl{\"\", \"\", false, false, true}\n}\n\nfunc Error(errmsg string) RouteError {\n\treturn &RouteErrorImpl{errmsg, \"\", false, false, false}\n}\n\nfunc FromError(err error) RouteError {\n\treturn &RouteErrorImpl{err.Error(), \"\", false, false, false}\n}\n\nfunc ErrorJSQ(errmsg string, js bool) RouteError {\n\treturn &RouteErrorImpl{errmsg, \"\", false, js, false}\n}\n\nfunc SysError(errmsg string) RouteError {\n\treturn &RouteErrorImpl{errmsg, errmsg, true, false, false}\n}\n\n// LogError logs internal handler errors which can't be handled with InternalError() as a wrapper for log.Fatal(), we might do more with it in the future.\n// TODO: Clean-up extra as a way of passing additional context\nfunc LogError(err error, extra ...string) {\n\tLogWarning(err, extra...)\n\tErrLogger.Fatal(\"\")\n}\n\nfunc LogWarning(err error, extra ...string) {\n\tvar esb strings.Builder\n\tfor _, extraBit := range extra {\n\t\tesb.WriteString(extraBit)\n\t\tesb.WriteRune(10)\n\t}\n\tif err == nil {\n\t\tesb.WriteString(\"nil error found\")\n\t} else {\n\t\tesb.WriteString(err.Error())\n\t}\n\tesb.WriteRune(10)\n\terrmsg := esb.String()\n\n\terrorBufferMutex.Lock()\n\tdefer errorBufferMutex.Unlock()\n\tstack := debug.Stack() // debug.Stack() can't be executed concurrently, so we'll guard this with a mutex too\n\tErr(errmsg, string(stack))\n\t//errorBuffer = append(errorBuffer, ErrorItem{err, stack})\n\tatomic.AddInt64(&ErrorCountSinceStartup,1)\n}\n\nfunc errorHeader(w http.ResponseWriter, u *User, title string) *Header {\n\th := DefaultHeader(w, u)\n\th.Title = title\n\th.Zone = \"error\"\n\treturn h\n}\n\n// TODO: Dump the request?\n// InternalError is the main function for handling internal errors, while simultaneously printing out a page for the end-user to let them know that *something* has gone wrong\n// ? - Add a user parameter?\n// ! Do not call CustomError here or we might get an error loop\nfunc InternalError(err error, w http.ResponseWriter, r *http.Request) RouteError {\n\tpi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase(\"internal_error_title\")), p.GetErrorPhrase(\"internal_error_body\")}\n\thandleErrorTemplate(w, r, pi, 500)\n\tLogError(err)\n\treturn HandledRouteError()\n}\n\n// InternalErrorJSQ is the JSON \"maybe\" version of InternalError which can handle both JSON and normal requests\n// ? - Add a user parameter?\nfunc InternalErrorJSQ(err error, w http.ResponseWriter, r *http.Request, js bool) RouteError {\n\tif !js {\n\t\treturn InternalError(err, w, r)\n\t}\n\treturn InternalErrorJS(err, w, r)\n}\n\n// InternalErrorJS is the JSON version of InternalError on routes we know will only be requested via JSON. E.g. An API.\n// ? - Add a user parameter?\nfunc InternalErrorJS(err error, w http.ResponseWriter, r *http.Request) RouteError {\n\tw.WriteHeader(500)\n\twriteJsonError(p.GetErrorPhrase(\"internal_error_body\"), w)\n\tLogError(err)\n\treturn HandledRouteError()\n}\n\n// When the task system detects if the database is down, some database errors might slip by this\nfunc DatabaseError(w http.ResponseWriter, r *http.Request) RouteError {\n\tpi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase(\"internal_error_title\")), p.GetErrorPhrase(\"internal_error_body\")}\n\thandleErrorTemplate(w, r, pi, 500)\n\treturn HandledRouteError()\n}\n\nfunc InternalErrorXML(err error, w http.ResponseWriter, r *http.Request) RouteError {\n\tw.Header().Set(\"Content-Type\", \"application/xml\")\n\tw.WriteHeader(500)\n\tw.Write([]byte(`<?xml version=\"1.0\"encoding=\"UTF-8\"?>\n<error>` + p.GetErrorPhrase(\"internal_error_body\") + `</error>`))\n\tLogError(err)\n\treturn HandledRouteError()\n}\n\n// TODO: Stop killing the instance upon hitting an error with InternalError* and deprecate this\nfunc SilentInternalErrorXML(err error, w http.ResponseWriter, r *http.Request) RouteError {\n\tw.Header().Set(\"Content-Type\", \"application/xml\")\n\tw.WriteHeader(500)\n\tw.Write([]byte(`<?xml version=\"1.0\"encoding=\"UTF-8\"?>\n<error>` + p.GetErrorPhrase(\"internal_error_body\") + `</error>`))\n\tlog.Print(\"InternalError: \", err)\n\treturn HandledRouteError()\n}\n\n// ! Do not call CustomError here otherwise we might get an error loop\nfunc PreError(errmsg string, w http.ResponseWriter, r *http.Request) RouteError {\n\tpi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase(\"error_title\")), errmsg}\n\thandleErrorTemplate(w, r, pi, 500)\n\treturn HandledRouteError()\n}\n\nfunc PreErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError {\n\tw.WriteHeader(500)\n\twriteJsonError(errmsg, w)\n\treturn HandledRouteError()\n}\n\nfunc PreErrorJSQ(errmsg string, w http.ResponseWriter, r *http.Request, js bool) RouteError {\n\tif !js {\n\t\treturn PreError(errmsg, w, r)\n\t}\n\treturn PreErrorJS(errmsg, w, r)\n}\n\n// LocalError is an error shown to the end-user when something goes wrong and it's not the software's fault\n// TODO: Pass header in for this and similar errors instead of having to pass in both user and w? Would also allow for more stateful things, although this could be a problem\n/*func LocalError(errmsg string, w http.ResponseWriter, r *http.Request, user *User) RouteError {\n\tw.WriteHeader(500)\n\tpi := ErrorPage{errorHeader(w, user, p.GetErrorPhrase(\"local_error_title\")), errmsg}\n\thandleErrorTemplate(w, r, pi)\n\treturn HandledRouteError()\n}*/\n\nfunc LocalError(errmsg string, w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\treturn SimpleError(errmsg, w, r, errorHeader(w, u, \"\"))\n}\n\nfunc LocalErrorf(errmsg string, w http.ResponseWriter, r *http.Request, u *User, params ...interface{}) RouteError {\n\treturn LocalError(fmt.Sprintf(errmsg, params), w, r, u)\n}\n\nfunc SimpleError(errmsg string, w http.ResponseWriter, r *http.Request, h *Header) RouteError {\n\tif h == nil {\n\t\th = errorHeader(w, &GuestUser, p.GetErrorPhrase(\"local_error_title\"))\n\t} else {\n\t\th.Title = p.GetErrorPhrase(\"local_error_title\")\n\t}\n\tpi := ErrorPage{h, errmsg}\n\thandleErrorTemplate(w, r, pi, 500)\n\treturn HandledRouteError()\n}\n\nfunc LocalErrorJSQ(errmsg string, w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError {\n\tif !js {\n\t\treturn SimpleError(errmsg, w, r, errorHeader(w, u, \"\"))\n\t}\n\treturn LocalErrorJS(errmsg, w, r)\n}\n\nfunc LocalErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError {\n\tw.WriteHeader(500)\n\twriteJsonError(errmsg, w)\n\treturn HandledRouteError()\n}\n\n// TODO: We might want to centralise the error logic in the future and just return what the error handler needs to construct the response rather than handling it here\n// NoPermissions is an error shown to the end-user when they try to access an area which they aren't authorised to access\nfunc NoPermissions(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tpi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase(\"no_permissions_title\")), p.GetErrorPhrase(\"no_permissions_body\")}\n\thandleErrorTemplate(w, r, pi, 403)\n\treturn HandledRouteError()\n}\n\nfunc NoPermissionsJSQ(w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError {\n\tif !js {\n\t\treturn NoPermissions(w, r, u)\n\t}\n\treturn NoPermissionsJS(w, r, u)\n}\n\nfunc NoPermissionsJS(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tw.WriteHeader(403)\n\twriteJsonError(p.GetErrorPhrase(\"no_permissions_body\"), w)\n\treturn HandledRouteError()\n}\n\n// ? - Is this actually used? Should it be used? A ban in Gosora should be more of a permission revocation to stop them posting rather than something which spits up an error page, right?\nfunc Banned(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tpi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase(\"banned_title\")), p.GetErrorPhrase(\"banned_body\")}\n\thandleErrorTemplate(w, r, pi, 403)\n\treturn HandledRouteError()\n}\n\n// nolint\n// BannedJSQ is the version of the banned error page which handles both JavaScript requests and normal page loads\nfunc BannedJSQ(w http.ResponseWriter, r *http.Request, user *User, js bool) RouteError {\n\tif !js {\n\t\treturn Banned(w, r, user)\n\t}\n\treturn BannedJS(w, r, user)\n}\n\nfunc BannedJS(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tw.WriteHeader(403)\n\twriteJsonError(p.GetErrorPhrase(\"banned_body\"), w)\n\treturn HandledRouteError()\n}\n\n// nolint\nfunc LoginRequiredJSQ(w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError {\n\tif !js {\n\t\treturn LoginRequired(w, r, u)\n\t}\n\treturn LoginRequiredJS(w, r, u)\n}\n\n// ? - Where is this used? Should we use it more?\n// LoginRequired is an error shown to the end-user when they try to access an area which requires them to login\nfunc LoginRequired(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\treturn CustomError(p.GetErrorPhrase(\"login_required_body\"), 401, p.GetErrorPhrase(\"no_permissions_title\"), w, r, nil, u)\n}\n\n// nolint\nfunc LoginRequiredJS(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tw.WriteHeader(401)\n\twriteJsonError(p.GetErrorPhrase(\"login_required_body\"), w)\n\treturn HandledRouteError()\n}\n\n// SecurityError is used whenever a session mismatch is found\n// ? - Should we add JS and JSQ versions of this?\nfunc SecurityError(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tpi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase(\"security_error_title\")), p.GetErrorPhrase(\"security_error_body\")}\n\tw.Header().Set(\"Content-Type\", \"text/html;charset=utf-8\")\n\tw.WriteHeader(403)\n\te := RenderTemplateAlias(\"error\", \"security_error\", w, r, pi.Header, pi)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn HandledRouteError()\n}\n\nvar microNotFoundBytes = []byte(\"file not found\")\nfunc MicroNotFound(w http.ResponseWriter, r *http.Request) RouteError {\n\tw.Header().Set(\"Content-Type\", \"text/html;charset=utf-8\")\n\tw.WriteHeader(404)\n\t_, _ = w.Write(microNotFoundBytes)\n\treturn HandledRouteError()\n}\n\n// NotFound is used when the requested page doesn't exist\n// ? - Add a JSQ version of this?\n// ? - Add a user parameter?\nfunc NotFound(w http.ResponseWriter, r *http.Request, h *Header) RouteError {\n\treturn CustomError(p.GetErrorPhrase(\"not_found_body\"), 404, p.GetErrorPhrase(\"not_found_title\"), w, r, h, &GuestUser)\n}\n\n// ? - Add a user parameter?\nfunc NotFoundJS(w http.ResponseWriter, r *http.Request) RouteError {\n\tw.WriteHeader(404)\n\twriteJsonError(p.GetErrorPhrase(\"not_found_body\"), w)\n\treturn HandledRouteError()\n}\n\nfunc NotFoundJSQ(w http.ResponseWriter, r *http.Request, h *Header, js bool) RouteError {\n\tif js {\n\t\treturn NotFoundJS(w, r)\n\t}\n\tif h == nil {\n\t\th = DefaultHeader(w, &GuestUser)\n\t}\n\treturn NotFound(w, r, h)\n}\n\n// CustomError lets us make custom error types which aren't covered by the generic functions above\nfunc CustomError(errmsg string, errcode int, errtitle string, w http.ResponseWriter, r *http.Request, h *Header, u *User) (rerr RouteError) {\n\tif h == nil {\n\t\th, rerr = UserCheck(w, r, u)\n\t\tif rerr != nil {\n\t\t\th = errorHeader(w, u, errtitle)\n\t\t}\n\t}\n\th.Title = errtitle\n\th.Zone = \"error\"\n\tpi := ErrorPage{h, errmsg}\n\thandleErrorTemplate(w, r, pi, errcode)\n\treturn HandledRouteError()\n}\n\n// CustomErrorJSQ is a version of CustomError which lets us handle both JSON and regular pages depending on how it's being accessed\nfunc CustomErrorJSQ(errmsg string, errcode int, errtitle string, w http.ResponseWriter, r *http.Request, h *Header, u *User, js bool) RouteError {\n\tif !js {\n\t\treturn CustomError(errmsg, errcode, errtitle, w, r, h, u)\n\t}\n\treturn CustomErrorJS(errmsg, errcode, w, r, u)\n}\n\n// CustomErrorJS is the pure JSON version of CustomError\nfunc CustomErrorJS(errmsg string, errcode int, w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tw.WriteHeader(errcode)\n\twriteJsonError(errmsg, w)\n\treturn HandledRouteError()\n}\n\n// TODO: Should we optimise this by caching these json strings?\nfunc writeJsonError(errmsg string, w http.ResponseWriter) {\n\t_, _ = w.Write([]byte(`{\"errmsg\":\"` + strings.Replace(errmsg, \"\\\"\", \"\", -1) + `\"}`))\n}\n\nfunc handleErrorTemplate(w http.ResponseWriter, r *http.Request, pi ErrorPage, errcode int) {\n\tw.Header().Set(\"Content-Type\", \"text/html;charset=utf-8\")\n\tw.WriteHeader(errcode)\n\terr := RenderTemplateAlias(\"error\", \"error\", w, r, pi.Header, pi)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n}\n\n// Alias of routes.renderTemplate\nvar RenderTemplateAlias func(tmplName, hookName string, w http.ResponseWriter, r *http.Request, h *Header, pi interface{}) error\n"
  },
  {
    "path": "common/extend.go",
    "content": "/*\n*\n* Gosora Plugin System\n* Copyright Azareal 2016 - 2021\n*\n */\npackage common\n\n// TODO: Break this file up into multiple files to make it easier to maintain\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"log\"\n\t\"net/http\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar ErrPluginNotInstallable = errors.New(\"This plugin is not installable\")\n\ntype PluginList map[string]*Plugin\n\n// TODO: Have a proper store rather than a map?\nvar Plugins PluginList = make(map[string]*Plugin)\n\nfunc (l PluginList) Add(pl *Plugin) {\n\tbuildPlugin(pl)\n\tl[pl.UName] = pl\n}\n\nfunc buildPlugin(pl *Plugin) {\n\tpl.Installable = (pl.Install != nil)\n\t/*\n\t\tThe Active field should never be altered by a plugin. It's used internally by the software to determine whether an admin has enabled a plugin or not and whether to run it. This will be overwritten by the user's preference.\n\t*/\n\tpl.Active = false\n\tpl.Installed = false\n\tpl.Hooks = make(map[string]int)\n\tpl.Data = nil\n}\n\nvar hookTableBox atomic.Value\n\n// ! HookTable is a work in progress, do not use it yet\n// TODO: Test how fast it is to indirect hooks off the hook table as opposed to using them normally or using an interface{} for the hooks\n// TODO: Can we filter the HookTable for each request down to only hooks the request actually uses?\n// TODO: Make the RunXHook functions methods on HookTable\n// TODO: Have plugins update hooks on a mutex guarded map and create a copy of that map in a serial global goroutine which gets thrown in the atomic.Value\ntype HookTable struct {\n\t//Hooks           map[string][]func(interface{}) interface{}\n\tHooksNoRet      map[string][]func(interface{})\n\tHooksSkip       map[string][]func(interface{}) bool\n\tVhooks          map[string]func(...interface{}) interface{}\n\tVhookSkippable_ map[string]func(...interface{}) (bool, RouteError)\n\tSshooks         map[string][]func(string) string\n\tPreRenderHooks  map[string][]func(http.ResponseWriter, *http.Request, *User, interface{}) bool\n\n\t// For future use:\n\t//messageHooks map[string][]func(Message, PageInt, ...interface{}) interface{}\n}\n\nfunc init() {\n\tRebuildHookTable()\n}\n\n// For extend.go use only, access this via GetHookTable() elsewhere\nvar hookTable = &HookTable{\n\t//map[string][]func(interface{}) interface{}{},\n\tmap[string][]func(interface{}){\n\t\t\"forums_frow_assign\": nil, //hg\n\t},\n\tmap[string][]func(interface{}) bool{\n\t\t\"topic_create_frow_assign\": nil, //hg\n\t},\n\tmap[string]func(...interface{}) interface{}{\n\t\t//\"convo_post_update\":nil,\n\t\t//\"convo_post_create\":nil,\n\n\t\t///\"forum_trow_assign\":       nil,\n\t\t\"topics_topic_row_assign\": nil,\n\t\t//\"topics_user_row_assign\": nil,\n\t\t\"topic_reply_row_assign\": nil,\n\t\t\"create_group_preappend\": nil, // What is this? Investigate!\n\t\t\"topic_create_pre_loop\":  nil,\n\n\t\t\"router_end\": nil,\n\t},\n\tmap[string]func(...interface{}) (bool, RouteError){\n\t\t\"simple_forum_check_pre_perms\": nil, //hg\n\t\t\"forum_check_pre_perms\":        nil, //hg\n\n\t\t\"route_topic_list_start\":            nil,\n\t\t\"route_topic_list_mostviewed_start\": nil,\n\t\t\"route_forum_list_start\":            nil,\n\t\t\"route_attach_start\":                nil,\n\t\t\"route_attach_post_get\":             nil,\n\n\t\t\"action_end_create_topic\":  nil,\n\t\t\"action_end_edit_topic\":    nil,\n\t\t\"action_end_delete_topic\":  nil,\n\t\t\"action_end_lock_topic\":    nil,\n\t\t\"action_end_unlock_topic\":  nil,\n\t\t\"action_end_stick_topic\":   nil,\n\t\t\"action_end_unstick_topic\": nil,\n\t\t\"action_end_move_topic\":    nil,\n\t\t\"action_end_like_topic\":    nil,\n\t\t\"action_end_unlike_topic\":  nil,\n\n\t\t\"action_end_create_reply\":             nil,\n\t\t\"action_end_edit_reply\":               nil,\n\t\t\"action_end_delete_reply\":             nil,\n\t\t\"action_end_add_attach_to_reply\":      nil,\n\t\t\"action_end_remove_attach_from_reply\": nil,\n\n\t\t\"action_end_like_reply\":   nil,\n\t\t\"action_end_unlike_reply\": nil,\n\n\t\t\"action_end_ban_user\":      nil,\n\t\t\"action_end_unban_user\":    nil,\n\t\t\"action_end_activate_user\": nil,\n\n\t\t\"router_after_filters\": nil,\n\t\t\"router_pre_route\":     nil,\n\n\t\t\"tasks_tick_topic_list\": nil,\n\t\t\"tasks_tick_widget_wol\": nil,\n\n\t\t\"counters_perf_tick_row\": nil,\n\t},\n\tmap[string][]func(string) string{\n\t\t\"preparse_preassign\":  nil,\n\t\t\"parse_assign\":        nil,\n\t\t\"topic_ogdesc_assign\": nil,\n\t},\n\tnil,\n\t//nil,\n}\nvar hookTableUpdateMutex sync.Mutex\n\nfunc RebuildHookTable() {\n\thookTableUpdateMutex.Lock()\n\tdefer hookTableUpdateMutex.Unlock()\n\tunsafeRebuildHookTable()\n}\n\nfunc unsafeRebuildHookTable() {\n\tihookTable := new(HookTable)\n\t*ihookTable = *hookTable\n\thookTableBox.Store(ihookTable)\n}\n\nfunc GetHookTable() *HookTable {\n\treturn hookTableBox.Load().(*HookTable)\n}\n\n// Hooks with a single argument. Is this redundant? Might be useful for inlining, as variadics aren't inlined? Are closures even inlined to begin with?\n/*func (t *HookTable) Hook(name string, data interface{}) interface{} {\n\tfor _, hook := range t.Hooks[name] {\n\t\tdata = hook(data)\n\t}\n\treturn data\n}*/\n\nfunc (t *HookTable) HookNoRet(name string, data interface{}) {\n\tfor _, hook := range t.HooksNoRet[name] {\n\t\thook(data)\n\t}\n}\n\n// To cover the case in routes/topic.go's CreateTopic route, we could probably obsolete this use and replace it\nfunc (t *HookTable) HookSkip(name string, data interface{}) (skip bool) {\n\tfor _, hook := range t.HooksSkip[name] {\n\t\tif skip = hook(data); skip {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn skip\n}\n\n// Hooks with a variable number of arguments\n// TODO: Use RunHook semantics to allow multiple lined up plugins / modules their turn?\nfunc (t *HookTable) Vhook(name string, data ...interface{}) interface{} {\n\tif hook := t.Vhooks[name]; hook != nil {\n\t\treturn hook(data...)\n\t}\n\treturn nil\n}\n\nfunc (t *HookTable) VhookNoRet(name string, data ...interface{}) {\n\tif hook := t.Vhooks[name]; hook != nil {\n\t\t_ = hook(data...)\n\t}\n}\n\n// TODO: Find a better way of doing this\nfunc (t *HookTable) VhookNeedHook(name string, data ...interface{}) (ret interface{}, hasHook bool) {\n\tif hook := t.Vhooks[name]; hook != nil {\n\t\treturn hook(data...), true\n\t}\n\treturn nil, false\n}\n\n// Hooks with a variable number of arguments and return values for skipping the parent function and propagating an error upwards\nfunc (t *HookTable) VhookSkippable(name string, data ...interface{}) (bool, RouteError) {\n\tif hook := t.VhookSkippable_[name]; hook != nil {\n\t\treturn hook(data...)\n\t}\n\treturn false, nil\n}\n\n/*func VhookSkippableTest(t *HookTable, name string, data ...interface{}) (bool, RouteError) {\n\tif hook := t.VhookSkippable_[name]; hook != nil {\n\t\treturn hook(data...)\n\t}\n\treturn false, nil\n}\n\nfunc forum_check_pre_perms_hook(t *HookTable, w http.ResponseWriter, r *http.Request, u *User, fid *int, h *Header) (bool, RouteError) {\n\thook := t.VhookSkippable_[\"forum_check_pre_perms\"]\n\tif hook != nil {\n\t\treturn hook(w, r, u, fid, h)\n\t}\n\treturn false, nil\n}*/\n\n// Hooks which take in and spit out a string. This is usually used for parser components\n// Trying to get a teeny bit of type-safety where-ever possible, especially for such a critical set of hooks\nfunc (t *HookTable) Sshook(name, data string) string {\n\tfor _, hook := range t.Sshooks[name] {\n\t\tdata = hook(data)\n\t}\n\treturn data\n}\n\n//var vhookErrorable = map[string]func(...interface{}) (interface{}, RouteError){}\n\nvar taskHooks = map[string][]func() error{\n\t\"before_half_second_tick\":    nil,\n\t\"after_half_second_tick\":     nil,\n\t\"before_second_tick\":         nil,\n\t\"after_second_tick\":          nil,\n\t\"before_fifteen_minute_tick\": nil,\n\t\"after_fifteen_minute_tick\":  nil,\n\t\"before_shutdown_tick\":       nil,\n\t\"after_shutdown_tick\":        nil,\n}\n\n// Coming Soon:\ntype Message interface {\n\tID() int\n\tPoster() int\n\tContents() string\n\tParsedContents() string\n}\n\n// While the idea is nice, this might result in too much code duplication, as we have seventy billion page structs, what else could we do to get static typing with these in plugins?\ntype PageInt interface {\n\tTitle() string\n\tHeader() *Header\n\tCurrentUser() *User\n\tGetExtData(name string) interface{}\n\tSetExtData(name string, contents interface{})\n}\n\n// Coming Soon:\nvar messageHooks = map[string][]func(Message, PageInt, ...interface{}) interface{}{\n\t\"topic_reply_row_assign\": nil,\n}\n\n// The hooks which run before the template is rendered for a route\nvar PreRenderHooks = map[string][]func(http.ResponseWriter, *http.Request, *User, interface{}) bool{\n\t\"pre_render\": nil,\n\n\t\"pre_render_forums\":       nil,\n\t\"pre_render_forum\":        nil,\n\t\"pre_render_topics\":       nil,\n\t\"pre_render_topic\":        nil,\n\t\"pre_render_profile\":      nil,\n\t\"pre_render_custom_page\":  nil,\n\t\"pre_render_tmpl_page\":    nil,\n\t\"pre_render_overview\":     nil,\n\t\"pre_render_create_topic\": nil,\n\n\t\"pre_render_account_own_edit\":           nil,\n\t\"pre_render_account_own_edit_password\":  nil,\n\t\"pre_render_account_own_edit_mfa\":       nil,\n\t\"pre_render_account_own_edit_mfa_setup\": nil,\n\t\"pre_render_account_own_edit_email\":     nil,\n\t\"pre_render_level_list\":                 nil,\n\t\"pre_render_login\":                      nil,\n\t\"pre_render_login_mfa_verify\":           nil,\n\t\"pre_render_register\":                   nil,\n\t\"pre_render_ban\":                        nil,\n\t\"pre_render_ip_search\":                  nil,\n\n\t\"pre_render_panel_dashboard\":        nil,\n\t\"pre_render_panel_forums\":           nil,\n\t\"pre_render_panel_delete_forum\":     nil,\n\t\"pre_render_panel_forum_edit\":       nil,\n\t\"pre_render_panel_forum_edit_perms\": nil,\n\n\t\"pre_render_panel_analytics_views\":          nil,\n\t\"pre_render_panel_analytics_routes\":         nil,\n\t\"pre_render_panel_analytics_agents\":         nil,\n\t\"pre_render_panel_analytics_systems\":        nil,\n\t\"pre_render_panel_analytics_referrers\":      nil,\n\t\"pre_render_panel_analytics_route_views\":    nil,\n\t\"pre_render_panel_analytics_agent_views\":    nil,\n\t\"pre_render_panel_analytics_system_views\":   nil,\n\t\"pre_render_panel_analytics_referrer_views\": nil,\n\n\t\"pre_render_panel_settings\":          nil,\n\t\"pre_render_panel_setting\":           nil,\n\t\"pre_render_panel_word_filters\":      nil,\n\t\"pre_render_panel_word_filters_edit\": nil,\n\t\"pre_render_panel_plugins\":           nil,\n\t\"pre_render_panel_users\":             nil,\n\t\"pre_render_panel_user_edit\":         nil,\n\t\"pre_render_panel_groups\":            nil,\n\t\"pre_render_panel_group_edit\":        nil,\n\t\"pre_render_panel_group_edit_perms\":  nil,\n\t\"pre_render_panel_themes\":            nil,\n\t\"pre_render_panel_modlogs\":           nil,\n\n\t\"pre_render_error\": nil, // Note: This hook isn't run for a few errors whose templates are computed at startup and reused, such as InternalError. This hook is also not available in JS mode.\n\t// ^-- I don't know if it's run for InternalError, but it isn't computed at startup anymore\n\t\"pre_render_security_error\": nil,\n}\n\n// ? - Should we make this an interface which plugins implement instead?\n// Plugin is a struct holding the metadata for a plugin, along with a few of it's primary handlers.\ntype Plugin struct {\n\tUName       string\n\tName        string\n\tAuthor      string\n\tURL         string\n\tSettings    string\n\tActive      bool\n\tTag         string\n\tType        string\n\tInstallable bool\n\tInstalled   bool\n\n\tInit       func(pl *Plugin) error\n\tActivate   func(pl *Plugin) error\n\tDeactivate func(pl *Plugin) // TODO: We might want to let this return an error?\n\tInstall    func(pl *Plugin) error\n\tUninstall  func(pl *Plugin) error // TODO: I'm not sure uninstall is implemented\n\n\tHooks map[string]int // Active hooks\n\tMeta  PluginMetaData\n\tData  interface{} // Usually used for hosting the VMs / reusable elements of non-native plugins\n}\n\ntype PluginMetaData struct {\n\tHooks []string\n\t//StaticHooks map[string]string\n}\n\nfunc (pl *Plugin) BypassActive() (active bool, err error) {\n\terr = extendStmts.isActive.QueryRow(pl.UName).Scan(&active)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn false, err\n\t}\n\treturn active, nil\n}\n\nfunc (pl *Plugin) InDatabase() (exists bool, err error) {\n\tvar sink bool\n\terr = extendStmts.isActive.QueryRow(pl.UName).Scan(&sink)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn false, err\n\t}\n\treturn err == nil, nil\n}\n\n// TODO: Silently add to the database, if it doesn't exist there rather than forcing users to call AddToDatabase instead?\nfunc (pl *Plugin) SetActive(active bool) (err error) {\n\t_, err = extendStmts.setActive.Exec(active, pl.UName)\n\tif err == nil {\n\t\tpl.Active = active\n\t}\n\treturn err\n}\n\n// TODO: Silently add to the database, if it doesn't exist there rather than forcing users to call AddToDatabase instead?\nfunc (pl *Plugin) SetInstalled(installed bool) (err error) {\n\tif !pl.Installable {\n\t\treturn ErrPluginNotInstallable\n\t}\n\t_, err = extendStmts.setInstalled.Exec(installed, pl.UName)\n\tif err == nil {\n\t\tpl.Installed = installed\n\t}\n\treturn err\n}\n\nfunc (pl *Plugin) AddToDatabase(active, installed bool) (err error) {\n\t_, err = extendStmts.add.Exec(pl.UName, active, installed)\n\tif err == nil {\n\t\tpl.Active = active\n\t\tpl.Installed = installed\n\t}\n\treturn err\n}\n\ntype ExtendStmts struct {\n\tgetPlugins *sql.Stmt\n\n\tisActive     *sql.Stmt\n\tsetActive    *sql.Stmt\n\tsetInstalled *sql.Stmt\n\tadd          *sql.Stmt\n}\n\nvar extendStmts ExtendStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tpl := \"plugins\"\n\t\textendStmts = ExtendStmts{\n\t\t\tgetPlugins: acc.Select(pl).Columns(\"uname,active,installed\").Prepare(),\n\n\t\t\tisActive:     acc.Select(pl).Columns(\"active\").Where(\"uname=?\").Prepare(),\n\t\t\tsetActive:    acc.Update(pl).Set(\"active=?\").Where(\"uname=?\").Prepare(),\n\t\t\tsetInstalled: acc.Update(pl).Set(\"installed=?\").Where(\"uname=?\").Prepare(),\n\t\t\tadd:          acc.Insert(pl).Columns(\"uname,active,installed\").Fields(\"?,?,?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc InitExtend() error {\n\terr := InitPluginLangs()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn Plugins.Load()\n}\n\n// Load polls the database to see which plugins have been activated and which have been installed\nfunc (l PluginList) Load() error {\n\trows, err := extendStmts.getPlugins.Query()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tvar uname string\n\tvar active, installed bool\n\tfor rows.Next() {\n\t\terr = rows.Scan(&uname, &active, &installed)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Was the plugin deleted at some point?\n\t\tpl, ok := l[uname]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tpl.Active = active\n\t\tpl.Installed = installed\n\t\tl[uname] = pl\n\t}\n\treturn rows.Err()\n}\n\n// ? - Is this racey?\n// TODO: Generate the cases in this switch\nfunc (pl *Plugin) AddHook(name string, hInt interface{}) {\n\thookTableUpdateMutex.Lock()\n\tdefer hookTableUpdateMutex.Unlock()\n\n\tswitch h := hInt.(type) {\n\t/*case func(interface{}) interface{}:\n\tif len(hookTable.Hooks[name]) == 0 {\n\t\thookTable.Hooks[name] = []func(interface{}) interface{}{}\n\t}\n\thookTable.Hooks[name] = append(hookTable.Hooks[name], h)\n\tpl.Hooks[name] = len(hookTable.Hooks[name]) - 1*/\n\tcase func(interface{}):\n\t\tif len(hookTable.HooksNoRet[name]) == 0 {\n\t\t\thookTable.HooksNoRet[name] = []func(interface{}){}\n\t\t}\n\t\thookTable.HooksNoRet[name] = append(hookTable.HooksNoRet[name], h)\n\t\tpl.Hooks[name] = len(hookTable.HooksNoRet[name]) - 1\n\tcase func(interface{}) bool:\n\t\tif len(hookTable.HooksSkip[name]) == 0 {\n\t\t\thookTable.HooksSkip[name] = []func(interface{}) bool{}\n\t\t}\n\t\thookTable.HooksSkip[name] = append(hookTable.HooksSkip[name], h)\n\t\tpl.Hooks[name] = len(hookTable.HooksSkip[name]) - 1\n\tcase func(string) string:\n\t\tif len(hookTable.Sshooks[name]) == 0 {\n\t\t\thookTable.Sshooks[name] = []func(string) string{}\n\t\t}\n\t\thookTable.Sshooks[name] = append(hookTable.Sshooks[name], h)\n\t\tpl.Hooks[name] = len(hookTable.Sshooks[name]) - 1\n\tcase func(http.ResponseWriter, *http.Request, *User, interface{}) bool:\n\t\tif len(PreRenderHooks[name]) == 0 {\n\t\t\tPreRenderHooks[name] = []func(http.ResponseWriter, *http.Request, *User, interface{}) bool{}\n\t\t}\n\t\tPreRenderHooks[name] = append(PreRenderHooks[name], h)\n\t\tpl.Hooks[name] = len(PreRenderHooks[name]) - 1\n\tcase func() error: // ! We might want a more generic name, as we might use this signature for things other than tasks hooks\n\t\tif len(taskHooks[name]) == 0 {\n\t\t\ttaskHooks[name] = []func() error{}\n\t\t}\n\t\ttaskHooks[name] = append(taskHooks[name], h)\n\t\tpl.Hooks[name] = len(taskHooks[name]) - 1\n\tcase func(...interface{}) interface{}:\n\t\thookTable.Vhooks[name] = h\n\t\tpl.Hooks[name] = 0\n\tcase func(...interface{}) (bool, RouteError):\n\t\thookTable.VhookSkippable_[name] = h\n\t\tpl.Hooks[name] = 0\n\tdefault:\n\t\tpanic(\"I don't recognise this kind of handler!\") // Should this be an error for the plugin instead of a panic()?\n\t}\n\t// TODO: Do this once during plugin activation / deactivation rather than doing it for each hook\n\tunsafeRebuildHookTable()\n}\n\n// ? - Is this racey?\n// TODO: Generate the cases in this switch\nfunc (pl *Plugin) RemoveHook(name string, hInt interface{}) {\n\thookTableUpdateMutex.Lock()\n\tdefer hookTableUpdateMutex.Unlock()\n\n\tkey, ok := pl.Hooks[name]\n\tif !ok {\n\t\tpanic(\"handler not registered as hook\")\n\t}\n\n\tswitch hInt.(type) {\n\t/*case func(interface{}) interface{}:\n\thook := hookTable.Hooks[name]\n\tif len(hook) == 1 {\n\t\thook = []func(interface{}) interface{}{}\n\t} else {\n\t\thook = append(hook[:key], hook[key+1:]...)\n\t}\n\thookTable.Hooks[name] = hook*/\n\tcase func(interface{}):\n\t\thook := hookTable.HooksNoRet[name]\n\t\tif len(hook) == 1 {\n\t\t\thook = []func(interface{}){}\n\t\t} else {\n\t\t\thook = append(hook[:key], hook[key+1:]...)\n\t\t}\n\t\thookTable.HooksNoRet[name] = hook\n\tcase func(interface{}) bool:\n\t\thook := hookTable.HooksSkip[name]\n\t\tif len(hook) == 1 {\n\t\t\thook = []func(interface{}) bool{}\n\t\t} else {\n\t\t\thook = append(hook[:key], hook[key+1:]...)\n\t\t}\n\t\thookTable.HooksSkip[name] = hook\n\tcase func(string) string:\n\t\thook := hookTable.Sshooks[name]\n\t\tif len(hook) == 1 {\n\t\t\thook = []func(string) string{}\n\t\t} else {\n\t\t\thook = append(hook[:key], hook[key+1:]...)\n\t\t}\n\t\thookTable.Sshooks[name] = hook\n\tcase func(http.ResponseWriter, *http.Request, *User, interface{}) bool:\n\t\thook := PreRenderHooks[name]\n\t\tif len(hook) == 1 {\n\t\t\thook = []func(http.ResponseWriter, *http.Request, *User, interface{}) bool{}\n\t\t} else {\n\t\t\thook = append(hook[:key], hook[key+1:]...)\n\t\t}\n\t\tPreRenderHooks[name] = hook\n\tcase func() error:\n\t\thook := taskHooks[name]\n\t\tif len(hook) == 1 {\n\t\t\thook = []func() error{}\n\t\t} else {\n\t\t\thook = append(hook[:key], hook[key+1:]...)\n\t\t}\n\t\ttaskHooks[name] = hook\n\tcase func(...interface{}) interface{}:\n\t\tdelete(hookTable.Vhooks, name)\n\tcase func(...interface{}) (bool, RouteError):\n\t\tdelete(hookTable.VhookSkippable_, name)\n\tdefault:\n\t\tpanic(\"I don't recognise this kind of handler!\") // Should this be an error for the plugin instead of a panic()?\n\t}\n\tdelete(pl.Hooks, name)\n\t// TODO: Do this once during plugin activation / deactivation rather than doing it for each hook\n\tunsafeRebuildHookTable()\n}\n\n// TODO: Add a HasHook method to complete the AddHook, RemoveHook, etc. set?\n\nvar PluginsInited = false\n\nfunc InitPlugins() {\n\tfor name, body := range Plugins {\n\t\tlog.Printf(\"Added plugin '%s'\", name)\n\t\tif body.Active {\n\t\t\tlog.Printf(\"Initialised plugin '%s'\", name)\n\t\t\tif body.Init != nil {\n\t\t\t\tif err := body.Init(body); err != nil {\n\t\t\t\t\tlog.Print(err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"Plugin '%s' doesn't have an initialiser.\", name)\n\t\t\t}\n\t\t}\n\t}\n\tPluginsInited = true\n}\n\n// ? - Are the following functions racey?\nfunc RunTaskHook(name string) error {\n\tfor _, hook := range taskHooks[name] {\n\t\tif e := hook(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc RunPreRenderHook(name string, w http.ResponseWriter, r *http.Request, u *User, data interface{}) (halt bool) {\n\t// This hook runs on ALL PreRender hooks\n\tpreRenderHooks, ok := PreRenderHooks[\"pre_render\"]\n\tif ok {\n\t\tfor _, hook := range preRenderHooks {\n\t\t\tif hook(w, r, u, data) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\t// The actual PreRender hook\n\tpreRenderHooks, ok = PreRenderHooks[name]\n\tif ok {\n\t\tfor _, hook := range preRenderHooks {\n\t\t\tif hook(w, r, u, data) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "common/files.go",
    "content": "package common\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"mime\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\ttmpl \"github.com/Azareal/Gosora/tmpl_client\"\n\t\"github.com/andybalholm/brotli\"\n)\n\n//type SFileList map[string]*SFile\n//type SFileListShort map[string]*SFile\n\nvar StaticFiles = SFileList{\"/s/\", make(map[string]*SFile), make(map[string]*SFile)}\n\n//var StaticFilesShort SFileList = make(map[string]*SFile)\nvar staticFileMutex sync.RWMutex\n\n// ? Is it efficient to have two maps for this?\ntype SFileList struct {\n\tPrefix string\n\tLong   map[string]*SFile\n\tShort  map[string]*SFile\n}\n\ntype SFile struct {\n\t// TODO: Move these to the end?\n\tData     []byte\n\tGzipData []byte\n\tBrData   []byte\n\n\tSha256 string\n\tSha256I string\n\tOName  string\n\tPos    int64\n\n\tLength        int64\n\tStrLength     string\n\tGzipLength    int64\n\tStrGzipLength string\n\tBrLength      int64\n\tStrBrLength   string\n\n\tMimetype         string\n\tInfo             os.FileInfo\n\tFormattedModTime string\n}\n\ntype CSSData struct {\n\tPhrases map[string]string\n}\n\nfunc (l SFileList) JSTmplInit() error {\n\tDebugLog(\"Initialising the client side templates\")\n\treturn filepath.Walk(\"./tmpl_client\", func(path string, f os.FileInfo, err error) error {\n\t\tif f.IsDir() || strings.HasSuffix(path, \"tmpl_list.go\") || strings.HasSuffix(path, \"stub.go\") {\n\t\t\treturn nil\n\t\t}\n\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\tDebugLog(\"Processing client template \" + path)\n\t\tdata, err := ioutil.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpath = strings.TrimPrefix(path, \"tmpl_client/\")\n\t\ttmplName := strings.TrimSuffix(path, \".jgo\")\n\t\tshortName := strings.TrimPrefix(tmplName, \"tmpl_\")\n\n\t\treplace := func(data []byte, replaceThis, withThis string) []byte {\n\t\t\treturn bytes.Replace(data, []byte(replaceThis), []byte(withThis), -1)\n\t\t}\n\t\trep := func(replaceThis, withThis string) {\n\t\t\tdata = replace(data, replaceThis, withThis)\n\t\t}\n\n\t\tstartIndex, hasFunc := skipAllUntilCharsExist(data, 0, []byte(\"if(tmplInits===undefined)\"))\n\t\tif !hasFunc {\n\t\t\treturn errors.New(\"no init map found\")\n\t\t}\n\t\tdata = data[startIndex-len([]byte(\"if(tmplInits===undefined)\")):]\n\t\trep(\"// nolint\", \"\")\n\t\t//rep(\"func \", \"function \")\n\t\trep(\"func \", \"function \")\n\t\trep(\" error {\\n\", \" {\\nlet o=\\\"\\\"\\n\")\n\t\tfuncIndex, hasFunc := skipAllUntilCharsExist(data, 0, []byte(\"function Tmpl_\"))\n\t\tif !hasFunc {\n\t\t\treturn errors.New(\"no template function found\")\n\t\t}\n\t\tspaceIndex, hasSpace := skipUntilIfExists(data, funcIndex, ' ')\n\t\tif !hasSpace {\n\t\t\treturn errors.New(\"no spaces found after the template function name\")\n\t\t}\n\t\tendBrace, hasBrace := skipUntilIfExists(data, spaceIndex, ')')\n\t\tif !hasBrace {\n\t\t\treturn errors.New(\"no right brace found after the template function name\")\n\t\t}\n\t\tfmt.Println(\"spaceIndex: \", spaceIndex)\n\t\tfmt.Println(\"endBrace: \", endBrace)\n\t\tfmt.Println(\"string(data[spaceIndex:endBrace]): \", string(data[spaceIndex:endBrace]))\n\n\t\tpreLen := len(data)\n\t\trep(string(data[spaceIndex:endBrace]), \"\")\n\t\trep(\"))\\n\", \"  \\n\")\n\t\tendBrace -= preLen - len(data) // Offset it as we've deleted portions\n\t\tfmt.Println(\"new endBrace: \", endBrace)\n\t\tfmt.Println(\"data: \", string(data))\n\n\t\t/*showPos := func(data []byte, index int) (out string) {\n\t\t\tout = \"[\"\n\t\t\tfor j, char := range data {\n\t\t\t\tif index == j {\n\t\t\t\t\tout += \"[\" + string(char) + \"] \"\n\t\t\t\t} else {\n\t\t\t\t\tout += string(char) + \" \"\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn out + \"]\"\n\t\t}*/\n\n\t\t// ? Can we just use a regex? I'm thinking of going more efficient, or just outright rolling wasm, this is a temp hack in a place where performance doesn't particularly matter\n\t\teach := func(phrase string, h func(index int)) {\n\t\t\t//fmt.Println(\"find each '\" + phrase + \"'\")\n\t\t\tindex := endBrace\n\t\t\tif index < 0 {\n\t\t\t\tpanic(\"index under zero: \" + strconv.Itoa(index))\n\t\t\t}\n\t\t\tvar foundIt bool\n\t\t\tfor {\n\t\t\t\t//fmt.Println(\"in index: \", index)\n\t\t\t\t//fmt.Println(\"pos: \", showPos(data, index))\n\t\t\t\tindex, foundIt = skipAllUntilCharsExist(data, index, []byte(phrase))\n\t\t\t\tif !foundIt {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\th(index)\n\t\t\t}\n\t\t}\n\t\teach(\"strconv.Itoa(\", func(index int) {\n\t\t\tbraceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')')\n\t\t\tif hasEndBrace {\n\t\t\t\tdata[braceAt] = ' ' // Blank it\n\t\t\t}\n\t\t})\n\t\teach(\"[]byte(\", func(index int) {\n\t\t\tbraceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')')\n\t\t\tif hasEndBrace {\n\t\t\t\tdata[braceAt] = ' ' // Blank it\n\t\t\t}\n\t\t})\n\t\teach(\"StringToBytes(\", func(index int) {\n\t\t\tbraceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')')\n\t\t\tif hasEndBrace {\n\t\t\t\tdata[braceAt] = ' ' // Blank it\n\t\t\t}\n\t\t})\n\t\teach(\"w.Write(\", func(index int) {\n\t\t\tbraceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')')\n\t\t\tif hasEndBrace {\n\t\t\t\tdata[braceAt] = ' ' // Blank it\n\t\t\t}\n\t\t})\n\t\teach(\"RelativeTime(\", func(index int) {\n\t\t\tbraceAt, _ := skipUntilIfExistsOrLine(data, index, 10)\n\t\t\tif data[braceAt-1] == ' ' {\n\t\t\t\tdata[braceAt-1] = ' ' // Blank it\n\t\t\t}\n\t\t})\n\t\teach(\"if \", func(index int) {\n\t\t\t//fmt.Println(\"if index: \", index)\n\t\t\tbraceAt, hasBrace := skipUntilIfExistsOrLine(data, index, '{')\n\t\t\tif hasBrace {\n\t\t\t\tif data[braceAt-1] != ' ' {\n\t\t\t\t\tpanic(\"couldn't find space before brace, found ' \" + string(data[braceAt-1]) + \"' instead\")\n\t\t\t\t}\n\t\t\t\tdata[braceAt-1] = ')' // Drop a brace here to satisfy JS\n\t\t\t}\n\t\t})\n\t\teach(\"for _, item := range \", func(index int) {\n\t\t\t//fmt.Println(\"for index: \", index)\n\t\t\tbraceAt, hasBrace := skipUntilIfExists(data, index, '{')\n\t\t\tif hasBrace {\n\t\t\t\tif data[braceAt-1] != ' ' {\n\t\t\t\t\tpanic(\"couldn't find space before brace, found ' \" + string(data[braceAt-1]) + \"' instead\")\n\t\t\t\t}\n\t\t\t\tdata[braceAt-1] = ')' // Drop a brace here to satisfy JS\n\t\t\t}\n\t\t})\n\t\trep(\"for _, item := range \", \"for(item of \")\n\t\trep(\"w.Write([]byte(\", \"o += \")\n\t\trep(\"w.Write(StringToBytes(\", \"o += \")\n\t\trep(\"w.Write(\", \"o += \")\n\t\trep(\"+= c.\", \"+= \")\n\t\trep(\"strconv.Itoa(\", \"\")\n\t\trep(\"strconv.FormatInt(\", \"\")\n\t\trep(\"\tc.\", \"\")\n\t\trep(\"phrases.\", \"\")\n\t\trep(\", 10;\", \"\")\n\n\t\t//rep(\"var plist = GetTmplPhrasesBytes(\"+shortName+\"_tmpl_phrase_id)\", \"const plist = tmplPhrases[\\\"\"+tmplName+\"\\\"];\")\n\t\t//rep(\"//var plist = GetTmplPhrasesBytes(\"+shortName+\"_tmpl_phrase_id)\", \"const \"+shortName+\"_phrase_arr = tmplPhrases[\\\"\"+tmplName+\"\\\"];\")\n\t\trep(\"//var plist = GetTmplPhrasesBytes(\"+shortName+\"_tmpl_phrase_id)\", \"const pl=tmplPhrases[\\\"\"+tmplName+\"\\\"];\")\n\t\trep(shortName+\"_phrase_arr\", \"pl\")\n\t\trep(shortName+\"_phrase\", \"pl\")\n\t\trep(\"tmpl_\"+shortName+\"_vars\", \"t_v\")\n\n\t\trep(\"var c_v_\", \"let c_v_\")\n\t\trep(`t_vars, ok := tmpl_i.`, `/*`)\n\t\trep(\"[]byte(\", \"\")\n\t\trep(\"StringToBytes(\", \"\")\n\t\trep(\"RelativeTime(t_v.\", \"t_v.Relative\")\n\t\t// TODO: Format dates properly on the client side\n\t\trep(\".Format(\\\"2006-01-02 15:04:05\\\"\", \"\")\n\t\trep(\", 10\", \"\")\n\t\trep(\"if \", \"if(\")\n\t\trep(\"return nil\", \"return o\")\n\t\trep(\" )\", \")\")\n\t\trep(\" \\n\", \"\\n\")\n\t\trep(\"\\n\", \";\\n\")\n\t\trep(\"{;\", \"{\")\n\t\trep(\"};\", \"}\")\n\t\trep(\"[;\", \"[\")\n\t\trep(\",;\", \",\")\n\t\trep(\"=;\", \"=\")\n\t\trep(`,\n\t});\n}`, \"\\n\\t];\")\n\t\trep(`=\n}`, \"=[]\")\n\t\trep(\"o += \", \"o+=\")\n\t\trep(shortName+\"_frags[\", \"fr[\")\n\t\trep(\"function Tmpl_\"+shortName+\"(t_v) {\", \"var Tmpl_\"+shortName+\"=(t_v)=>{\")\n\n\t\tfragset := tmpl.GetFrag(shortName)\n\t\tif fragset != nil {\n\t\t\t//sfrags := []byte(\"let \" + shortName + \"_frags=[\\n\")\n\t\t\tsfrags := []byte(\"{const fr=[\")\n\t\t\tfor i, frags := range fragset {\n\t\t\t\t//sfrags = append(sfrags, []byte(shortName+\"_frags.push(`\"+string(frags)+\"`);\\n\")...)\n\t\t\t\t//sfrags = append(sfrags, []byte(\"`\"+string(frags)+\"`,\\n\")...)\n\t\t\t\tif i == 0 {\n\t\t\t\t\tsfrags = append(sfrags, []byte(\"`\"+string(frags)+\"`\")...)\n\t\t\t\t} else {\n\t\t\t\t\tsfrags = append(sfrags, []byte(\",`\"+string(frags)+\"`\")...)\n\t\t\t\t}\n\t\t\t}\n\t\t\t//sfrags = append(sfrags, []byte(\"];\\n\")...)\n\t\t\tsfrags = append(sfrags, []byte(\"];\")...)\n\t\t\tdata = append(sfrags, data...)\n\t\t}\n\t\trep(\"\\n;\", \"\\n\")\n\t\trep(\";;\", \";\")\n\n\t\tdata = append(data, '}')\n\t\tfor name, _ := range Themes {\n\t\t\tif strings.HasSuffix(shortName, \"_\"+name) {\n\t\t\t\tdata = append(data, \"var Tmpl_\"+strings.TrimSuffix(shortName, \"_\"+name)+\"=Tmpl_\"+shortName+\";\"...)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tpath = tmplName + \".js\"\n\t\tDebugLog(\"js path: \", path)\n\t\text := filepath.Ext(\"/tmpl_client/\" + path)\n\n\t\tbrData, err := CompressBytesBrotli(data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Don't use Brotli if we get meagre gains from it as it takes longer to process the responses\n\t\tif len(brData) >= (len(data) + 110) {\n\t\t\tbrData = nil\n\t\t} else {\n\t\t\tdiff := len(data) - len(brData)\n\t\t\tif diff <= len(data)/100 {\n\t\t\t\tbrData = nil\n\t\t\t}\n\t\t}\n\n\t\tgzipData, err := CompressBytesGzip(data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Don't use Gzip if we get meagre gains from it as it takes longer to process the responses\n\t\tif len(gzipData) >= (len(data) + 120) {\n\t\t\tgzipData = nil\n\t\t} else {\n\t\t\tdiff := len(data) - len(gzipData)\n\t\t\tif diff <= len(data)/100 {\n\t\t\t\tgzipData = nil\n\t\t\t}\n\t\t}\n\n\t\t// Get a checksum for CSPs and cache busting\n\t\thasher := sha256.New()\n\t\thasher.Write(data)\n\t\tsum := hasher.Sum(nil)\n\t\tchecksum := hex.EncodeToString(sum)\n\t\tintegrity := base64.StdEncoding.EncodeToString(sum)\n\n\t\tl.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + \"?h=\" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})\n\n\t\tDebugLogf(\"Added the '%s' static file.\", path)\n\t\treturn nil\n\t})\n}\n\nfunc (l SFileList) Init() error {\n\treturn filepath.Walk(\"./public\", func(path string, f os.FileInfo, err error) error {\n\t\tif f.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\tdata, err := ioutil.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpath = strings.TrimPrefix(path, \"public/\")\n\t\text := filepath.Ext(\"/public/\" + path)\n\t\tif ext == \".js\" {\n\t\t\tdata = bytes.Replace(data, []byte(\"\\r\"), []byte(\"\"), -1)\n\t\t}\n\t\tmimetype := mime.TypeByExtension(ext)\n\n\t\t// Get a checksum for CSPs and cache busting\n\t\thasher := sha256.New()\n\t\thasher.Write(data)\n\t\tsum := hasher.Sum(nil)\n\t\tchecksum := hex.EncodeToString(sum)\n\t\tintegrity := base64.StdEncoding.EncodeToString(sum)\n\n\t\t// Avoid double-compressing images\n\t\tvar gzipData, brData []byte\n\t\tif mimetype != \"image/jpeg\" && mimetype != \"image/png\" && mimetype != \"image/gif\" {\n\t\t\tbrData, err = CompressBytesBrotli(data)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Don't use Brotli if we get meagre gains from it as it takes longer to process the responses\n\t\t\tif len(brData) >= (len(data) + 130) {\n\t\t\t\tbrData = nil\n\t\t\t} else {\n\t\t\t\tdiff := len(data) - len(brData)\n\t\t\t\tif diff <= len(data)/100 {\n\t\t\t\t\tbrData = nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tgzipData, err = CompressBytesGzip(data)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t// Don't use Gzip if we get meagre gains from it as it takes longer to process the responses\n\t\t\tif len(gzipData) >= (len(data) + 150) {\n\t\t\t\tgzipData = nil\n\t\t\t} else {\n\t\t\t\tdiff := len(data) - len(gzipData)\n\t\t\t\tif diff <= len(data)/100 {\n\t\t\t\t\tgzipData = nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tl.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + \"?h=\" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)})\n\n\t\tDebugLogf(\"Added the '%s' static file.\", path)\n\t\treturn nil\n\t})\n}\n\nfunc (l SFileList) Add(path, prefix string) error {\n\tdata, err := ioutil.ReadFile(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfi, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tf, err := fi.Stat()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\text := filepath.Ext(path)\n\tpath = strings.TrimPrefix(path, prefix)\n\n\tbrData, err := CompressBytesBrotli(data)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Don't use Brotli if we get meagre gains from it as it takes longer to process the responses\n\tif len(brData) >= (len(data) + 130) {\n\t\tbrData = nil\n\t} else {\n\t\tdiff := len(data) - len(brData)\n\t\tif diff <= len(data)/100 {\n\t\t\tbrData = nil\n\t\t}\n\t}\n\n\tgzipData, err := CompressBytesGzip(data)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Don't use Gzip if we get meagre gains from it as it takes longer to process the responses\n\tif len(gzipData) >= (len(data) + 150) {\n\t\tgzipData = nil\n\t} else {\n\t\tdiff := len(data) - len(gzipData)\n\t\tif diff <= len(data)/100 {\n\t\t\tgzipData = nil\n\t\t}\n\t}\n\n\t// Get a checksum for CSPs and cache busting\n\thasher := sha256.New()\n\thasher.Write(data)\n\tsum := hasher.Sum(nil)\n\t\tchecksum := hex.EncodeToString(sum)\n\t\tintegrity := base64.StdEncoding.EncodeToString(sum)\n\n\tl.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + \"?h=\" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})\n\n\tDebugLogf(\"Added the '%s' static file\", path)\n\treturn nil\n}\n\nfunc (l SFileList) Get(path string) (file *SFile, exists bool) {\n\tstaticFileMutex.RLock()\n\tdefer staticFileMutex.RUnlock()\n\tfile, exists = l.Long[path]\n\treturn file, exists\n}\n\n// fetch without /s/ to avoid allocing in pages.go\nfunc (l SFileList) GetShort(name string) (file *SFile, exists bool) {\n\tstaticFileMutex.RLock()\n\tdefer staticFileMutex.RUnlock()\n\tfile, exists = l.Short[name]\n\treturn file, exists\n}\n\nfunc (l SFileList) Set(name string, data *SFile) {\n\tstaticFileMutex.Lock()\n\tdefer staticFileMutex.Unlock()\n\t// TODO: Propagate errors back up\n\tuurl, err := url.Parse(name)\n\tif err != nil {\n\t\treturn\n\t}\n\tl.Long[uurl.Path] = data\n\tl.Short[strings.TrimPrefix(strings.TrimPrefix(name, l.Prefix), \"/\")] = data\n}\n\nvar gzipBestCompress sync.Pool\n\nfunc CompressBytesGzip(in []byte) (b []byte, err error) {\n\tvar buf bytes.Buffer\n\tii := gzipBestCompress.Get()\n\tvar gz *gzip.Writer\n\tif ii == nil {\n\t\tgz, err = gzip.NewWriterLevel(&buf, gzip.BestCompression)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\tgz = ii.(*gzip.Writer)\n\t\tgz.Reset(&buf)\n\t}\n\t_, err = gz.Write(in)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = gz.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tgzipBestCompress.Put(gz)\n\treturn buf.Bytes(), nil\n}\n\nfunc CompressBytesBrotli(in []byte) ([]byte, error) {\n\tvar buff bytes.Buffer\n\tbr := brotli.NewWriterLevel(&buff, brotli.BestCompression)\n\t_, err := br.Write(in)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = br.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn buff.Bytes(), nil\n}\n"
  },
  {
    "path": "common/forum.go",
    "content": "package common\n\nimport (\n\t//\"log\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\n// TODO: Do we really need this?\ntype ForumAdmin struct {\n\tID         int\n\tName       string\n\tDesc       string\n\tActive     bool\n\tPreset     string\n\tTopicCount int\n\tPresetLang string\n}\n\ntype Forum struct {\n\tID         int\n\tLink       string\n\tName       string\n\tDesc       string\n\tTmpl       string\n\tActive     bool\n\tOrder      int\n\tPreset     string\n\tParentID   int\n\tParentType string\n\tTopicCount int\n\n\tLastTopic     *Topic\n\tLastTopicID   int\n\tLastReplyer   *User\n\tLastReplyerID int\n\tLastTopicTime string // So that we can re-calculate the relative time on the spot in /forums/\n\tLastPage int\n}\n\n// ? - What is this for?\ntype ForumSimple struct {\n\tID     int\n\tName   string\n\tActive bool\n\tPreset string\n}\n\ntype ForumStmts struct {\n\tupdate    *sql.Stmt\n\tsetPreset *sql.Stmt\n}\n\nvar forumStmts ForumStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tforumStmts = ForumStmts{\n\t\t\tupdate:    acc.Update(\"forums\").Set(\"name=?,desc=?,active=?,preset=?\").Where(\"fid=?\").Prepare(),\n\t\t\tsetPreset: acc.Update(\"forums\").Set(\"preset=?\").Where(\"fid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// Copy gives you a non-pointer concurrency safe copy of the forum\nfunc (f *Forum) Copy() (fcopy Forum) {\n\tfcopy = *f\n\treturn fcopy\n}\n\n// TODO: Write tests for this\nfunc (f *Forum) Update(name, desc string, active bool, preset string) error {\n\tif name == \"\" {\n\t\tname = f.Name\n\t}\n\t// TODO: Do a line sanitise? Does it matter?\n\tpreset = strings.TrimSpace(preset)\n\t_, err := forumStmts.update.Exec(name, desc, active, preset, f.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif f.Preset != preset && preset != \"custom\" && preset != \"\" {\n\t\terr = PermmapToQuery(PresetToPermmap(preset), f.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t_ = Forums.Reload(f.ID)\n\treturn nil\n}\n\nfunc (f *Forum) SetPreset(preset string, gid int) error {\n\tfp, changed := GroupForumPresetToForumPerms(preset)\n\tif changed {\n\t\treturn f.SetPerms(fp, preset, gid)\n\t}\n\treturn nil\n}\n\n// TODO: Refactor this\nfunc (f *Forum) SetPerms(fperms *ForumPerms, preset string, gid int) (err error) {\n\terr = ReplaceForumPermsForGroup(gid, map[int]string{f.ID: preset}, map[int]*ForumPerms{f.ID: fperms})\n\tif err != nil {\n\t\tLogError(err)\n\t\treturn errors.New(\"Unable to update the permissions\")\n\t}\n\n\t// TODO: Add this and replaceForumPermsForGroup into a transaction?\n\t_, err = forumStmts.setPreset.Exec(\"\", f.ID)\n\tif err != nil {\n\t\tLogError(err)\n\t\treturn errors.New(\"Unable to update the forum\")\n\t}\n\terr = Forums.Reload(f.ID)\n\tif err != nil {\n\t\treturn errors.New(\"Unable to reload forum\")\n\t}\n\terr = FPStore.Reload(f.ID)\n\tif err != nil {\n\t\treturn errors.New(\"Unable to reload the forum permissions\")\n\t}\n\treturn nil\n}\n\n// TODO: Replace this sorting mechanism with something a lot more efficient\n// ? - Use sort.Slice instead?\ntype SortForum []*Forum\n\nfunc (sf SortForum) Len() int {\n\treturn len(sf)\n}\nfunc (sf SortForum) Swap(i, j int) {\n\tsf[i], sf[j] = sf[j], sf[i]\n}\n\n/*func (sf SortForum) Less(i,j int) bool {\n\tl := sf.less(i,j)\n\tif l {\n\t\tlog.Printf(\"%s is less than %s. order: %d. id: %d.\",sf[i].Name, sf[j].Name, sf[i].Order, sf[i].ID)\n\t} else {\n\t\tlog.Printf(\"%s is not less than %s. order: %d. id: %d.\",sf[i].Name, sf[j].Name, sf[i].Order, sf[i].ID)\n\t}\n\treturn l\n}*/\nfunc (sf SortForum) Less(i, j int) bool {\n\tif sf[i].Order < sf[j].Order {\n\t\treturn true\n\t} else if sf[i].Order == sf[j].Order {\n\t\treturn sf[i].ID < sf[j].ID\n\t}\n\treturn false\n}\n\n// ! Don't use this outside of tests and possibly template_init.go\nfunc BlankForum(fid int, link, name, desc string, active bool, preset string, parentID int, parentType string, topicCount int) *Forum {\n\treturn &Forum{ID: fid, Link: link, Name: name, Desc: desc, Active: active, Preset: preset, ParentID: parentID, ParentType: parentType, TopicCount: topicCount}\n}\n\nfunc BuildForumURL(slug string, fid int) string {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\treturn \"/forum/\" + strconv.Itoa(fid)\n\t}\n\treturn \"/forum/\" + slug + \".\" + strconv.Itoa(fid)\n}\n\nfunc GetForumURLPrefix() string {\n\treturn \"/forum/\"\n}\n"
  },
  {
    "path": "common/forum_actions.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar ForumActionStore ForumActionStoreInt\n\n//var ForumActionRunnableStore ForumActionRunnableStoreInt\n\nconst (\n\tForumActionDelete = iota\n\tForumActionLock\n\tForumActionUnlock\n\tForumActionMove\n)\n\nfunc ConvStringToAct(s string) int {\n\tswitch s {\n\tcase \"delete\":\n\t\treturn ForumActionDelete\n\tcase \"lock\":\n\t\treturn ForumActionLock\n\tcase \"unlock\":\n\t\treturn ForumActionUnlock\n\tcase \"move\":\n\t\treturn ForumActionMove\n\t}\n\treturn -1\n}\nfunc ConvActToString(a int) string {\n\tswitch a {\n\tcase ForumActionDelete:\n\t\treturn \"delete\"\n\tcase ForumActionLock:\n\t\treturn \"lock\"\n\tcase ForumActionUnlock:\n\t\treturn \"unlock\"\n\tcase ForumActionMove:\n\t\treturn \"move\"\n\t}\n\treturn \"\"\n}\n\nvar forumActionStmts ForumActionStmts\n\ntype ForumActionStmts struct {\n\tget1    *sql.Stmt\n\tget2    *sql.Stmt\n\tlock1   *sql.Stmt\n\tlock2   *sql.Stmt\n\tunlock1 *sql.Stmt\n\tunlock2 *sql.Stmt\n}\n\ntype ForumAction struct {\n\tID                         int\n\tForum                      int\n\tRunOnTopicCreation         bool\n\tRunDaysAfterTopicCreation  int\n\tRunDaysAfterTopicLastReply int\n\tAction                     int\n\tExtra                      string\n}\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tt := \"topics\"\n\t\tforumActionStmts = ForumActionStmts{\n\t\t\tget1: acc.Select(t).Cols(\"tid,createdBy,poll\").Where(\"parentID=?\").DateOlderThanQ(\"createdAt\", \"day\").Stmt(),\n\t\t\tget2: acc.Select(t).Cols(\"tid,createdBy,poll\").Where(\"parentID=?\").DateOlderThanQ(\"lastReplyAt\", \"day\").Stmt(),\n\n\t\t\t/*lock1:   acc.Update(t).Set(\"is_closed=1\").Where(\"parentID=?\").DateOlderThanQ(\"createdAt\", \"day\").Stmt(),\n\t\t\tlock2:   acc.Update(t).Set(\"is_closed=1\").Where(\"parentID=?\").DateOlderThanQ(\"lastReplyAt\", \"day\").Stmt(),\n\t\t\tunlock1: acc.Update(t).Set(\"is_closed=0\").Where(\"parentID=?\").DateOlderThanQ(\"createdAt\", \"day\").Stmt(),\n\t\t\tunlock2: acc.Update(t).Set(\"is_closed=0\").Where(\"parentID=?\").DateOlderThanQ(\"lastReplyAt\", \"day\").Stmt(),*/\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc (a *ForumAction) Run() error {\n\tif a.RunDaysAfterTopicCreation > 0 {\n\t\tif e := a.runDaysAfterTopicCreation(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\tif a.RunDaysAfterTopicLastReply > 0 {\n\t\tif e := a.runDaysAfterTopicLastReply(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (a *ForumAction) runQ(stmt *sql.Stmt, days int, f func(t *Topic) error) error {\n\trows, e := stmt.Query(days, a.Forum)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\t// TODO: Decouple this\n\t\tt := &Topic{ParentID: a.Forum}\n\t\tif e := rows.Scan(&t.ID, &t.CreatedBy, &t.Poll); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e = f(t); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nfunc (a *ForumAction) runDaysAfterTopicCreation() (e error) {\n\tswitch a.Action {\n\tcase ForumActionDelete:\n\t\t// TODO: Bulk delete?\n\t\te = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error {\n\t\t\treturn t.Delete()\n\t\t})\n\tcase ForumActionLock:\n\t\t/*_, e := forumActionStmts.lock1.Exec(a.Forum)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}*/\n\t\t// TODO: Bulk lock? Lock and get resultset of changed topics somehow?\n\t\tfmt.Println(\"ForumActionLock\")\n\t\te = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error {\n\t\t\tfmt.Printf(\"t: %+v\\n\", t)\n\t\t\treturn t.Lock()\n\t\t})\n\tcase ForumActionUnlock:\n\t\t// TODO: Bulk unlock? Unlock and get resultset of changed topics somehow?\n\t\te = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error {\n\t\t\treturn t.Unlock()\n\t\t})\n\tcase ForumActionMove:\n\t\tdestForum, e := strconv.Atoi(a.Extra)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\te = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error {\n\t\t\treturn t.MoveTo(destForum)\n\t\t})\n\t}\n\treturn e\n}\n\nfunc (a *ForumAction) runDaysAfterTopicLastReply() (e error) {\n\tswitch a.Action {\n\tcase ForumActionDelete:\n\t\te = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error {\n\t\t\treturn t.Delete()\n\t\t})\n\tcase ForumActionLock:\n\t\t// TODO: Bulk lock? Lock and get resultset of changed topics somehow?\n\t\te = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error {\n\t\t\treturn t.Lock()\n\t\t})\n\tcase ForumActionUnlock:\n\t\t// TODO: Bulk unlock? Unlock and get resultset of changed topics somehow?\n\t\te = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error {\n\t\t\treturn t.Unlock()\n\t\t})\n\tcase ForumActionMove:\n\t\tdestForum, e := strconv.Atoi(a.Extra)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\te = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error {\n\t\t\treturn t.MoveTo(destForum)\n\t\t})\n\t}\n\treturn nil\n}\n\nfunc (a *ForumAction) TopicCreation(tid int) error {\n\tif !a.RunOnTopicCreation {\n\t\treturn nil\n\t}\n\treturn nil\n}\n\ntype ForumActionStoreInt interface {\n\tGet(faid int) (*ForumAction, error)\n\tGetInForum(fid int) ([]*ForumAction, error)\n\tGetAll() ([]*ForumAction, error)\n\tGetNewTopicActions(fid int) ([]*ForumAction, error)\n\n\tAdd(fa *ForumAction) (int, error)\n\tDelete(faid int) error\n\tExists(faid int) bool\n\tCount() int\n\tCountInForum(fid int) int\n\n\tDailyTick() error\n}\n\ntype DefaultForumActionStore struct {\n\tget                *sql.Stmt\n\tgetInForum         *sql.Stmt\n\tgetAll             *sql.Stmt\n\tgetNewTopicActions *sql.Stmt\n\n\tadd          *sql.Stmt\n\tdelete       *sql.Stmt\n\texists       *sql.Stmt\n\tcount        *sql.Stmt\n\tcountInForum *sql.Stmt\n}\n\nfunc NewDefaultForumActionStore(acc *qgen.Accumulator) (*DefaultForumActionStore, error) {\n\tfa := \"forums_actions\"\n\tallCols := \"faid,fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra\"\n\treturn &DefaultForumActionStore{\n\t\tget:                acc.Select(fa).Columns(\"fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra\").Where(\"faid=?\").Prepare(),\n\t\tgetInForum:         acc.Select(fa).Columns(\"faid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra\").Where(\"fid=?\").Prepare(),\n\t\tgetAll:             acc.Select(fa).Columns(allCols).Prepare(),\n\t\tgetNewTopicActions: acc.Select(fa).Columns(allCols).Where(\"fid=? AND runOnTopicCreation=1\").Prepare(),\n\n\t\tadd:          acc.Insert(fa).Columns(\"fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra\").Fields(\"?,?,?,?,?,?\").Prepare(),\n\t\tdelete:       acc.Delete(fa).Where(\"faid=?\").Prepare(),\n\t\texists:       acc.Exists(fa, \"faid\").Prepare(),\n\t\tcount:        acc.Count(fa).Prepare(),\n\t\tcountInForum: acc.Count(fa).Where(\"fid=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultForumActionStore) DailyTick() error {\n\tfas, e := s.GetAll()\n\tif e != nil {\n\t\treturn e\n\t}\n\tfor _, fa := range fas {\n\t\tif e := fa.Run(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *DefaultForumActionStore) Get(id int) (*ForumAction, error) {\n\tfa := ForumAction{ID: id}\n\tvar str string\n\te := s.get.QueryRow(id).Scan(&fa.Forum, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra)\n\tfa.Action = ConvStringToAct(str)\n\treturn &fa, e\n}\n\nfunc (s *DefaultForumActionStore) GetInForum(fid int) (fas []*ForumAction, e error) {\n\trows, e := s.getInForum.Query(fid)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tvar str string\n\tfor rows.Next() {\n\t\tfa := ForumAction{Forum: fid}\n\t\tif e := rows.Scan(&fa.ID, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tfa.Action = ConvStringToAct(str)\n\t\tfas = append(fas, &fa)\n\t}\n\treturn fas, rows.Err()\n}\n\nfunc (s *DefaultForumActionStore) GetAll() (fas []*ForumAction, e error) {\n\trows, e := s.getAll.Query()\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tvar str string\n\tfor rows.Next() {\n\t\tfa := ForumAction{}\n\t\tif e := rows.Scan(&fa.ID, &fa.Forum, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tfa.Action = ConvStringToAct(str)\n\t\tfas = append(fas, &fa)\n\t}\n\treturn fas, rows.Err()\n}\n\nfunc (s *DefaultForumActionStore) GetNewTopicActions(fid int) (fas []*ForumAction, e error) {\n\trows, e := s.getNewTopicActions.Query(fid)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tvar str string\n\tfor rows.Next() {\n\t\tfa := ForumAction{RunOnTopicCreation: true}\n\t\tif e := rows.Scan(&fa.ID, &fa.Forum, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tfa.Action = ConvStringToAct(str)\n\t\tfas = append(fas, &fa)\n\t}\n\treturn fas, rows.Err()\n}\n\nfunc (s *DefaultForumActionStore) Add(fa *ForumAction) (int, error) {\n\tres, e := s.add.Exec(fa.Forum, fa.RunOnTopicCreation, fa.RunDaysAfterTopicCreation, fa.RunDaysAfterTopicLastReply, ConvActToString(fa.Action), fa.Extra)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\treturn int(lastID), e\n}\n\nfunc (s *DefaultForumActionStore) Delete(id int) error {\n\t_, e := s.delete.Exec(id)\n\treturn e\n}\n\nfunc (s *DefaultForumActionStore) Exists(id int) bool {\n\terr := s.exists.QueryRow(id).Scan(&id)\n\tif err != nil && err != ErrNoRows {\n\t\tLogError(err)\n\t}\n\treturn err != ErrNoRows\n}\n\nfunc (s *DefaultForumActionStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultForumActionStore) CountInForum(fid int) (count int) {\n\treturn Countf(s.countInForum, fid)\n}\n\n/*type ForumActionRunnable struct {\n\tID         int\n\tActionID   int\n\tTargetID   int\n\tTargetType int // 0 = topic\n\tRunAfter   int //unixtime\n}\n\ntype ForumActionRunnableStoreInt interface {\n\tGetAfterTime(unix int) ([]*ForumActionRunnable, error)\n\tGetInForum(fid int) ([]*ForumActionRunnable, error)\n\tDelete(faid int) error\n\tDeleteInForum(fid int) error\n\tDeleteByActionID(faid int) error\n\tCount() int\n\tCountInForum(fid int) int\n}\n\ntype DefaultForumActionRunnableStore struct {\n\tdelete        *sql.Stmt\n\tdeleteInForum *sql.Stmt\n\tcount         *sql.Stmt\n\tcountInForum  *sql.Stmt\n}\n\nfunc NewDefaultForumActionRunnableStore(acc *qgen.Accumulator) (*DefaultForumActionRunnableStore, error) {\n\tfa := \"forums_actions\"\n\treturn &DefaultForumActionRunnableStore{\n\t\tdelete:        acc.Delete(fa).Where(\"faid=?\").Prepare(),\n\t\tdeleteInForum: acc.Delete(fa).Where(\"fid=?\").Prepare(),\n\t\tcount:         acc.Count(fa).Prepare(),\n\t\tcountInForum:  acc.Count(fa).Where(\"faid=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultForumActionRunnableStore) Delete(id int) error {\n\t_, e := s.delete.Exec(id)\n\treturn e\n}\n\nfunc (s *DefaultForumActionRunnableStore) DeleteInForum(fid int) error {\n\t_, e := s.deleteInForum.Exec(id)\n\treturn e\n}\n\nfunc (s *DefaultForumActionRunnableStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultForumActionRunnableStore) CountInForum(fid int) (count int) {\n\treturn Countf(s.countInForum, fid)\n}\n*/\n"
  },
  {
    "path": "common/forum_perms.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\n\t\"github.com/Azareal/Gosora/query_gen\"\n)\n\n// ? - Can we avoid duplicating the items in this list in a bunch of places?\n\nvar LocalPermList = []string{\n\t\"ViewTopic\",\n\t\"LikeItem\",\n\t\"CreateTopic\",\n\t\"EditTopic\",\n\t\"DeleteTopic\",\n\t\"CreateReply\",\n\t\"EditReply\",\n\t\"DeleteReply\",\n\t\"PinTopic\",\n\t\"CloseTopic\",\n\t\"MoveTopic\",\n}\n\n// TODO: Rename this to ForumPermSet?\n/* Inherit from group permissions for ones we don't have */\ntype ForumPerms struct {\n\tViewTopic bool\n\t//ViewOwnTopic bool\n\tLikeItem    bool\n\tCreateTopic bool\n\tEditTopic   bool\n\tDeleteTopic bool\n\tCreateReply bool\n\t//CreateReplyToOwn bool\n\tEditReply bool\n\t//EditOwnReply bool\n\tDeleteReply bool\n\tPinTopic    bool\n\tCloseTopic  bool\n\t//CloseOwnTopic bool\n\tMoveTopic bool\n\n\tOverrides bool\n\tExtData   map[string]bool\n}\n\nfunc PresetToPermmap(preset string) (out map[string]*ForumPerms) {\n\tout = make(map[string]*ForumPerms)\n\tswitch preset {\n\tcase \"all\":\n\t\tout[\"guests\"] = ReadForumPerms()\n\t\tout[\"members\"] = ReadWriteForumPerms()\n\t\tout[\"staff\"] = AllForumPerms()\n\t\tout[\"admins\"] = AllForumPerms()\n\tcase \"announce\":\n\t\tout[\"guests\"] = ReadForumPerms()\n\t\tout[\"members\"] = ReadReplyForumPerms()\n\t\tout[\"staff\"] = AllForumPerms()\n\t\tout[\"admins\"] = AllForumPerms()\n\tcase \"members\":\n\t\tout[\"guests\"] = BlankForumPerms()\n\t\tout[\"members\"] = ReadWriteForumPerms()\n\t\tout[\"staff\"] = AllForumPerms()\n\t\tout[\"admins\"] = AllForumPerms()\n\tcase \"staff\":\n\t\tout[\"guests\"] = BlankForumPerms()\n\t\tout[\"members\"] = BlankForumPerms()\n\t\tout[\"staff\"] = ReadWriteForumPerms()\n\t\tout[\"admins\"] = AllForumPerms()\n\tcase \"admins\":\n\t\tout[\"guests\"] = BlankForumPerms()\n\t\tout[\"members\"] = BlankForumPerms()\n\t\tout[\"staff\"] = BlankForumPerms()\n\t\tout[\"admins\"] = AllForumPerms()\n\tcase \"archive\":\n\t\tout[\"guests\"] = ReadForumPerms()\n\t\tout[\"members\"] = ReadForumPerms()\n\t\tout[\"staff\"] = ReadForumPerms()\n\t\tout[\"admins\"] = ReadForumPerms() //CurateForumPerms. Delete / Edit but no create?\n\tdefault:\n\t\tout[\"guests\"] = BlankForumPerms()\n\t\tout[\"members\"] = BlankForumPerms()\n\t\tout[\"staff\"] = BlankForumPerms()\n\t\tout[\"admins\"] = BlankForumPerms()\n\t}\n\treturn out\n}\n\nfunc PermmapToQuery(permmap map[string]*ForumPerms, fid int) error {\n\ttx, err := qgen.Builder.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tdeleteForumPermsByForumTx, err := qgen.Builder.SimpleDeleteTx(tx, \"forums_permissions\", \"fid = ?\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = deleteForumPermsByForumTx.Exec(fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tperms, err := json.Marshal(permmap[\"admins\"])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taddForumPermsToForumAdminsTx, err := qgen.Builder.SimpleInsertSelectTx(tx,\n\t\tqgen.DBInsert{\"forums_permissions\", \"gid,fid,preset,permissions\", \"\"},\n\t\tqgen.DBSelect{\"users_groups\", \"gid,?,'',?\", \"is_admin = 1\", \"\", \"\"},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = addForumPermsToForumAdminsTx.Exec(fid, perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tperms, err = json.Marshal(permmap[\"staff\"])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taddForumPermsToForumStaffTx, err := qgen.Builder.SimpleInsertSelectTx(tx,\n\t\tqgen.DBInsert{\"forums_permissions\", \"gid,fid,preset,permissions\", \"\"},\n\t\tqgen.DBSelect{\"users_groups\", \"gid,?,'',?\", \"is_admin = 0 AND is_mod = 1\", \"\", \"\"},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = addForumPermsToForumStaffTx.Exec(fid, perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tperms, err = json.Marshal(permmap[\"members\"])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taddForumPermsToForumMembersTx, err := qgen.Builder.SimpleInsertSelectTx(tx,\n\t\tqgen.DBInsert{\"forums_permissions\", \"gid,fid,preset,permissions\", \"\"},\n\t\tqgen.DBSelect{\"users_groups\", \"gid,?,'',?\", \"is_admin = 0 AND is_mod = 0 AND is_banned = 0\", \"\", \"\"},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = addForumPermsToForumMembersTx.Exec(fid, perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// TODO: The group ID is probably a variable somewhere. Find it and use it.\n\t// Group 5 is the Awaiting Activation group\n\terr = ReplaceForumPermsForGroupTx(tx, 5, map[int]string{fid: \"\"}, map[int]*ForumPerms{fid: permmap[\"guests\"]})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// TODO: Consult a config setting instead of GuestUser?\n\terr = ReplaceForumPermsForGroupTx(tx, GuestUser.Group, map[int]string{fid: \"\"}, map[int]*ForumPerms{fid: permmap[\"guests\"]})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = tx.Commit()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn FPStore.Reload(fid)\n\t//return TopicList.RebuildPermTree()\n}\n\n// TODO: FPStore.Reload?\nfunc ReplaceForumPermsForGroup(gid int, presetSet map[int]string, permSets map[int]*ForumPerms) error {\n\ttx, err := qgen.Builder.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\terr = ReplaceForumPermsForGroupTx(tx, gid, presetSet, permSets)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tx.Commit()\n\t//return TopicList.RebuildPermTree()\n}\n\nfunc ReplaceForumPermsForGroupTx(tx *sql.Tx, gid int, presetSets map[int]string, permSets map[int]*ForumPerms) error {\n\tdeleteForumPermsForGroupTx, err := qgen.Builder.SimpleDeleteTx(tx, \"forums_permissions\", \"gid = ? AND fid = ?\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taddForumPermsToGroupTx, err := qgen.Builder.SimpleInsertTx(tx, \"forums_permissions\", \"gid,fid,preset,permissions\", \"?,?,?,?\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor fid, permSet := range permSets {\n\t\tpermstr, err := json.Marshal(permSet)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = deleteForumPermsForGroupTx.Exec(gid, fid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = addForumPermsToGroupTx.Exec(gid, fid, presetSets[fid], string(permstr))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// TODO: Refactor this and write tests for it\n// TODO: We really need to improve the thread safety of this\nfunc ForumPermsToGroupForumPreset(fp *ForumPerms) string {\n\tif !fp.Overrides {\n\t\treturn \"default\"\n\t}\n\tif !fp.ViewTopic {\n\t\treturn \"no_access\"\n\t}\n\tcanPost := (fp.LikeItem && fp.CreateTopic && fp.CreateReply)\n\tcanModerate := (canPost && fp.EditTopic && fp.DeleteTopic && fp.EditReply && fp.DeleteReply && fp.PinTopic && fp.CloseTopic && fp.MoveTopic)\n\tif canModerate {\n\t\treturn \"can_moderate\"\n\t}\n\tif fp.EditTopic || fp.DeleteTopic || fp.EditReply || fp.DeleteReply || fp.PinTopic || fp.CloseTopic || fp.MoveTopic {\n\t\t//if !canPost {\n\t\treturn \"custom\"\n\t\t//}\n\t\t//return \"quasi_mod\"\n\t}\n\n\tif canPost {\n\t\treturn \"can_post\"\n\t}\n\tif fp.ViewTopic && !fp.LikeItem && !fp.CreateTopic && !fp.CreateReply {\n\t\treturn \"read_only\"\n\t}\n\treturn \"custom\"\n}\n\nfunc GroupForumPresetToForumPerms(preset string) (fperms *ForumPerms, changed bool) {\n\tswitch preset {\n\tcase \"read_only\":\n\t\treturn ReadForumPerms(), true\n\tcase \"can_post\":\n\t\treturn ReadWriteForumPerms(), true\n\tcase \"can_moderate\":\n\t\treturn AllForumPerms(), true\n\tcase \"no_access\":\n\t\treturn &ForumPerms{Overrides: true, ExtData: make(map[string]bool)}, true\n\tcase \"default\":\n\t\treturn BlankForumPerms(), true\n\t}\n\treturn fperms, false\n}\n\nfunc BlankForumPerms() *ForumPerms {\n\treturn &ForumPerms{ViewTopic: false}\n}\n\nfunc ReadWriteForumPerms() *ForumPerms {\n\treturn &ForumPerms{\n\t\tViewTopic:   true,\n\t\tLikeItem:    true,\n\t\tCreateTopic: true,\n\t\tCreateReply: true,\n\t\tOverrides:   true,\n\t\tExtData:     make(map[string]bool),\n\t}\n}\n\nfunc ReadReplyForumPerms() *ForumPerms {\n\treturn &ForumPerms{\n\t\tViewTopic:   true,\n\t\tLikeItem:    true,\n\t\tCreateReply: true,\n\t\tOverrides:   true,\n\t\tExtData:     make(map[string]bool),\n\t}\n}\n\nfunc ReadForumPerms() *ForumPerms {\n\treturn &ForumPerms{\n\t\tViewTopic: true,\n\t\tOverrides: true,\n\t\tExtData:   make(map[string]bool),\n\t}\n}\n\n// AllForumPerms is a set of forum local permissions with everything set to true\nfunc AllForumPerms() *ForumPerms {\n\treturn &ForumPerms{\n\t\tViewTopic:   true,\n\t\tLikeItem:    true,\n\t\tCreateTopic: true,\n\t\tEditTopic:   true,\n\t\tDeleteTopic: true,\n\t\tCreateReply: true,\n\t\tEditReply:   true,\n\t\tDeleteReply: true,\n\t\tPinTopic:    true,\n\t\tCloseTopic:  true,\n\t\tMoveTopic:   true,\n\n\t\tOverrides: true,\n\t\tExtData:   make(map[string]bool),\n\t}\n}\n"
  },
  {
    "path": "common/forum_perms_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"sync\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar FPStore ForumPermsStore\n\ntype ForumPermsStore interface {\n\tInit() error\n\tGetAllMap() (bigMap map[int]map[int]*ForumPerms)\n\tGet(fid, gid int) (fp *ForumPerms, err error)\n\tGetCopy(fid, gid int) (fp ForumPerms, err error)\n\tReloadAll() error\n\tReload(id int) error\n}\n\ntype ForumPermsCache interface {\n}\n\ntype MemoryForumPermsStore struct {\n\tgetByForum      *sql.Stmt\n\tgetByForumGroup *sql.Stmt\n\n\tevenForums map[int]map[int]*ForumPerms\n\toddForums  map[int]map[int]*ForumPerms // [fid][gid]*ForumPerms\n\tevenLock   sync.RWMutex\n\toddLock    sync.RWMutex\n}\n\nfunc NewMemoryForumPermsStore() (*MemoryForumPermsStore, error) {\n\tacc := qgen.NewAcc()\n\tfp := \"forums_permissions\"\n\treturn &MemoryForumPermsStore{\n\t\tgetByForum:      acc.Select(fp).Columns(\"gid,permissions\").Where(\"fid=?\").Orderby(\"gid ASC\").Prepare(),\n\t\tgetByForumGroup: acc.Select(fp).Columns(\"permissions\").Where(\"fid=? AND gid=?\").Prepare(),\n\n\t\tevenForums: make(map[int]map[int]*ForumPerms),\n\t\toddForums:  make(map[int]map[int]*ForumPerms),\n\t}, acc.FirstError()\n}\n\nfunc (s *MemoryForumPermsStore) Init() error {\n\tDebugLog(\"Initialising the forum perms store\")\n\treturn s.ReloadAll()\n}\n\n// TODO: Optimise this?\nfunc (s *MemoryForumPermsStore) ReloadAll() error {\n\tDebugLog(\"Reloading the forum perms\")\n\tfids, e := Forums.GetAllIDs()\n\tif e != nil {\n\t\treturn e\n\t}\n\tfor _, fid := range fids {\n\t\tif e := s.reload(fid); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\tif e := s.recalcCanSeeAll(); e != nil {\n\t\treturn e\n\t}\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\nfunc (s *MemoryForumPermsStore) parseForumPerm(perms []byte) (pperms *ForumPerms, e error) {\n\tDebugDetail(\"perms: \", string(perms))\n\tpperms = BlankForumPerms()\n\te = json.Unmarshal(perms, &pperms)\n\tpperms.ExtData = make(map[string]bool)\n\tpperms.Overrides = true\n\treturn pperms, e\n}\n\nfunc (s *MemoryForumPermsStore) Reload(fid int) error {\n\te := s.reload(fid)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif e = s.recalcCanSeeAll(); e != nil {\n\t\treturn e\n\t}\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\n// TODO: Need a more thread-safe way of doing this. Possibly with sync.Map?\nfunc (s *MemoryForumPermsStore) reload(fid int) error {\n\tDebugLogf(\"Reloading the forum permissions for forum #%d\", fid)\n\trows, err := s.getByForum.Query(fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tforumPerms := make(map[int]*ForumPerms)\n\tfor rows.Next() {\n\t\tvar gid int\n\t\tvar perms []byte\n\t\terr := rows.Scan(&gid, &perms)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tDebugLog(\"gid:\", gid)\n\t\tDebugLogf(\"perms: %+v\\n\", perms)\n\t\tpperms, err := s.parseForumPerm(perms)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tDebugLogf(\"pperms: %+v\\n\", pperms)\n\t\tforumPerms[gid] = pperms\n\t}\n\tDebugLogf(\"forumPerms: %+v\\n\", forumPerms)\n\n\tif fid%2 == 0 {\n\t\ts.evenLock.Lock()\n\t\ts.evenForums[fid] = forumPerms\n\t\ts.evenLock.Unlock()\n\t} else {\n\t\ts.oddLock.Lock()\n\t\ts.oddForums[fid] = forumPerms\n\t\ts.oddLock.Unlock()\n\t}\n\treturn nil\n}\n\nfunc (s *MemoryForumPermsStore) recalcCanSeeAll() error {\n\tgroups, err := Groups.GetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfids, err := Forums.GetAllIDs()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgc, ok := Groups.(GroupCache)\n\tif !ok {\n\t\tTopicListThaw.Thaw()\n\t\treturn nil\n\t}\n\n\t// A separate loop to avoid contending on the odd-even locks as much\n\tfForumPerms := make(map[int]map[int]*ForumPerms)\n\tfor _, fid := range fids {\n\t\tvar forumPerms map[int]*ForumPerms\n\t\tvar ok bool\n\t\tif fid%2 == 0 {\n\t\t\ts.evenLock.RLock()\n\t\t\tforumPerms, ok = s.evenForums[fid]\n\t\t\ts.evenLock.RUnlock()\n\t\t} else {\n\t\t\ts.oddLock.RLock()\n\t\t\tforumPerms, ok = s.oddForums[fid]\n\t\t\ts.oddLock.RUnlock()\n\t\t}\n\t\tif ok {\n\t\t\tfForumPerms[fid] = forumPerms\n\t\t}\n\t}\n\n\t// TODO: Can we recalculate CanSee without calculating every other forum?\n\tfor _, g := range groups {\n\t\tDebugLogf(\"Updating the forum permissions for Group #%d\", g.ID)\n\t\tcanSee := []int{}\n\t\tfor _, fid := range fids {\n\t\t\tDebugDetailf(\"Forum #%+v\\n\", fid)\n\t\t\tforumPerms, ok := fForumPerms[fid]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfp, ok := forumPerms[g.ID]\n\t\t\tif !ok {\n\t\t\t\tif g.Perms.ViewTopic {\n\t\t\t\t\tcanSee = append(canSee, fid)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif fp.Overrides {\n\t\t\t\tif fp.ViewTopic {\n\t\t\t\t\tcanSee = append(canSee, fid)\n\t\t\t\t}\n\t\t\t} else if g.Perms.ViewTopic {\n\t\t\t\tcanSee = append(canSee, fid)\n\t\t\t}\n\t\t\t//DebugDetail(\"g.ID: \", g.ID)\n\t\t\tDebugDetailf(\"forumPerm: %+v\\n\", fp)\n\t\t\tDebugDetail(\"canSee: \", canSee)\n\t\t}\n\t\tDebugDetailf(\"canSee (length %d): %+v \\n\", len(canSee), canSee)\n\t\tgc.SetCanSee(g.ID, canSee)\n\t}\n\n\treturn nil\n}\n\n// ! Throughput on this might be bad due to the excessive locking\nfunc (s *MemoryForumPermsStore) GetAllMap() (bigMap map[int]map[int]*ForumPerms) {\n\tbigMap = make(map[int]map[int]*ForumPerms)\n\ts.evenLock.RLock()\n\tfor fid, subMap := range s.evenForums {\n\t\tbigMap[fid] = subMap\n\t}\n\ts.evenLock.RUnlock()\n\ts.oddLock.RLock()\n\tfor fid, subMap := range s.oddForums {\n\t\tbigMap[fid] = subMap\n\t}\n\ts.oddLock.RUnlock()\n\treturn bigMap\n}\n\n// TODO: Add a hook here and have plugin_guilds use it\n// TODO: Check if the forum exists?\n// TODO: Fix the races\n// TODO: Return BlankForumPerms() when the forum permission set doesn't exist?\nfunc (s *MemoryForumPermsStore) Get(fid, gid int) (fp *ForumPerms, err error) {\n\tvar fmap map[int]*ForumPerms\n\tvar ok bool\n\tif fid%2 == 0 {\n\t\ts.evenLock.RLock()\n\t\tfmap, ok = s.evenForums[fid]\n\t\ts.evenLock.RUnlock()\n\t} else {\n\t\ts.oddLock.RLock()\n\t\tfmap, ok = s.oddForums[fid]\n\t\ts.oddLock.RUnlock()\n\t}\n\tif !ok {\n\t\treturn fp, ErrNoRows\n\t}\n\n\tfp, ok = fmap[gid]\n\tif !ok {\n\t\treturn fp, ErrNoRows\n\t}\n\treturn fp, nil\n}\n\n// TODO: Check if the forum exists?\n// TODO: Fix the races\nfunc (s *MemoryForumPermsStore) GetCopy(fid, gid int) (fp ForumPerms, e error) {\n\tfPermsPtr, e := s.Get(fid, gid)\n\tif e != nil {\n\t\treturn fp, e\n\t}\n\treturn *fPermsPtr, nil\n}\n"
  },
  {
    "path": "common/forum_store.go",
    "content": "/*\n*\n*\tGosora Forum Store\n* \tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"log\"\n\n\t//\"fmt\"\n\t\"sort\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar forumCreateMutex sync.Mutex\nvar forumPerms map[int]map[int]*ForumPerms // [gid][fid]*ForumPerms // TODO: Add an abstraction around this and make it more thread-safe\nvar Forums ForumStore\nvar ErrBlankName = errors.New(\"The name must not be blank\")\nvar ErrNoDeleteReports = errors.New(\"You cannot delete the Reports forum\")\n\n// ForumStore is an interface for accessing the forums and the metadata stored on them\ntype ForumStore interface {\n\tLoadForums() error\n\tEach(h func(*Forum) error) error\n\tDirtyGet(id int) *Forum\n\tGet(id int) (*Forum, error)\n\tBypassGet(id int) (*Forum, error)\n\tBulkGetCopy(ids []int) (forums []Forum, err error)\n\tReload(id int) error // ? - Should we move this to ForumCache? It might require us to do some unnecessary casting though\n\t//Update(Forum) error\n\tDelete(id int) error\n\tAddTopic(tid, uid, fid int) error\n\tRemoveTopic(fid int) error\n\tRemoveTopics(fid, count int) error\n\tUpdateLastTopic(tid, uid, fid int) error\n\tExists(id int) bool\n\tGetAll() ([]*Forum, error)\n\tGetAllIDs() ([]int, error)\n\tGetAllVisible() ([]*Forum, error)\n\tGetAllVisibleIDs() ([]int, error)\n\t//GetChildren(parentID int, parentType string) ([]*Forum,error)\n\t//GetFirstChild(parentID int, parentType string) (*Forum,error)\n\tCreate(name, desc string, active bool, preset string) (int, error)\n\tUpdateOrder(updateMap map[int]int) error\n\n\tCount() int\n}\n\ntype ForumCache interface {\n\tCacheGet(id int) (*Forum, error)\n\tCacheSet(f *Forum) error\n\tCacheDelete(id int)\n\tLength() int\n}\n\n// MemoryForumStore is a struct which holds an arbitrary number of forums in memory, usually all of them, although we might introduce functionality to hold a smaller subset in memory for sites with an extremely large number of forums\ntype MemoryForumStore struct {\n\tforums    sync.Map     // map[int]*Forum\n\tforumView atomic.Value // []*Forum\n\n\tget          *sql.Stmt\n\tgetAll       *sql.Stmt\n\tdelete       *sql.Stmt\n\tcreate       *sql.Stmt\n\tcount        *sql.Stmt\n\tupdateCache  *sql.Stmt\n\taddTopics    *sql.Stmt\n\tremoveTopics *sql.Stmt\n\tlastTopic    *sql.Stmt\n\tupdateOrder  *sql.Stmt\n}\n\n// NewMemoryForumStore gives you a new instance of MemoryForumStore\nfunc NewMemoryForumStore() (*MemoryForumStore, error) {\n\tacc := qgen.NewAcc()\n\tf := \"forums\"\n\tset := func(s string) *sql.Stmt {\n\t\treturn acc.Update(f).Set(s).Where(\"fid=?\").Prepare()\n\t}\n\t// TODO: Do a proper delete\n\treturn &MemoryForumStore{\n\t\tget:          acc.Select(f).Columns(\"name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID\").Where(\"fid=?\").Prepare(),\n\t\tgetAll:       acc.Select(f).Columns(\"fid, name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID\").Orderby(\"order ASC, fid ASC\").Prepare(),\n\t\tdelete:       set(\"name='',active=0\"),\n\t\tcreate:       acc.Insert(f).Columns(\"name,desc,tmpl,active,preset\").Fields(\"?,?,'',?,?\").Prepare(),\n\t\tcount:        acc.Count(f).Where(\"name != ''\").Prepare(),\n\t\tupdateCache:  set(\"lastTopicID=?,lastReplyerID=?\"),\n\t\taddTopics:    set(\"topicCount=topicCount+?\"),\n\t\tremoveTopics: set(\"topicCount=topicCount-?\"),\n\t\tlastTopic:    acc.Select(\"topics\").Columns(\"tid\").Where(\"parentID=?\").Orderby(\"lastReplyAt DESC,createdAt DESC\").Limit(\"1\").Prepare(),\n\t\tupdateOrder:  set(\"order=?\"),\n\t}, acc.FirstError()\n}\n\n// TODO: Rename to ReloadAll?\n// TODO: Add support for subforums\nfunc (s *MemoryForumStore) LoadForums() error {\n\tvar forumView []*Forum\n\taddForum := func(f *Forum) {\n\t\ts.forums.Store(f.ID, f)\n\t\tif f.Active && f.Name != \"\" && f.ParentType == \"\" {\n\t\t\tforumView = append(forumView, f)\n\t\t}\n\t}\n\n\trows, err := s.getAll.Query()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\ti := 0\n\tfor ; rows.Next(); i++ {\n\t\tf := &Forum{ID: 0, Active: true, Preset: \"all\"}\n\t\terr = rows.Scan(&f.ID, &f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif f.Name == \"\" {\n\t\t\tDebugLog(\"Adding a placeholder forum\")\n\t\t} else {\n\t\t\tlog.Printf(\"Adding the '%s' forum\", f.Name)\n\t\t}\n\n\t\tf.Link = BuildForumURL(NameToSlug(f.Name), f.ID)\n\t\tf.LastTopic = Topics.DirtyGet(f.LastTopicID)\n\t\tf.LastReplyer = Users.DirtyGet(f.LastReplyerID)\n\t\t// TODO: Create a specialised function with a bit less overhead for getting the last page for a post count\n\t\t_, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage)\n\t\tf.LastPage = lastPage\n\t\taddForum(f)\n\t}\n\ts.forumView.Store(forumView)\n\tTopicListThaw.Thaw()\n\treturn rows.Err()\n}\n\n// TODO: Hide social groups too\n// ? - Will this be hit a lot by plugin_guilds?\nfunc (s *MemoryForumStore) rebuildView() {\n\tvar forumView []*Forum\n\ts.forums.Range(func(_, val interface{}) bool {\n\t\tf := val.(*Forum)\n\t\t// ? - ParentType blank means that it doesn't have a parent\n\t\tif f.Active && f.Name != \"\" && f.ParentType == \"\" {\n\t\t\tforumView = append(forumView, f)\n\t\t}\n\t\treturn true\n\t})\n\tsort.Sort(SortForum(forumView))\n\ts.forumView.Store(forumView)\n\tTopicListThaw.Thaw()\n}\n\nfunc (s *MemoryForumStore) Each(h func(*Forum) error) (err error) {\n\ts.forums.Range(func(_, val interface{}) bool {\n\t\terr = h(val.(*Forum))\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\t\treturn true\n\t})\n\treturn err\n}\n\nfunc (s *MemoryForumStore) DirtyGet(id int) *Forum {\n\tfint, ok := s.forums.Load(id)\n\tif !ok || fint.(*Forum).Name == \"\" {\n\t\treturn &Forum{ID: -1, Name: \"\"}\n\t}\n\treturn fint.(*Forum)\n}\n\nfunc (s *MemoryForumStore) CacheGet(id int) (*Forum, error) {\n\tfint, ok := s.forums.Load(id)\n\tif !ok || fint.(*Forum).Name == \"\" {\n\t\treturn nil, ErrNoRows\n\t}\n\treturn fint.(*Forum), nil\n}\n\nfunc (s *MemoryForumStore) Get(id int) (*Forum, error) {\n\tfint, ok := s.forums.Load(id)\n\tif ok {\n\t\tforum := fint.(*Forum)\n\t\tif forum.Name == \"\" {\n\t\t\treturn nil, ErrNoRows\n\t\t}\n\t\treturn forum, nil\n\t}\n\n\tforum, err := s.BypassGet(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ts.CacheSet(forum)\n\treturn forum, err\n}\n\nfunc (s *MemoryForumStore) BypassGet(id int) (*Forum, error) {\n\tf := &Forum{ID: id}\n\terr := s.get.QueryRow(id).Scan(&f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif f.Name == \"\" {\n\t\treturn nil, ErrNoRows\n\t}\n\tf.Link = BuildForumURL(NameToSlug(f.Name), f.ID)\n\tf.LastTopic = Topics.DirtyGet(f.LastTopicID)\n\tf.LastReplyer = Users.DirtyGet(f.LastReplyerID)\n\t// TODO: Create a specialised function with a bit less overhead for getting the last page for a post count\n\t_, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage)\n\tf.LastPage = lastPage\n\t//TopicListThaw.Thaw()\n\n\treturn f, err\n}\n\n// TODO: Optimise this\nfunc (s *MemoryForumStore) BulkGetCopy(ids []int) (forums []Forum, err error) {\n\tforums = make([]Forum, len(ids))\n\tfor i, id := range ids {\n\t\tf, err := s.Get(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tforums[i] = f.Copy()\n\t}\n\treturn forums, nil\n}\n\nfunc (s *MemoryForumStore) Reload(id int) error {\n\tforum, err := s.BypassGet(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\ts.CacheSet(forum)\n\treturn nil\n}\n\nfunc (s *MemoryForumStore) CacheSet(f *Forum) error {\n\ts.forums.Store(f.ID, f)\n\ts.rebuildView()\n\treturn nil\n}\n\n// ! Has a randomised order\nfunc (s *MemoryForumStore) GetAll() (forumView []*Forum, err error) {\n\ts.forums.Range(func(_, val interface{}) bool {\n\t\tforumView = append(forumView, val.(*Forum))\n\t\treturn true\n\t})\n\tsort.Sort(SortForum(forumView))\n\treturn forumView, nil\n}\n\n// ? - Can we optimise the sorting?\nfunc (s *MemoryForumStore) GetAllIDs() (ids []int, err error) {\n\ts.forums.Range(func(_, val interface{}) bool {\n\t\tids = append(ids, val.(*Forum).ID)\n\t\treturn true\n\t})\n\tsort.Ints(ids)\n\treturn ids, nil\n}\n\nfunc (s *MemoryForumStore) GetAllVisible() (forumView []*Forum, err error) {\n\tforumView = s.forumView.Load().([]*Forum)\n\treturn forumView, nil\n}\n\nfunc (s *MemoryForumStore) GetAllVisibleIDs() ([]int, error) {\n\tforumView := s.forumView.Load().([]*Forum)\n\tids := make([]int, len(forumView))\n\tfor i := 0; i < len(forumView); i++ {\n\t\tids[i] = forumView[i].ID\n\t}\n\treturn ids, nil\n}\n\n// TODO: Implement sub-forums.\n/*func (s *MemoryForumStore) GetChildren(parentID int, parentType string) ([]*Forum,error) {\n\treturn nil, nil\n}\nfunc (s *MemoryForumStore) GetFirstChild(parentID int, parentType string) (*Forum,error) {\n\treturn nil, nil\n}*/\n\n// TODO: Add a query for this rather than hitting cache\nfunc (s *MemoryForumStore) Exists(id int) bool {\n\tforum, ok := s.forums.Load(id)\n\tif !ok {\n\t\treturn false\n\t}\n\treturn forum.(*Forum).Name != \"\"\n}\n\n// TODO: Batch deletions with name blanking? Is this necessary?\nfunc (s *MemoryForumStore) CacheDelete(id int) {\n\ts.forums.Delete(id)\n\ts.rebuildView()\n}\n\n// TODO: Add a hook to allow plugin_guilds to detect when one of it's forums has just been deleted?\nfunc (s *MemoryForumStore) Delete(id int) error {\n\tif id == ReportForumID {\n\t\treturn ErrNoDeleteReports\n\t}\n\t_, err := s.delete.Exec(id)\n\ts.CacheDelete(id)\n\treturn err\n}\n\nfunc (s *MemoryForumStore) AddTopic(tid, uid, fid int) error {\n\t_, err := s.updateCache.Exec(tid, uid, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.addTopics.Exec(1, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// TODO: Bypass the database and update this with a lock or an unsafe atomic swap\n\treturn s.Reload(fid)\n}\n\nfunc (s *MemoryForumStore) RefreshTopic(fid int) (err error) {\n\tvar tid int\n\terr = s.lastTopic.QueryRow(fid).Scan(&tid)\n\tif err == sql.ErrNoRows {\n\t\tf, err := s.CacheGet(fid)\n\t\tif err != nil || f.LastTopicID != 0 {\n\t\t\t_, err = s.updateCache.Exec(0, 0, fid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ts.Reload(fid)\n\t\t}\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttopic, err := Topics.Get(tid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.updateCache.Exec(tid, topic.CreatedBy, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// TODO: Bypass the database and update this with a lock or an unsafe atomic swap\n\ts.Reload(fid)\n\treturn nil\n}\n\n// TODO: Make this update more atomic\nfunc (s *MemoryForumStore) RemoveTopic(fid int) error {\n\t_, err := s.removeTopics.Exec(1, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.RefreshTopic(fid)\n}\nfunc (s *MemoryForumStore) RemoveTopics(fid, count int) error {\n\t_, err := s.removeTopics.Exec(count, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.RefreshTopic(fid)\n}\n\n// DEPRECATED. forum.Update() will be the way to do this in the future, once it's completed\n// TODO: Have a pointer to the last topic rather than storing it on the forum itself\nfunc (s *MemoryForumStore) UpdateLastTopic(tid, uid, fid int) error {\n\t_, err := s.updateCache.Exec(tid, uid, fid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// TODO: Bypass the database and update this with a lock or an unsafe atomic swap\n\treturn s.Reload(fid)\n}\n\nfunc (s *MemoryForumStore) Create(name, desc string, active bool, preset string) (int, error) {\n\tif name == \"\" {\n\t\treturn 0, ErrBlankName\n\t}\n\tforumCreateMutex.Lock()\n\tdefer forumCreateMutex.Unlock()\n\n\tres, err := s.create.Exec(name, desc, active, preset)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tfid64, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tfid := int(fid64)\n\n\terr = s.Reload(fid)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tPermmapToQuery(PresetToPermmap(preset), fid)\n\treturn fid, nil\n}\n\n// TODO: Make this atomic, maybe with a transaction?\nfunc (s *MemoryForumStore) UpdateOrder(updateMap map[int]int) error {\n\tfor fid, order := range updateMap {\n\t\t_, err := s.updateOrder.Exec(order, fid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn s.LoadForums()\n}\n\n// ! Might be slightly inaccurate, if the sync.Map is constantly shifting and churning, but it'll stabilise eventually. Also, slow. Don't use this on every request x.x\n// Length returns the number of forums in the memory cache\nfunc (s *MemoryForumStore) Length() (len int) {\n\ts.forums.Range(func(_, _ interface{}) bool {\n\t\tlen++\n\t\treturn true\n\t})\n\treturn len\n}\n\n// TODO: Get the total count of forums in the forum store rather than doing a heavy query for this?\n// Count returns the total number of forums\nfunc (s *MemoryForumStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\n// TODO: Work on SqlForumStore\n\n// TODO: Work on the NullForumStore\n"
  },
  {
    "path": "common/gauth/authenticator.go",
    "content": "// Google Authenticator 2FA\n// Borrowed from https://github.com/tilaklodha/google-authenticator, as we can't import it as a library as it's in package main\npackage gauth\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/base32\"\n\t\"encoding/binary\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Append extra 0s if the length of otp is less than 6\n// If otp is \"1234\", it will return it as \"001234\"\nfunc prefix0(otp string) string {\n\tif len(otp) == 6 {\n\t\treturn otp\n\t}\n\tfor i := (6 - len(otp)); i > 0; i-- {\n\t\totp = \"0\" + otp\n\t}\n\treturn otp\n}\n\nfunc GetHOTPToken(secret string, interval int64) (string, error) {\n\tsecret = strings.Replace(secret, \" \", \"\", -1)\n\n\t// Converts secret to base32 Encoding. Base32 encoding desires a 32-character subset of the twenty-six letters A–Z and ten digits 0–9\n\tkey, err := base32.StdEncoding.DecodeString(strings.ToUpper(secret))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tbs := make([]byte, 8)\n\tbinary.BigEndian.PutUint64(bs, uint64(interval))\n\n\t// Signing the value using HMAC-SHA1 Algorithm\n\thash := hmac.New(sha1.New, key)\n\thash.Write(bs)\n\th := hash.Sum(nil)\n\n\t// We're going to use a subset of the generated hash.\n\t// Using the last nibble (half-byte) to choose the index to start from.\n\t// This number is always appropriate as it's maximum decimal 15, the hash will have the maximum index 19 (20 bytes of SHA1) and we need 4 bytes.\n\to := (h[19] & 15)\n\n\tvar header uint32\n\t// Get 32 bit chunk from hash starting at the o\n\tr := bytes.NewReader(h[o : o+4])\n\terr = binary.Read(r, binary.BigEndian, &header)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Ignore most significant bits as per RFC 4226.\n\t// Takes division from one million to generate a remainder less than < 7 digits\n\th12 := (int(header) & 0x7fffffff) % 1000000\n\treturn prefix0(strconv.Itoa(int(h12))), nil\n}\n\nfunc GetTOTPToken(secret string) (string, error) {\n\t// The TOTP token is just a HOTP token seeded with every 30 seconds.\n\tinterval := time.Now().Unix() / 30\n\treturn GetHOTPToken(secret, interval)\n}\n"
  },
  {
    "path": "common/group.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar blankGroup = Group{ID: 0, Name: \"\"}\n\ntype GroupAdmin struct {\n\tID        int\n\tName      string\n\tRank      string\n\tRankClass string\n\tCanEdit   bool\n\tCanDelete bool\n}\n\n// ! Fix the data races in the fperms\ntype Group struct {\n\tID              int\n\tName            string\n\tIsMod           bool\n\tIsAdmin         bool\n\tIsBanned        bool\n\tTag             string\n\tPerms           Perms\n\tPermissionsText []byte\n\tPluginPerms     map[string]bool // Custom permissions defined by plugins. What if two plugins declare the same permission, but they handle them in incompatible ways? Very unlikely, we probably don't need to worry about this, the plugin authors should be aware of each other to some extent\n\tPluginPermsText []byte\n\tCanSee          []int // The IDs of the forums this group can see\n\tUserCount       int   // ! Might be temporary as I might want to lean on the database instead for this\n}\n\ntype GroupStmts struct {\n\tupdateGroup      *sql.Stmt\n\tupdateGroupRank  *sql.Stmt\n\tupdateGroupPerms *sql.Stmt\n}\n\nvar groupStmts GroupStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tset := func(s string) *sql.Stmt {\n\t\t\treturn acc.Update(\"users_groups\").Set(s).Where(\"gid=?\").Prepare()\n\t\t}\n\t\tgroupStmts = GroupStmts{\n\t\t\tupdateGroup:      set(\"name=?,tag=?\"),\n\t\t\tupdateGroupRank:  set(\"is_admin=?,is_mod=?,is_banned=?\"),\n\t\t\tupdateGroupPerms: set(\"permissions=?\"),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc (g *Group) ChangeRank(isAdmin, isMod, isBanned bool) (err error) {\n\t_, err = groupStmts.updateGroupRank.Exec(isAdmin, isMod, isBanned, g.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_ = Groups.Reload(g.ID)\n\treturn nil\n}\n\nfunc (g *Group) Update(name, tag string) (err error) {\n\t_, err = groupStmts.updateGroup.Exec(name, tag, g.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_ = Groups.Reload(g.ID)\n\treturn nil\n}\n\n// Please don't pass arbitrary inputs to this method\nfunc (g *Group) UpdatePerms(perms map[string]bool) (err error) {\n\tpjson, err := json.Marshal(perms)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = groupStmts.updateGroupPerms.Exec(pjson, g.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn Groups.Reload(g.ID)\n}\n\n// Copy gives you a non-pointer concurrency safe copy of the group\nfunc (g *Group) Copy() Group {\n\treturn *g\n}\n\nfunc (g *Group) CopyPtr() (co *Group) {\n\tco = new(Group)\n\t*co = *g\n\treturn co\n}\n\n// TODO: Replace this sorting mechanism with something a lot more efficient\n// ? - Use sort.Slice instead?\ntype SortGroup []*Group\n\nfunc (sg SortGroup) Len() int {\n\treturn len(sg)\n}\nfunc (sg SortGroup) Swap(i, j int) {\n\tsg[i], sg[j] = sg[j], sg[i]\n}\nfunc (sg SortGroup) Less(i, j int) bool {\n\treturn sg[i].ID < sg[j].ID\n}\n"
  },
  {
    "path": "common/group_store.go",
    "content": "/* Under Heavy Construction */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"log\"\n\t\"sort\"\n\t\"strconv\"\n\t\"sync\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Groups GroupStore\n\n// ? - We could fallback onto the database when an item can't be found in the cache?\ntype GroupStore interface {\n\tLoadGroups() error\n\tDirtyGet(id int) *Group\n\tGet(id int) (*Group, error)\n\tGetCopy(id int) (Group, error)\n\tExists(id int) bool\n\tCreate(name, tag string, isAdmin, isMod, isBanned bool) (id int, err error)\n\tGetAll() ([]*Group, error)\n\tGetRange(lower, higher int) ([]*Group, error)\n\tReload(id int) error // ? - Should we move this to GroupCache? It might require us to do some unnecessary casting though\n\tCount() int\n}\n\ntype GroupCache interface {\n\tCacheSet(g *Group) error\n\tSetCanSee(gid int, canSee []int) error\n\tCacheAdd(g *Group) error\n\tLength() int\n}\n\ntype MemoryGroupStore struct {\n\tgroups     map[int]*Group // TODO: Use a sync.Map instead of a map?\n\tgroupCount int\n\tgetAll     *sql.Stmt\n\tget        *sql.Stmt\n\tcount      *sql.Stmt\n\tuserCount  *sql.Stmt\n\n\tsync.RWMutex\n}\n\nfunc NewMemoryGroupStore() (*MemoryGroupStore, error) {\n\tacc := qgen.NewAcc()\n\tug := \"users_groups\"\n\treturn &MemoryGroupStore{\n\t\tgroups:     make(map[int]*Group),\n\t\tgroupCount: 0,\n\t\tgetAll:     acc.Select(ug).Columns(\"gid,name,permissions,plugin_perms,is_mod,is_admin,is_banned,tag\").Prepare(),\n\t\tget:        acc.Select(ug).Columns(\"name,permissions,plugin_perms,is_mod,is_admin,is_banned,tag\").Where(\"gid=?\").Prepare(),\n\t\tcount:      acc.Count(ug).Prepare(),\n\t\tuserCount:  acc.Count(\"users\").Where(\"group=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Move this query from the global stmt store into this store\nfunc (s *MemoryGroupStore) LoadGroups() error {\n\ts.Lock()\n\tdefer s.Unlock()\n\ts.groups[0] = &Group{ID: 0, Name: \"Unknown\"}\n\n\trows, err := s.getAll.Query()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\ti := 1\n\tfor ; rows.Next(); i++ {\n\t\tg := &Group{ID: 0}\n\t\terr := rows.Scan(&g.ID, &g.Name, &g.PermissionsText, &g.PluginPermsText, &g.IsMod, &g.IsAdmin, &g.IsBanned, &g.Tag)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\terr = s.initGroup(g)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.groups[g.ID] = g\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn err\n\t}\n\ts.groupCount = i\n\n\tDebugLog(\"Binding the Not Loggedin Group\")\n\tGuestPerms = s.dirtyGetUnsafe(6).Perms // ! Race?\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\n// TODO: Hit the database when the item isn't in memory\nfunc (s *MemoryGroupStore) dirtyGetUnsafe(id int) *Group {\n\tgroup, ok := s.groups[id]\n\tif !ok {\n\t\treturn &blankGroup\n\t}\n\treturn group\n}\n\n// TODO: Hit the database when the item isn't in memory\nfunc (s *MemoryGroupStore) DirtyGet(id int) *Group {\n\ts.RLock()\n\tgroup, ok := s.groups[id]\n\ts.RUnlock()\n\tif !ok {\n\t\treturn &blankGroup\n\t}\n\treturn group\n}\n\n// TODO: Hit the database when the item isn't in memory\nfunc (s *MemoryGroupStore) Get(id int) (*Group, error) {\n\ts.RLock()\n\tgroup, ok := s.groups[id]\n\ts.RUnlock()\n\tif !ok {\n\t\treturn nil, ErrNoRows\n\t}\n\treturn group, nil\n}\n\n// TODO: Hit the database when the item isn't in memory\nfunc (s *MemoryGroupStore) GetCopy(id int) (Group, error) {\n\ts.RLock()\n\tgroup, ok := s.groups[id]\n\ts.RUnlock()\n\tif !ok {\n\t\treturn blankGroup, ErrNoRows\n\t}\n\treturn *group, nil\n}\n\nfunc (s *MemoryGroupStore) Reload(id int) error {\n\t// TODO: Reload this data too\n\tg, e := s.Get(id)\n\tif e != nil {\n\t\tLogError(errors.New(\"can't get cansee data for group #\" + strconv.Itoa(id)))\n\t\treturn nil\n\t}\n\tcanSee := g.CanSee\n\n\tg = &Group{ID: id, CanSee: canSee}\n\te = s.get.QueryRow(id).Scan(&g.Name, &g.PermissionsText, &g.PluginPermsText, &g.IsMod, &g.IsAdmin, &g.IsBanned, &g.Tag)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif e = s.initGroup(g); e != nil {\n\t\tLogError(e)\n\t\treturn nil\n\t}\n\n\ts.CacheSet(g)\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\nfunc (s *MemoryGroupStore) initGroup(g *Group) error {\n\te := json.Unmarshal(g.PermissionsText, &g.Perms)\n\tif e != nil {\n\t\tlog.Printf(\"g: %+v\\n\", g)\n\t\tlog.Print(\"bad group perms: \", g.PermissionsText)\n\t\treturn e\n\t}\n\tDebugLogf(g.Name+\": %+v\\n\", g.Perms)\n\n\te = json.Unmarshal(g.PluginPermsText, &g.PluginPerms)\n\tif e != nil {\n\t\tlog.Printf(\"g: %+v\\n\", g)\n\t\tlog.Print(\"bad group plugin perms: \", g.PluginPermsText)\n\t\treturn e\n\t}\n\tDebugLogf(g.Name+\": %+v\\n\", g.PluginPerms)\n\n\t//group.Perms.ExtData = make(map[string]bool)\n\t// TODO: Can we optimise the bit where this cascades down to the user now?\n\tif g.IsAdmin || g.IsMod {\n\t\tg.IsBanned = false\n\t}\n\n\te = s.userCount.QueryRow(g.ID).Scan(&g.UserCount)\n\tif e != sql.ErrNoRows {\n\t\treturn e\n\t}\n\treturn nil\n}\n\nfunc (s *MemoryGroupStore) SetCanSee(gid int, canSee []int) error {\n\ts.Lock()\n\tgroup, ok := s.groups[gid]\n\tif !ok {\n\t\ts.Unlock()\n\t\treturn nil\n\t}\n\tngroup := &Group{}\n\t*ngroup = *group\n\tngroup.CanSee = canSee\n\ts.groups[group.ID] = ngroup\n\ts.Unlock()\n\treturn nil\n}\n\nfunc (s *MemoryGroupStore) CacheSet(g *Group) error {\n\ts.Lock()\n\ts.groups[g.ID] = g\n\ts.Unlock()\n\treturn nil\n}\n\n// TODO: Hit the database when the item isn't in memory\nfunc (s *MemoryGroupStore) Exists(id int) bool {\n\ts.RLock()\n\tgroup, ok := s.groups[id]\n\ts.RUnlock()\n\treturn ok && group.Name != \"\"\n}\n\n// ? Allow two groups with the same name?\n// TODO: Refactor this\nfunc (s *MemoryGroupStore) Create(name, tag string, isAdmin, isMod, isBanned bool) (gid int, err error) {\n\tpermstr := \"{}\"\n\ttx, err := qgen.Builder.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer tx.Rollback()\n\n\tinsertTx, err := qgen.Builder.SimpleInsertTx(tx, \"users_groups\", \"name,tag,is_admin,is_mod,is_banned,permissions,plugin_perms\", \"?,?,?,?,?,?,'{}'\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tres, err := insertTx.Exec(name, tag, isAdmin, isMod, isBanned, permstr)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tgid64, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tgid = int(gid64)\n\n\tperms := BlankPerms\n\tblankIntList := []int{}\n\tpluginPerms := make(map[string]bool)\n\tpluginPermsBytes := []byte(\"{}\")\n\tGetHookTable().Vhook(\"create_group_preappend\", &pluginPerms, &pluginPermsBytes)\n\n\t// Generate the forum permissions based on the presets...\n\tforums, err := Forums.GetAll()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tpresetSet := make(map[int]string)\n\tpermSet := make(map[int]*ForumPerms)\n\tfor _, f := range forums {\n\t\tvar thePreset string\n\t\tswitch {\n\t\tcase isAdmin:\n\t\t\tthePreset = \"admins\"\n\t\tcase isMod:\n\t\t\tthePreset = \"staff\"\n\t\tcase isBanned:\n\t\t\tthePreset = \"banned\"\n\t\tdefault:\n\t\t\tthePreset = \"members\"\n\t\t}\n\n\t\tpermmap := PresetToPermmap(f.Preset)\n\t\tpermItem := permmap[thePreset]\n\t\tpermItem.Overrides = true\n\n\t\tpermSet[f.ID] = permItem\n\t\tpresetSet[f.ID] = f.Preset\n\t}\n\n\terr = ReplaceForumPermsForGroupTx(tx, gid, presetSet, permSet)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\terr = tx.Commit()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\t// TODO: Can we optimise the bit where this cascades down to the user now?\n\tif isAdmin || isMod {\n\t\tisBanned = false\n\t}\n\n\ts.CacheAdd(&Group{gid, name, isMod, isAdmin, isBanned, tag, perms, []byte(permstr), pluginPerms, pluginPermsBytes, blankIntList, 0})\n\n\tTopicListThaw.Thaw()\n\treturn gid, FPStore.ReloadAll()\n\t//return gid, TopicList.RebuildPermTree()\n}\n\nfunc (s *MemoryGroupStore) CacheAdd(g *Group) error {\n\ts.Lock()\n\ts.groups[g.ID] = g\n\ts.groupCount++\n\ts.Unlock()\n\treturn nil\n}\n\nfunc (s *MemoryGroupStore) GetAll() (results []*Group, err error) {\n\tvar i int\n\ts.RLock()\n\tresults = make([]*Group, len(s.groups))\n\tfor _, group := range s.groups {\n\t\tresults[i] = group\n\t\ti++\n\t}\n\ts.RUnlock()\n\tsort.Sort(SortGroup(results))\n\treturn results, nil\n}\n\nfunc (s *MemoryGroupStore) GetAllMap() (map[int]*Group, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\treturn s.groups, nil\n}\n\n// ? - Set the lower and higher numbers to 0 to remove the bounds\n// TODO: Might be a little slow right now, maybe we can cache the groups in a slice or break the map up into chunks\nfunc (s *MemoryGroupStore) GetRange(lower, higher int) (groups []*Group, err error) {\n\tif lower == 0 && higher == 0 {\n\t\treturn s.GetAll()\n\t}\n\n\t// TODO: Simplify these four conditionals into two\n\tif lower == 0 {\n\t\tif higher < 0 {\n\t\t\treturn nil, errors.New(\"higher may not be lower than 0\")\n\t\t}\n\t} else if higher == 0 {\n\t\tif lower < 0 {\n\t\t\treturn nil, errors.New(\"lower may not be lower than 0\")\n\t\t}\n\t}\n\n\ts.RLock()\n\tfor gid, group := range s.groups {\n\t\tif gid >= lower && (gid <= higher || higher == 0) {\n\t\t\tgroups = append(groups, group)\n\t\t}\n\t}\n\ts.RUnlock()\n\tsort.Sort(SortGroup(groups))\n\n\treturn groups, nil\n}\n\nfunc (s *MemoryGroupStore) Length() int {\n\ts.RLock()\n\tdefer s.RUnlock()\n\treturn s.groupCount\n}\n\nfunc (s *MemoryGroupStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n"
  },
  {
    "path": "common/ip_search.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar IPSearch IPSearcher\n\ntype IPSearcher interface {\n\tLookup(ip string) (uids []int, e error)\n}\n\ntype DefaultIPSearcher struct {\n\tsearchUsers        *sql.Stmt\n\tsearchTopics       *sql.Stmt\n\tsearchReplies      *sql.Stmt\n\tsearchUsersReplies *sql.Stmt\n}\n\n// NewDefaultIPSearcher gives you a new instance of DefaultIPSearcher\nfunc NewDefaultIPSearcher() (*DefaultIPSearcher, error) {\n\tacc := qgen.NewAcc()\n\tuu := \"users\"\n\tq := func(tbl string) *sql.Stmt {\n\t\treturn acc.Select(uu).Columns(\"uid\").InQ(\"uid\", acc.Select(tbl).Columns(\"createdBy\").Where(\"ip=?\")).Prepare()\n\t}\n\treturn &DefaultIPSearcher{\n\t\tsearchUsers:        acc.Select(uu).Columns(\"uid\").Where(\"last_ip=? OR last_ip LIKE CONCAT('%-',?)\").Prepare(),\n\t\tsearchTopics:       q(\"topics\"),\n\t\tsearchReplies:      q(\"replies\"),\n\t\tsearchUsersReplies: q(\"users_replies\"),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultIPSearcher) Lookup(ip string) (uids []int, e error) {\n\tvar uid int\n\treqUserList := make(map[int]bool)\n\trunQuery2 := func(rows *sql.Rows, e error) error {\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tif e := rows.Scan(&uid); e != nil {\n\t\t\t\treturn e\n\t\t\t}\n\t\t\treqUserList[uid] = true\n\t\t}\n\t\treturn rows.Err()\n\t}\n\trunQuery := func(stmt *sql.Stmt) error {\n\t\treturn runQuery2(stmt.Query(ip))\n\t}\n\n\te = runQuery2(s.searchUsers.Query(ip, ip))\n\tif e != nil {\n\t\treturn uids, e\n\t}\n\te = runQuery(s.searchTopics)\n\tif e != nil {\n\t\treturn uids, e\n\t}\n\te = runQuery(s.searchReplies)\n\tif e != nil {\n\t\treturn uids, e\n\t}\n\te = runQuery(s.searchUsersReplies)\n\tif e != nil {\n\t\treturn uids, e\n\t}\n\n\t// Convert the user ID map to a slice, then bulk load the users\n\tuids = make([]int, len(reqUserList))\n\tvar i int\n\tfor userID := range reqUserList {\n\t\tuids[i] = userID\n\t\ti++\n\t}\n\n\treturn uids, nil\n}\n"
  },
  {
    "path": "common/likes.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Likes LikeStore\n\ntype LikeStore interface {\n\tBulkExists(ids []int, sentBy int, targetType string) ([]int, error)\n\tBulkExistsFunc(ids []int, sentBy int, targetType string, f func(int) error) error\n\tDelete(targetID int, targetType string) error\n\tCount() (count int)\n}\n\ntype DefaultLikeStore struct {\n\tcount        *sql.Stmt\n\tdelete       *sql.Stmt\n\tsingleExists *sql.Stmt\n}\n\nfunc NewDefaultLikeStore(acc *qgen.Accumulator) (*DefaultLikeStore, error) {\n\treturn &DefaultLikeStore{\n\t\tcount:        acc.Count(\"likes\").Prepare(),\n\t\tdelete:       acc.Delete(\"likes\").Where(\"targetItem=? AND targetType=?\").Prepare(),\n\t\tsingleExists: acc.Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? AND targetType=? AND targetItem=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Write a test for this\nfunc (s *DefaultLikeStore) BulkExists(ids []int, sentBy int, targetType string) (eids []int, e error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tvar rows *sql.Rows\n\tif len(ids) == 1 {\n\t\trows, e = s.singleExists.Query(sentBy, targetType, ids[0])\n\t} else {\n\t\trows, e = qgen.NewAcc().Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? AND targetType=?\").In(\"targetItem\", ids).Query(sentBy, targetType)\n\t}\n\tif e == sql.ErrNoRows {\n\t\treturn nil, nil\n\t} else if e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\n\tvar id int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&id); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\teids = append(eids, id)\n\t}\n\treturn eids, rows.Err()\n}\n\n// TODO: Write a test for this\nfunc (s *DefaultLikeStore) BulkExistsFunc(ids []int, sentBy int, targetType string, f func(id int) error) (e error) {\n\tif len(ids) == 0 {\n\t\treturn nil\n\t}\n\tvar rows *sql.Rows\n\tif len(ids) == 1 {\n\t\trows, e = s.singleExists.Query(sentBy, targetType, ids[0])\n\t} else {\n\t\trows, e = qgen.NewAcc().Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? AND targetType=?\").In(\"targetItem\", ids).Query(sentBy, targetType)\n\t}\n\tif e == sql.ErrNoRows {\n\t\treturn nil\n\t} else if e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\n\tvar id int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&id); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e := f(id); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nfunc (s *DefaultLikeStore) Delete(targetID int, targetType string) error {\n\t_, err := s.delete.Exec(targetID, targetType)\n\treturn err\n}\n\n// TODO: Write a test for this\n// Count returns the total number of likes globally\nfunc (s *DefaultLikeStore) Count() (count int) {\n\te := s.count.QueryRow().Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n"
  },
  {
    "path": "common/menu_item_store.go",
    "content": "package common\n\nimport \"sync\"\n\ntype DefaultMenuItemStore struct {\n\titems map[int]MenuItem\n\tlock  sync.RWMutex\n}\n\nfunc NewDefaultMenuItemStore() *DefaultMenuItemStore {\n\treturn &DefaultMenuItemStore{\n\t\titems: make(map[int]MenuItem),\n\t}\n}\n\nfunc (s *DefaultMenuItemStore) Add(i MenuItem) {\n\ts.lock.Lock()\n\tdefer s.lock.Unlock()\n\ts.items[i.ID] = i\n}\n\nfunc (s *DefaultMenuItemStore) Get(id int) (MenuItem, error) {\n\ts.lock.RLock()\n\titem, ok := s.items[id]\n\ts.lock.RUnlock()\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n"
  },
  {
    "path": "common/menu_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"strconv\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Menus *DefaultMenuStore\n\ntype DefaultMenuStore struct {\n\tmenus     map[int]*atomic.Value\n\titemStore *DefaultMenuItemStore\n}\n\nfunc NewDefaultMenuStore() *DefaultMenuStore {\n\treturn &DefaultMenuStore{\n\t\tmake(map[int]*atomic.Value),\n\t\tNewDefaultMenuItemStore(),\n\t}\n}\n\n// TODO: Add actual support for multiple menus\nfunc (s *DefaultMenuStore) GetAllMap() (out map[int]*MenuListHolder) {\n\tout = make(map[int]*MenuListHolder)\n\tfor mid, atom := range s.menus {\n\t\tout[mid] = atom.Load().(*MenuListHolder)\n\t}\n\treturn out\n}\n\nfunc (s *DefaultMenuStore) Get(mid int) (*MenuListHolder, error) {\n\taStore, ok := s.menus[mid]\n\tif ok {\n\t\treturn aStore.Load().(*MenuListHolder), nil\n\t}\n\treturn nil, ErrNoRows\n}\n\nfunc (s *DefaultMenuStore) Items(mid int) (mlist MenuItemList, err error) {\n\terr = qgen.NewAcc().Select(\"menu_items\").Columns(\"miid,name,htmlID,cssClass,position,path,aria,tooltip,order,tmplName,guestOnly,memberOnly,staffOnly,adminOnly\").Where(\"mid=\" + strconv.Itoa(mid)).Orderby(\"order ASC\").Each(func(rows *sql.Rows) error {\n\t\ti := MenuItem{MenuID: mid}\n\t\terr := rows.Scan(&i.ID, &i.Name, &i.HTMLID, &i.CSSClass, &i.Position, &i.Path, &i.Aria, &i.Tooltip, &i.Order, &i.TmplName, &i.GuestOnly, &i.MemberOnly, &i.SuperModOnly, &i.AdminOnly)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts.itemStore.Add(i)\n\t\tmlist = append(mlist, i)\n\t\treturn nil\n\t})\n\treturn mlist, err\n}\n\nfunc (s *DefaultMenuStore) Load(mid int) error {\n\tmlist, err := s.Items(mid)\n\tif err != nil {\n\t\treturn err\n\t}\n\thold := &MenuListHolder{mid, mlist, make(map[int]menuTmpl)}\n\terr = hold.Preparse()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taStore := &atomic.Value{}\n\taStore.Store(hold)\n\ts.menus[mid] = aStore\n\treturn nil\n}\n\nfunc (s *DefaultMenuStore) ItemStore() *DefaultMenuItemStore {\n\treturn s.itemStore\n}\n"
  },
  {
    "path": "common/menus.go",
    "content": "package common\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\ttmpl \"github.com/Azareal/Gosora/common/templates\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype MenuItemList []MenuItem\n\ntype MenuListHolder struct {\n\tMenuID     int\n\tList       MenuItemList\n\tVariations map[int]menuTmpl // 0 = Guest Menu, 1 = Member Menu, 2 = Super Mod Menu, 3 = Admin Menu\n}\n\ntype menuPath struct {\n\tPath  string\n\tIndex int\n}\n\ntype menuTmpl struct {\n\tRenderBuffer    [][]byte\n\tVariableIndices []int\n\tPathMappings    []menuPath\n}\n\ntype MenuItem struct {\n\tID     int\n\tMenuID int\n\n\tName     string\n\tHTMLID   string\n\tCSSClass string\n\tPosition string\n\tPath     string\n\tAria     string\n\tTooltip  string\n\tOrder    int\n\tTmplName string\n\n\tGuestOnly    bool\n\tMemberOnly   bool\n\tSuperModOnly bool\n\tAdminOnly    bool\n}\n\n// TODO: Move the menu item stuff to it's own file\ntype MenuItemStmts struct {\n\tupdate      *sql.Stmt\n\tinsert      *sql.Stmt\n\tdelete      *sql.Stmt\n\tupdateOrder *sql.Stmt\n}\n\nvar menuItemStmts MenuItemStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tmi := \"menu_items\"\n\t\tmenuItemStmts = MenuItemStmts{\n\t\t\tupdate:      acc.Update(mi).Set(\"name=?,htmlID=?,cssClass=?,position=?,path=?,aria=?,tooltip=?,tmplName=?,guestOnly=?,memberOnly=?,staffOnly=?,adminOnly=?\").Where(\"miid=?\").Prepare(),\n\t\t\tinsert:      acc.Insert(mi).Columns(\"mid, name, htmlID, cssClass, position, path, aria, tooltip, tmplName, guestOnly, memberOnly, staffOnly, adminOnly\").Fields(\"?,?,?,?,?,?,?,?,?,?,?,?,?\").Prepare(),\n\t\t\tdelete:      acc.Delete(mi).Where(\"miid=?\").Prepare(),\n\t\t\tupdateOrder: acc.Update(mi).Set(\"order=?\").Where(\"miid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc (i MenuItem) Commit() error {\n\t_, e := menuItemStmts.update.Exec(i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly, i.ID)\n\tMenus.Load(i.MenuID)\n\treturn e\n}\n\nfunc (i MenuItem) Create() (int, error) {\n\tres, e := menuItemStmts.insert.Exec(i.MenuID, i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tMenus.Load(i.MenuID)\n\n\tmiid64, e := res.LastInsertId()\n\treturn int(miid64), e\n}\n\nfunc (i MenuItem) Delete() error {\n\t_, e := menuItemStmts.delete.Exec(i.ID)\n\tMenus.Load(i.MenuID)\n\treturn e\n}\n\nfunc (h *MenuListHolder) LoadTmpl(name string) (t MenuTmpl, e error) {\n\tdata, e := ioutil.ReadFile(\"./templates/\" + name + \".html\")\n\tif e != nil {\n\t\treturn t, e\n\t}\n\treturn h.Parse(name, []byte(tmpl.Minify(string(data)))), nil\n}\n\n// TODO: Make this atomic, maybe with a transaction or store the order on the menu itself?\nfunc (h *MenuListHolder) UpdateOrder(updateMap map[int]int) error {\n\tfor miid, order := range updateMap {\n\t\t_, e := menuItemStmts.updateOrder.Exec(order, miid)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\tMenus.Load(h.MenuID)\n\treturn nil\n}\n\nfunc (h *MenuListHolder) LoadTmpls() (tmpls map[string]MenuTmpl, e error) {\n\ttmpls = make(map[string]MenuTmpl)\n\tload := func(name string) error {\n\t\tmenuTmpl, e := h.LoadTmpl(name)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\ttmpls[name] = menuTmpl\n\t\treturn nil\n\t}\n\te = load(\"menu_item\")\n\tif e != nil {\n\t\treturn tmpls, e\n\t}\n\te = load(\"menu_alerts\")\n\treturn tmpls, e\n}\n\n// TODO: Run this in main, sync ticks, when the phrase file changes (need to implement the sync for that first), and when the settings are changed\nfunc (h *MenuListHolder) Preparse() error {\n\ttmpls, err := h.LoadTmpls()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\taddVariation := func(index int, callback func(i MenuItem) bool) {\n\t\trenderBuffer, variableIndices, pathList := h.Scan(tmpls, callback)\n\t\th.Variations[index] = menuTmpl{renderBuffer, variableIndices, pathList}\n\t}\n\n\t// Guest Menu\n\taddVariation(0, func(i MenuItem) bool {\n\t\treturn !i.MemberOnly\n\t})\n\t// Member Menu\n\taddVariation(1, func(i MenuItem) bool {\n\t\treturn !i.SuperModOnly && !i.GuestOnly\n\t})\n\t// Super Mod Menu\n\taddVariation(2, func(i MenuItem) bool {\n\t\treturn !i.AdminOnly && !i.GuestOnly\n\t})\n\t// Admin Menu\n\taddVariation(3, func(i MenuItem) bool {\n\t\treturn !i.GuestOnly\n\t})\n\treturn nil\n}\n\nfunc nextCharIs(tmplData []byte, i int, expects byte) bool {\n\tif len(tmplData) <= (i + 1) {\n\t\treturn false\n\t}\n\treturn tmplData[i+1] == expects\n}\n\nfunc peekNextChar(tmplData []byte, i int) byte {\n\tif len(tmplData) <= (i + 1) {\n\t\treturn 0\n\t}\n\treturn tmplData[i+1]\n}\n\nfunc skipUntilIfExists(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {\n\tj := i\n\tfor ; j < len(tmplData); j++ {\n\t\tif tmplData[j] == expects {\n\t\t\treturn j, true\n\t\t}\n\t}\n\treturn j, false\n}\n\nfunc skipUntilIfExistsOrLine(tmplData []byte, i int, expects byte) (newI int, hasIt bool) {\n\tj := i\n\tfor ; j < len(tmplData); j++ {\n\t\tif tmplData[j] == 10 {\n\t\t\treturn j, false\n\t\t} else if tmplData[j] == expects {\n\t\t\treturn j, true\n\t\t}\n\t}\n\treturn j, false\n}\n\nfunc skipUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {\n\tj := i\n\texpectIndex := 0\n\tfor ; j < len(tmplData) && expectIndex < len(expects); j++ {\n\t\t//fmt.Println(\"tmplData[j]: \", string(tmplData[j]))\n\t\tif tmplData[j] != expects[expectIndex] {\n\t\t\treturn j, false\n\t\t}\n\t\t//fmt.Printf(\"found %+v at %d\\n\", string(expects[expectIndex]), expectIndex)\n\t\texpectIndex++\n\t}\n\treturn j, true\n}\n\nfunc skipAllUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) {\n\tj := i\n\texpectIndex := 0\n\tfor ; j < len(tmplData) && expectIndex < len(expects); j++ {\n\t\tif tmplData[j] == expects[expectIndex] {\n\t\t\t//fmt.Printf(\"expects[expectIndex]: %+v - %d\\n\", string(expects[expectIndex]), expectIndex)\n\t\t\texpectIndex++\n\t\t\tif len(expects) <= expectIndex {\n\t\t\t\tbreak\n\t\t\t}\n\t\t} else {\n\t\t\t/*if expectIndex != 0 {\n\t\t\t\tfmt.Println(\"broke expectations\")\n\t\t\t\tfmt.Println(\"expected: \", string(expects[expectIndex]))\n\t\t\t\tfmt.Println(\"got: \", string(tmplData[j]))\n\t\t\t\tfmt.Println(\"next: \", string(peekNextChar(tmplData, j)))\n\t\t\t\tfmt.Println(\"next: \", string(peekNextChar(tmplData, j+1)))\n\t\t\t\tfmt.Println(\"next: \", string(peekNextChar(tmplData, j+2)))\n\t\t\t\tfmt.Println(\"next: \", string(peekNextChar(tmplData, j+3)))\n\t\t\t}*/\n\t\t\texpectIndex = 0\n\t\t}\n\t}\n\treturn j, len(expects) == expectIndex\n}\n\ntype menuRenderItem struct {\n\tType  int // 0: text, 1: variable\n\tIndex int\n}\n\ntype MenuTmpl struct {\n\tName           string\n\tTextBuffer     [][]byte\n\tVariableBuffer [][]byte\n\tRenderList     []menuRenderItem\n}\n\nfunc menuDumpSlice(outerSlice [][]byte) {\n\tfor sliceID, slice := range outerSlice {\n\t\tfmt.Print(strconv.Itoa(sliceID) + \":[\")\n\t\tfor _, ch := range slice {\n\t\t\tfmt.Print(string(ch))\n\t\t}\n\t\tfmt.Print(\"] \")\n\t}\n}\n\nfunc (h *MenuListHolder) Parse(name string, tmplData []byte) (menuTmpl MenuTmpl) {\n\tvar textBuffer, variableBuffer [][]byte\n\tvar renderList []menuRenderItem\n\tvar subBuffer []byte\n\n\t// ? We only support simple properties on MenuItem right now\n\taddVariable := func(name []byte) {\n\t\t// TODO: Check if the subBuffer has any items or is empty\n\t\ttextBuffer = append(textBuffer, subBuffer)\n\t\tsubBuffer = nil\n\n\t\tvariableBuffer = append(variableBuffer, name)\n\t\trenderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})\n\t\trenderList = append(renderList, menuRenderItem{1, len(variableBuffer) - 1})\n\t}\n\n\ttmplData = bytes.Replace(tmplData, []byte(\"{{\"), []byte(\"{\"), -1)\n\ttmplData = bytes.Replace(tmplData, []byte(\"}}\"), []byte(\"}}\"), -1)\n\tfor i := 0; i < len(tmplData); i++ {\n\t\tchar := tmplData[i]\n\t\tif char == '{' {\n\t\t\tdotIndex, hasDot := skipUntilIfExists(tmplData, i, '.')\n\t\t\tif !hasDot {\n\t\t\t\t// Template function style\n\t\t\t\tlangIndex, hasChars := skipUntilCharsExist(tmplData, i+1, []byte(\"lang\"))\n\t\t\t\tif hasChars {\n\t\t\t\t\tstartIndex, hasStart := skipUntilIfExists(tmplData, langIndex, '\"')\n\t\t\t\t\tendIndex, hasEnd := skipUntilIfExists(tmplData, startIndex+1, '\"')\n\t\t\t\t\tif hasStart && hasEnd {\n\t\t\t\t\t\tfenceIndex, hasFence := skipUntilIfExists(tmplData, endIndex, '}')\n\t\t\t\t\t\tif !hasFence || !nextCharIs(tmplData, fenceIndex, '}') {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\t//fmt.Println(\"tmplData[startIndex:endIndex]: \", tmplData[startIndex+1:endIndex])\n\t\t\t\t\t\tprefix := []byte(\"lang.\")\n\t\t\t\t\t\taddVariable(append(prefix, tmplData[startIndex+1:endIndex]...))\n\t\t\t\t\t\ti = fenceIndex + 1\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tfenceIndex, hasFence := skipUntilIfExists(tmplData, dotIndex, '}')\n\t\t\tif !hasFence {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\taddVariable(tmplData[dotIndex:fenceIndex])\n\t\t\ti = fenceIndex + 1\n\t\t\tcontinue\n\t\t}\n\t\tsubBuffer = append(subBuffer, char)\n\t}\n\tif len(subBuffer) > 0 {\n\t\t// TODO: Have a property in renderList which holds the byte slice since variableBuffers and textBuffers have the same underlying implementation?\n\t\ttextBuffer = append(textBuffer, subBuffer)\n\t\trenderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1})\n\t}\n\n\treturn MenuTmpl{name, textBuffer, variableBuffer, renderList}\n}\n\nfunc (h *MenuListHolder) Scan(tmpls map[string]MenuTmpl, showItem func(i MenuItem) bool) (renderBuffer [][]byte, variableIndices []int, pathList []menuPath) {\n\tfor _, mitem := range h.List {\n\t\t// Do we want this item in this variation of the menu?\n\t\tif !showItem(mitem) {\n\t\t\tcontinue\n\t\t}\n\t\trenderBuffer, variableIndices = h.ScanItem(tmpls, mitem, renderBuffer, variableIndices)\n\t\tpathList = append(pathList, menuPath{mitem.Path, len(renderBuffer) - 1})\n\t}\n\n\t// TODO: Need more coalescing in the renderBuffer\n\treturn renderBuffer, variableIndices, pathList\n}\n\n// Note: This doesn't do a visibility check like hold.Scan() does\nfunc (h *MenuListHolder) ScanItem(tmpls map[string]MenuTmpl, mitem MenuItem, renderBuffer [][]byte, variableIndices []int) ([][]byte, []int) {\n\tmenuTmpl, ok := tmpls[mitem.TmplName]\n\tif !ok {\n\t\tmenuTmpl = tmpls[\"menu_item\"]\n\t}\n\n\tfor _, renderItem := range menuTmpl.RenderList {\n\t\tif renderItem.Type == 0 {\n\t\t\trenderBuffer = append(renderBuffer, menuTmpl.TextBuffer[renderItem.Index])\n\t\t\tcontinue\n\t\t}\n\n\t\tvariable := menuTmpl.VariableBuffer[renderItem.Index]\n\t\tdotAt, hasDot := skipUntilIfExists(variable, 0, '.')\n\t\tif !hasDot {\n\t\t\tcontinue\n\t\t}\n\n\t\tif bytes.Equal(variable[:dotAt], []byte(\"lang\")) {\n\t\t\trenderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(bytes.TrimPrefix(variable[dotAt:], []byte(\".\"))))))\n\t\t\tcontinue\n\t\t}\n\n\t\tvar renderItem []byte\n\t\tswitch string(variable) {\n\t\tcase \".ID\":\n\t\t\trenderItem = []byte(strconv.Itoa(mitem.ID))\n\t\tcase \".Name\":\n\t\t\trenderItem = []byte(mitem.Name)\n\t\tcase \".HTMLID\":\n\t\t\trenderItem = []byte(mitem.HTMLID)\n\t\tcase \".CSSClass\":\n\t\t\trenderItem = []byte(mitem.CSSClass)\n\t\tcase \".Position\":\n\t\t\trenderItem = []byte(mitem.Position)\n\t\tcase \".Path\":\n\t\t\trenderItem = []byte(mitem.Path)\n\t\tcase \".Aria\":\n\t\t\trenderItem = []byte(mitem.Aria)\n\t\tcase \".Tooltip\":\n\t\t\trenderItem = []byte(mitem.Tooltip)\n\t\tcase \".CSSActive\":\n\t\t\trenderItem = []byte(\"{dyn.active}\")\n\t\t}\n\n\t\t_, hasInnerVar := skipUntilIfExists(renderItem, 0, '{')\n\t\tif hasInnerVar {\n\t\t\tDebugLog(\"inner var: \", string(renderItem))\n\t\t\tdotAt, hasDot := skipUntilIfExists(renderItem, 0, '.')\n\t\t\tendFence, hasEndFence := skipUntilIfExists(renderItem, dotAt, '}')\n\t\t\tif !hasDot || !hasEndFence || (endFence-dotAt) <= 1 {\n\t\t\t\trenderBuffer = append(renderBuffer, renderItem)\n\t\t\t\tvariableIndices = append(variableIndices, len(renderBuffer)-1)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif bytes.Equal(renderItem[1:dotAt], []byte(\"lang\")) {\n\t\t\t\t//fmt.Println(\"lang var: \", string(renderItem[dotAt+1:endFence]))\n\t\t\t\trenderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(renderItem[dotAt+1:endFence]))))\n\t\t\t} else {\n\t\t\t\tfmt.Println(\"other var: \", string(variable[:dotAt]))\n\t\t\t\tif len(renderItem) > 0 {\n\t\t\t\t\trenderBuffer = append(renderBuffer, renderItem)\n\t\t\t\t\tvariableIndices = append(variableIndices, len(renderBuffer)-1)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif len(renderItem) > 0 {\n\t\t\trenderBuffer = append(renderBuffer, renderItem)\n\t\t}\n\t}\n\treturn renderBuffer, variableIndices\n}\n\n// TODO: Pre-render the lang stuff\nfunc (h *MenuListHolder) Build(w io.Writer, user *User, pathPrefix string) error {\n\tvar mTmpl menuTmpl\n\tif !user.Loggedin {\n\t\tmTmpl = h.Variations[0]\n\t} else if user.IsAdmin {\n\t\tmTmpl = h.Variations[3]\n\t} else if user.IsSuperMod {\n\t\tmTmpl = h.Variations[2]\n\t} else {\n\t\tmTmpl = h.Variations[1]\n\t}\n\tif pathPrefix == \"\" {\n\t\tpathPrefix = Config.DefaultPath\n\t}\n\n\tif len(mTmpl.VariableIndices) == 0 {\n\t\tfor _, renderItem := range mTmpl.RenderBuffer {\n\t\t\tw.Write(renderItem)\n\t\t}\n\t\treturn nil\n\t}\n\n\tnearIndex := 0\n\tfor index, renderItem := range mTmpl.RenderBuffer {\n\t\tif index != mTmpl.VariableIndices[nearIndex] {\n\t\t\tw.Write(renderItem)\n\t\t\tcontinue\n\t\t}\n\t\tvariable := renderItem\n\t\t// ? - I can probably remove this check now that I've kicked it upstream, or we could keep it here for safety's sake?\n\t\tif len(variable) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tprevIndex := 0\n\t\tfor i := 0; i < len(renderItem); i++ {\n\t\t\tfenceStart, hasFence := skipUntilIfExists(variable, i, '{')\n\t\t\tif !hasFence {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ti = fenceStart\n\t\t\tfenceEnd, hasFence := skipUntilIfExists(variable, fenceStart, '}')\n\t\t\tif !hasFence {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ti = fenceEnd\n\t\t\tdotAt, hasDot := skipUntilIfExists(variable, fenceStart, '.')\n\t\t\tif !hasDot {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tswitch string(variable[fenceStart+1 : dotAt]) {\n\t\t\tcase \"me\":\n\t\t\t\tw.Write(variable[prevIndex:fenceStart])\n\t\t\t\tswitch string(variable[dotAt+1 : fenceEnd]) {\n\t\t\t\tcase \"Link\":\n\t\t\t\t\tw.Write([]byte(user.Link))\n\t\t\t\tcase \"Session\":\n\t\t\t\t\tw.Write([]byte(user.Session))\n\t\t\t\t}\n\t\t\t\tprevIndex = fenceEnd\n\t\t\t// TODO: Optimise this\n\t\t\tcase \"dyn\":\n\t\t\t\tw.Write(variable[prevIndex:fenceStart])\n\t\t\t\tvar pmi int\n\t\t\t\tfor ii, pathItem := range mTmpl.PathMappings {\n\t\t\t\t\tpmi = ii\n\t\t\t\t\tif pathItem.Index > index {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif len(mTmpl.PathMappings) != 0 {\n\t\t\t\t\tpath := mTmpl.PathMappings[pmi].Path\n\t\t\t\t\tif path == \"\" || path == \"/\" {\n\t\t\t\t\t\tpath = Config.DefaultPath\n\t\t\t\t\t}\n\t\t\t\t\tif strings.HasPrefix(path, pathPrefix) {\n\t\t\t\t\t\tw.Write([]byte(\" menu_active\"))\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tprevIndex = fenceEnd\n\t\t\t}\n\t\t}\n\n\t\tw.Write(variable[prevIndex : len(variable)-1])\n\t\tif len(mTmpl.VariableIndices) > (nearIndex + 1) {\n\t\t\tnearIndex++\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/meta/meta_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// MetaStore is a simple key-value store for the system to stash things in when needed\ntype MetaStore interface {\n\tGet(name string) (val string, err error)\n\tSet(name, val string) error\n\tSetInt(name string, val int) error\n\tSetInt64(name string, val int64) error\n}\n\ntype DefaultMetaStore struct {\n\tget *sql.Stmt\n\tset *sql.Stmt\n\tadd *sql.Stmt\n}\n\nfunc NewDefaultMetaStore(acc *qgen.Accumulator) (*DefaultMetaStore, error) {\n\tt := \"meta\"\n\tm := &DefaultMetaStore{\n\t\tget: acc.Select(t).Columns(\"value\").Where(\"name=?\").Prepare(),\n\t\tset: acc.Update(t).Set(\"value=?\").Where(\"name=?\").Prepare(),\n\t\tadd: acc.Insert(t).Columns(\"name,value\").Fields(\"?,''\").Prepare(),\n\t}\n\treturn m, acc.FirstError()\n}\n\nfunc (s *DefaultMetaStore) Get(name string) (val string, e error) {\n\te = s.get.QueryRow(name).Scan(&val)\n\treturn val, e\n}\n\n// TODO: Use timestamped rows as a more robust method of ensuring data integrity\nfunc (s *DefaultMetaStore) setVal(name string, val interface{}) error {\n\t_, e := s.Get(name)\n\tif e == sql.ErrNoRows {\n\t\t_, e := s.add.Exec(name)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\t_, e = s.set.Exec(val, name)\n\treturn e\n}\n\nfunc (s *DefaultMetaStore) Set(name, val string) error {\n\treturn s.setVal(name, val)\n}\n\nfunc (s *DefaultMetaStore) SetInt(name string, val int) error {\n\treturn s.setVal(name, val)\n}\n\nfunc (s *DefaultMetaStore) SetInt64(name string, val int64) error {\n\treturn s.setVal(name, val)\n}\n"
  },
  {
    "path": "common/mfa_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strings\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar MFAstore MFAStore\nvar ErrMFAScratchIndexOutOfBounds = errors.New(\"That MFA scratch index is out of bounds\")\n\ntype MFAItemStmts struct {\n\tupdate *sql.Stmt\n\tdelete *sql.Stmt\n}\n\nvar mfaItemStmts MFAItemStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tmfaItemStmts = MFAItemStmts{\n\t\t\tupdate: acc.Update(\"users_2fa_keys\").Set(\"scratch1=?,scratch2=?,scratch3=?,scratch4=?,scratch5=?,scratch6=?,scratch7=?,scratch8=?\").Where(\"uid=?\").Prepare(),\n\t\t\tdelete: acc.Delete(\"users_2fa_keys\").Where(\"uid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\ntype MFAItem struct {\n\tUID     int\n\tSecret  string\n\tScratch []string\n}\n\nfunc (i *MFAItem) BurnScratch(index int) error {\n\tif index < 0 || len(i.Scratch) <= index {\n\t\treturn ErrMFAScratchIndexOutOfBounds\n\t}\n\tnewScratch, err := mfaCreateScratch()\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.Scratch[index] = newScratch\n\n\t_, err = mfaItemStmts.update.Exec(i.Scratch[0], i.Scratch[1], i.Scratch[2], i.Scratch[3], i.Scratch[4], i.Scratch[5], i.Scratch[6], i.Scratch[7], i.UID)\n\treturn err\n}\n\nfunc (i *MFAItem) Delete() error {\n\t_, err := mfaItemStmts.delete.Exec(i.UID)\n\treturn err\n}\n\nfunc mfaCreateScratch() (string, error) {\n\tcode, err := GenerateStd32SafeString(8)\n\treturn strings.Replace(code, \"=\", \"\", -1), err\n}\n\ntype MFAStore interface {\n\tGet(id int) (*MFAItem, error)\n\tCreate(secret string, uid int) (err error)\n}\n\ntype SQLMFAStore struct {\n\tget    *sql.Stmt\n\tcreate *sql.Stmt\n}\n\nfunc NewSQLMFAStore(acc *qgen.Accumulator) (*SQLMFAStore, error) {\n\treturn &SQLMFAStore{\n\t\tget:    acc.Select(\"users_2fa_keys\").Columns(\"secret,scratch1,scratch2,scratch3,scratch4,scratch5,scratch6,scratch7,scratch8\").Where(\"uid=?\").Prepare(),\n\t\tcreate: acc.Insert(\"users_2fa_keys\").Columns(\"uid,secret,scratch1,scratch2,scratch3,scratch4,scratch5,scratch6,scratch7,scratch8,createdAt\").Fields(\"?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// TODO: Write a test for this\nfunc (s *SQLMFAStore) Get(id int) (*MFAItem, error) {\n\ti := MFAItem{UID: id, Scratch: make([]string, 8)}\n\terr := s.get.QueryRow(id).Scan(&i.Secret, &i.Scratch[0], &i.Scratch[1], &i.Scratch[2], &i.Scratch[3], &i.Scratch[4], &i.Scratch[5], &i.Scratch[6], &i.Scratch[7])\n\treturn &i, err\n\n}\n\n// TODO: Write a test for this\nfunc (s *SQLMFAStore) Create(secret string, uid int) (err error) {\n\tparams := make([]interface{}, 10)\n\tparams[0] = uid\n\tparams[1] = secret\n\tfor i := 2; i < len(params); i++ {\n\t\tcode, err := mfaCreateScratch()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tparams[i] = code\n\t}\n\n\t_, err = s.create.Exec(params...)\n\treturn err\n}\n"
  },
  {
    "path": "common/misc_logs.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar RegLogs RegLogStore\nvar LoginLogs LoginLogStore\n\ntype RegLogItem struct {\n\tID            int\n\tUsername      string\n\tEmail         string\n\tFailureReason string\n\tSuccess       bool\n\tIP            string\n\tDoneAt        string\n}\n\ntype RegLogStmts struct {\n\tupdate *sql.Stmt\n\tcreate *sql.Stmt\n}\n\nvar regLogStmts RegLogStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\trl := \"registration_logs\"\n\t\tregLogStmts = RegLogStmts{\n\t\t\tupdate: acc.Update(rl).Set(\"username=?,email=?,failureReason=?,success=?,doneAt=?\").Where(\"rlid=?\").Prepare(),\n\t\t\tcreate: acc.Insert(rl).Columns(\"username,email,failureReason,success,ipaddress,doneAt\").Fields(\"?,?,?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// TODO: Reload this item in the store, probably doesn't matter right now, but it might when we start caching this stuff in memory\n// ! Retroactive updates of date are not permitted for integrity reasons\n// TODO: Do we even use this anymore or can we just make the logs immutable (except for deletes) for simplicity sake?\nfunc (l *RegLogItem) Commit() error {\n\t_, e := regLogStmts.update.Exec(l.Username, l.Email, l.FailureReason, l.Success, l.DoneAt, l.ID)\n\treturn e\n}\n\nfunc (l *RegLogItem) Create() (id int, e error) {\n\tid, e = Createf(regLogStmts.create, l.Username, l.Email, l.FailureReason, l.Success, l.IP)\n\tl.ID = id\n\treturn l.ID, e\n}\n\ntype RegLogStore interface {\n\tCount() (count int)\n\tGetOffset(offset, perPage int) (logs []RegLogItem, err error)\n\tPurge() error\n\n\tDeleteOlderThanDays(days int) error\n}\n\ntype SQLRegLogStore struct {\n\tcount     *sql.Stmt\n\tgetOffset *sql.Stmt\n\tpurge     *sql.Stmt\n\n\tdeleteOlderThanDays *sql.Stmt\n}\n\nfunc NewRegLogStore(acc *qgen.Accumulator) (*SQLRegLogStore, error) {\n\trl := \"registration_logs\"\n\treturn &SQLRegLogStore{\n\t\tcount:     acc.Count(rl).Prepare(),\n\t\tgetOffset: acc.Select(rl).Columns(\"rlid,username,email,failureReason,success,ipaddress,doneAt\").Orderby(\"doneAt DESC\").Limit(\"?,?\").Prepare(),\n\t\tpurge:     acc.Purge(rl),\n\n\t\tdeleteOlderThanDays: acc.Delete(rl).DateOlderThanQ(\"doneAt\", \"day\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLRegLogStore) Count() (count int) {\n\treturn Count(s.count)\n}\n\nfunc (s *SQLRegLogStore) GetOffset(offset, perPage int) (logs []RegLogItem, e error) {\n\trows, e := s.getOffset.Query(offset, perPage)\n\tif e != nil {\n\t\treturn logs, e\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar l RegLogItem\n\t\tvar doneAt time.Time\n\t\te := rows.Scan(&l.ID, &l.Username, &l.Email, &l.FailureReason, &l.Success, &l.IP, &doneAt)\n\t\tif e != nil {\n\t\t\treturn logs, e\n\t\t}\n\t\tl.DoneAt = doneAt.Format(\"2006-01-02 15:04:05\")\n\t\tlogs = append(logs, l)\n\t}\n\treturn logs, rows.Err()\n}\n\nfunc (s *SQLRegLogStore) DeleteOlderThanDays(days int) error {\n\t_, e := s.deleteOlderThanDays.Exec(days)\n\treturn e\n}\n\n// Delete all registration logs\nfunc (s *SQLRegLogStore) Purge() error {\n\t_, e := s.purge.Exec()\n\treturn e\n}\n\ntype LoginLogItem struct {\n\tID      int\n\tUID     int\n\tSuccess bool\n\tIP      string\n\tDoneAt  string\n}\n\ntype LoginLogStmts struct {\n\tupdate *sql.Stmt\n\tcreate *sql.Stmt\n}\n\nvar loginLogStmts LoginLogStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tll := \"login_logs\"\n\t\tloginLogStmts = LoginLogStmts{\n\t\t\tupdate: acc.Update(ll).Set(\"uid=?,success=?,doneAt=?\").Where(\"lid=?\").Prepare(),\n\t\t\tcreate: acc.Insert(ll).Columns(\"uid,success,ipaddress,doneAt\").Fields(\"?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// TODO: Reload this item in the store, probably doesn't matter right now, but it might when we start caching this stuff in memory\n// ! Retroactive updates of date are not permitted for integrity reasons\nfunc (l *LoginLogItem) Commit() error {\n\t_, e := loginLogStmts.update.Exec(l.UID, l.Success, l.DoneAt, l.ID)\n\treturn e\n}\n\nfunc (l *LoginLogItem) Create() (id int, e error) {\n\tres, e := loginLogStmts.create.Exec(l.UID, l.Success, l.IP)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tid64, e := res.LastInsertId()\n\tl.ID = int(id64)\n\treturn l.ID, e\n}\n\ntype LoginLogStore interface {\n\tCount() (count int)\n\tCountUser(uid int) (count int)\n\tGetOffset(uid, offset, perPage int) (logs []LoginLogItem, err error)\n\tPurge() error\n\n\tDeleteOlderThanDays(days int) error\n}\n\ntype SQLLoginLogStore struct {\n\tcount           *sql.Stmt\n\tcountForUser    *sql.Stmt\n\tgetOffsetByUser *sql.Stmt\n\tpurge           *sql.Stmt\n\n\tdeleteOlderThanDays *sql.Stmt\n}\n\nfunc NewLoginLogStore(acc *qgen.Accumulator) (*SQLLoginLogStore, error) {\n\tll := \"login_logs\"\n\treturn &SQLLoginLogStore{\n\t\tcount:           acc.Count(ll).Prepare(),\n\t\tcountForUser:    acc.Count(ll).Where(\"uid=?\").Prepare(),\n\t\tgetOffsetByUser: acc.Select(ll).Columns(\"lid,success,ipaddress,doneAt\").Where(\"uid=?\").Orderby(\"doneAt DESC\").Limit(\"?,?\").Prepare(),\n\t\tpurge:           acc.Purge(ll),\n\n\t\tdeleteOlderThanDays: acc.Delete(ll).DateOlderThanQ(\"doneAt\", \"day\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLLoginLogStore) Count() (count int) {\n\treturn Count(s.count)\n}\n\nfunc (s *SQLLoginLogStore) CountUser(uid int) (count int) {\n\treturn Countf(s.countForUser, uid)\n}\n\nfunc (s *SQLLoginLogStore) GetOffset(uid, offset, perPage int) (logs []LoginLogItem, e error) {\n\trows, e := s.getOffsetByUser.Query(uid, offset, perPage)\n\tif e != nil {\n\t\treturn logs, e\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tl := LoginLogItem{UID: uid}\n\t\tvar doneAt time.Time\n\t\te := rows.Scan(&l.ID, &l.Success, &l.IP, &doneAt)\n\t\tif e != nil {\n\t\t\treturn logs, e\n\t\t}\n\t\tl.DoneAt = doneAt.Format(\"2006-01-02 15:04:05\")\n\t\tlogs = append(logs, l)\n\t}\n\treturn logs, rows.Err()\n}\n\nfunc (s *SQLLoginLogStore) DeleteOlderThanDays(days int) error {\n\t_, e := s.deleteOlderThanDays.Exec(days)\n\treturn e\n}\n\n// Delete all login logs\nfunc (s *SQLLoginLogStore) Purge() error {\n\t_, e := s.purge.Exec()\n\treturn e\n}\n"
  },
  {
    "path": "common/module_ottojs.go",
    "content": "/*\n*\n*\tOttoJS Plugin Module\n*\tCopyright Azareal 2016 - 2019\n*\n */\npackage common\n\nimport (\n\t\"errors\"\n\n\t\"github.com/robertkrimen/otto\"\n)\n\ntype OttoPluginLang struct {\n\tvm      *otto.Otto\n\tplugins map[string]*otto.Script\n\tvars    map[string]*otto.Object\n}\n\nfunc init() {\n\tpluginLangs[\"ottojs\"] = &OttoPluginLang{\n\t\tplugins: make(map[string]*otto.Script),\n\t\tvars:    make(map[string]*otto.Object),\n\t}\n}\n\nfunc (js *OttoPluginLang) Init() (err error) {\n\tjs.vm = otto.New()\n\tjs.vars[\"current_page\"], err = js.vm.Object(`var current_page = {}`)\n\treturn err\n}\n\nfunc (js *OttoPluginLang) GetName() string {\n\treturn \"ottojs\"\n}\n\nfunc (js *OttoPluginLang) GetExts() []string {\n\treturn []string{\".js\"}\n}\n\nfunc (js *OttoPluginLang) AddPlugin(meta PluginMeta) (plugin *Plugin, err error) {\n\tscript, err := js.vm.Compile(\"./extend/\"+meta.UName+\"/\"+meta.Main, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pluginInit = func(plugin *Plugin) error {\n\t\tretValue, err := js.vm.Run(script)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif retValue.IsString() {\n\t\t\tret, err := retValue.ToString()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif ret != \"\" {\n\t\t\t\treturn errors.New(ret)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tplugin = new(Plugin)\n\tplugin.UName = meta.UName\n\tplugin.Name = meta.Name\n\tplugin.Author = meta.Author\n\tplugin.URL = meta.URL\n\tplugin.Settings = meta.Settings\n\tplugin.Tag = meta.Tag\n\tplugin.Type = \"ottojs\"\n\tplugin.Init = pluginInit\n\n\t// TODO: Implement plugin life cycle events\n\n\tbuildPlugin(plugin)\n\n\tplugin.Data = script\n\treturn plugin, nil\n}\n\n/*func (js *OttoPluginLang) addHook(hook string, plugin string) {\n\thooks[hook] = func(data interface{}) interface{} {\n\t\tswitch d := data.(type) {\n\t\tcase Page:\n\t\t\tcurrentPage := js.vars[\"current_page\"]\n\t\t\tcurrentPage.Set(\"Title\", d.Title)\n\t\tcase TopicPage:\n\n\t\tcase ProfilePage:\n\n\t\tcase Reply:\n\n\t\tdefault:\n\t\t\tlog.Print(\"Not a valid JS datatype\")\n\t\t}\n\t}\n}*/\n"
  },
  {
    "path": "common/no_websockets.go",
    "content": "// +build no_ws\n\npackage common\n\nimport \"errors\"\nimport \"net/http\"\n\n// TODO: Disable WebSockets on high load? Add a Control Panel interface for disabling it?\nvar EnableWebsockets = false // Put this in caps for consistency with the other constants?\n\nvar wsHub WSHub\nvar errWsNouser = errors.New(\"This user isn't connected via WebSockets\")\n\ntype WSHub struct{}\n\nfunc (_ *WSHub) guestCount() int { return 0 }\n\nfunc (_ *WSHub) userCount() int { return 0 }\n\nfunc (hub *WSHub) broadcastMessage(_ string) error { return nil }\n\nfunc (hub *WSHub) pushMessage(_ int, _ string) error {\n\treturn errWsNouser\n}\n\nfunc (hub *WSHub) pushAlert(_ int, _ int, _ string, _ string, _ int, _ int, _ int) error {\n\treturn errWsNouser\n}\n\nfunc (hub *WSHub) pushAlerts(_ []int, _ int, _ string, _ string, _ int, _ int, _ int) error {\n\treturn errWsNouser\n}\n\nfunc RouteWebsockets(_ http.ResponseWriter, _ *http.Request, _ User) {}\n"
  },
  {
    "path": "common/null_reply_cache.go",
    "content": "package common\n\n// NullReplyCache is a reply cache to be used when you don't want a cache and just want queries to passthrough to the database\ntype NullReplyCache struct {\n}\n\n// NewNullReplyCache gives you a new instance of NullReplyCache\nfunc NewNullReplyCache() *NullReplyCache {\n\treturn &NullReplyCache{}\n}\n\n// nolint\nfunc (c *NullReplyCache) Get(id int) (*Reply, error) {\n\treturn nil, ErrNoRows\n}\nfunc (c *NullReplyCache) GetUnsafe(id int) (*Reply, error) {\n\treturn nil, ErrNoRows\n}\nfunc (c *NullReplyCache) BulkGet(ids []int) (list []*Reply) {\n\treturn make([]*Reply, len(ids))\n}\nfunc (c *NullReplyCache) Set(_ *Reply) error {\n\treturn nil\n}\nfunc (c *NullReplyCache) Add(_ *Reply) error {\n\treturn nil\n}\nfunc (c *NullReplyCache) AddUnsafe(_ *Reply) error {\n\treturn nil\n}\nfunc (c *NullReplyCache) Remove(id int) error {\n\treturn nil\n}\nfunc (c *NullReplyCache) RemoveUnsafe(id int) error {\n\treturn nil\n}\nfunc (c *NullReplyCache) Flush() {\n}\nfunc (c *NullReplyCache) Length() int {\n\treturn 0\n}\nfunc (c *NullReplyCache) SetCapacity(_ int) {\n}\nfunc (c *NullReplyCache) GetCapacity() int {\n\treturn 0\n}\n"
  },
  {
    "path": "common/null_topic_cache.go",
    "content": "package common\n\n// NullTopicCache is a topic cache to be used when you don't want a cache and just want queries to passthrough to the database\ntype NullTopicCache struct {\n}\n\n// NewNullTopicCache gives you a new instance of NullTopicCache\nfunc NewNullTopicCache() *NullTopicCache {\n\treturn &NullTopicCache{}\n}\n\n// nolint\nfunc (c *NullTopicCache) Get(id int) (*Topic, error) {\n\treturn nil, ErrNoRows\n}\nfunc (c *NullTopicCache) GetUnsafe(id int) (*Topic, error) {\n\treturn nil, ErrNoRows\n}\nfunc (c *NullTopicCache) BulkGet(ids []int) (list []*Topic) {\n\treturn make([]*Topic, len(ids))\n}\nfunc (c *NullTopicCache) Set(_ *Topic) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) Add(_ *Topic) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) AddUnsafe(_ *Topic) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) Remove(id int) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) RemoveMany(ids []int) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) RemoveUnsafe(id int) error {\n\treturn nil\n}\nfunc (c *NullTopicCache) Flush() {\n}\nfunc (c *NullTopicCache) Length() int {\n\treturn 0\n}\nfunc (c *NullTopicCache) SetCapacity(_ int) {\n}\nfunc (c *NullTopicCache) GetCapacity() int {\n\treturn 0\n}\n"
  },
  {
    "path": "common/null_user_cache.go",
    "content": "package common\n\n// NullUserCache is a user cache to be used when you don't want a cache and just want queries to passthrough to the database\ntype NullUserCache struct {\n}\n\n// NewNullUserCache gives you a new instance of NullUserCache\nfunc NewNullUserCache() *NullUserCache {\n\treturn &NullUserCache{}\n}\n\n// nolint\nfunc (c *NullUserCache) DeallocOverflow(evictPriority bool) (evicted int) {\n\treturn 0\n}\nfunc (c *NullUserCache) Get(id int) (*User, error) {\n\treturn nil, ErrNoRows\n}\n\nfunc (c *NullUserCache) Getn(id int) *User {\n\treturn nil\n}\nfunc (c *NullUserCache) BulkGet(ids []int) (list []*User) {\n\treturn make([]*User, len(ids))\n}\nfunc (c *NullUserCache) GetUnsafe(id int) (*User, error) {\n\treturn nil, ErrNoRows\n}\nfunc (c *NullUserCache) Set(_ *User) error {\n\treturn nil\n}\nfunc (c *NullUserCache) Add(_ *User) error {\n\treturn nil\n}\nfunc (c *NullUserCache) AddUnsafe(_ *User) error {\n\treturn nil\n}\nfunc (c *NullUserCache) Remove(id int) error {\n\treturn nil\n}\nfunc (c *NullUserCache) RemoveUnsafe(id int) error {\n\treturn nil\n}\nfunc (c *NullUserCache) BulkRemove(ids []int) {}\nfunc (c *NullUserCache) Flush() {\n}\nfunc (c *NullUserCache) Length() int {\n\treturn 0\n}\nfunc (c *NullUserCache) SetCapacity(_ int) {\n}\nfunc (c *NullUserCache) GetCapacity() int {\n\treturn 0\n}\n"
  },
  {
    "path": "common/page_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"strconv\"\n\t\"strings\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype CustomPageStmts struct {\n\tupdate *sql.Stmt\n\tcreate *sql.Stmt\n}\n\nvar customPageStmts CustomPageStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tcustomPageStmts = CustomPageStmts{\n\t\t\tupdate: acc.Update(\"pages\").Set(\"name=?,title=?,body=?,allowedGroups=?,menuID=?\").Where(\"pid=?\").Prepare(),\n\t\t\tcreate: acc.Insert(\"pages\").Columns(\"name,title,body,allowedGroups,menuID\").Fields(\"?,?,?,?,?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\ntype CustomPage struct {\n\tID            int\n\tName          string // TODO: Let admins put pages in \"virtual subdirectories\"\n\tTitle         string\n\tBody          string\n\tAllowedGroups []int\n\tMenuID        int\n}\n\nfunc BlankCustomPage() *CustomPage {\n\treturn new(CustomPage)\n}\n\nfunc (p *CustomPage) AddAllowedGroup(gid int) {\n\tp.AllowedGroups = append(p.AllowedGroups, gid)\n}\n\nfunc (p *CustomPage) getRawAllowedGroups() (rawAllowedGroups string) {\n\tfor _, group := range p.AllowedGroups {\n\t\trawAllowedGroups += strconv.Itoa(group) + \",\"\n\t}\n\tif len(rawAllowedGroups) > 0 {\n\t\trawAllowedGroups = rawAllowedGroups[:len(rawAllowedGroups)-1]\n\t}\n\treturn rawAllowedGroups\n}\n\nfunc (p *CustomPage) Commit() error {\n\t_, err := customPageStmts.update.Exec(p.Name, p.Title, p.Body, p.getRawAllowedGroups(), p.MenuID, p.ID)\n\tPages.Reload(p.ID)\n\treturn err\n}\n\nfunc (p *CustomPage) Create() (int, error) {\n\tres, err := customPageStmts.create.Exec(p.Name, p.Title, p.Body, p.getRawAllowedGroups(), p.MenuID)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tpid64, err := res.LastInsertId()\n\treturn int(pid64), err\n}\n\nvar Pages PageStore\n\n// Holds the custom pages, but doesn't include the template pages in /pages/ which are a lot more flexible yet harder to use and which are too risky security-wise to make editable in the Control Panel\ntype PageStore interface {\n\tCount() (count int)\n\tGet(id int) (*CustomPage, error)\n\tGetByName(name string) (*CustomPage, error)\n\tGetOffset(offset, perPage int) (pages []*CustomPage, err error)\n\tReload(id int) error\n\tDelete(id int) error\n}\n\n// TODO: Add a cache to this to save on the queries\ntype DefaultPageStore struct {\n\tget       *sql.Stmt\n\tgetByName *sql.Stmt\n\tgetOffset *sql.Stmt\n\tcount     *sql.Stmt\n\tdelete    *sql.Stmt\n}\n\nfunc NewDefaultPageStore(acc *qgen.Accumulator) (*DefaultPageStore, error) {\n\tpa := \"pages\"\n\tallCols := \"pid, name, title, body, allowedGroups, menuID\"\n\treturn &DefaultPageStore{\n\t\tget:       acc.Select(pa).Columns(\"name, title, body, allowedGroups, menuID\").Where(\"pid=?\").Prepare(),\n\t\tgetByName: acc.Select(pa).Columns(allCols).Where(\"name=?\").Prepare(),\n\t\tgetOffset: acc.Select(pa).Columns(allCols).Orderby(\"pid DESC\").Limit(\"?,?\").Prepare(),\n\t\tcount:     acc.Count(pa).Prepare(),\n\t\tdelete:    acc.Delete(pa).Where(\"pid=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultPageStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n\nfunc (s *DefaultPageStore) parseAllowedGroups(raw string, page *CustomPage) error {\n\tif raw == \"\" {\n\t\treturn nil\n\t}\n\tfor _, sgroup := range strings.Split(raw, \",\") {\n\t\tgroup, err := strconv.Atoi(sgroup)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpage.AddAllowedGroup(group)\n\t}\n\treturn nil\n}\n\nfunc (s *DefaultPageStore) Get(id int) (*CustomPage, error) {\n\tp := &CustomPage{ID: id}\n\trawAllowedGroups := \"\"\n\terr := s.get.QueryRow(id).Scan(&p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn p, s.parseAllowedGroups(rawAllowedGroups, p)\n}\n\nfunc (s *DefaultPageStore) GetByName(name string) (*CustomPage, error) {\n\tp := BlankCustomPage()\n\trawAllowedGroups := \"\"\n\terr := s.getByName.QueryRow(name).Scan(&p.ID, &p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn p, s.parseAllowedGroups(rawAllowedGroups, p)\n}\n\nfunc (s *DefaultPageStore) GetOffset(offset, perPage int) (pages []*CustomPage, err error) {\n\trows, err := s.getOffset.Query(offset, perPage)\n\tif err != nil {\n\t\treturn pages, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tp := &CustomPage{ID: 0}\n\t\trawAllowedGroups := \"\"\n\t\terr := rows.Scan(&p.ID, &p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID)\n\t\tif err != nil {\n\t\t\treturn pages, err\n\t\t}\n\t\terr = s.parseAllowedGroups(rawAllowedGroups, p)\n\t\tif err != nil {\n\t\t\treturn pages, err\n\t\t}\n\t\tpages = append(pages, p)\n\t}\n\treturn pages, rows.Err()\n}\n\n// Always returns nil as there's currently no cache\nfunc (s *DefaultPageStore) Reload(id int) error {\n\treturn nil\n}\n\nfunc (s *DefaultPageStore) Delete(id int) error {\n\t_, err := s.delete.Exec(id)\n\treturn err\n}\n"
  },
  {
    "path": "common/pages.go",
    "content": "package common\n\nimport (\n\t\"html/template\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"sync\"\n\t\"time\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\n/*type HResource struct {\n\tName string\n\tHash string\n}*/\n\n// TODO: Allow resources in spots other than /s/ and possibly even external domains (e.g. CDNs)\n// TODO: Preload Trumboyg on Cosora on the forum list\ntype Header struct {\n\tTitle string\n\t//Title      []byte // Experimenting with []byte for increased efficiency, let's avoid converting too many things to []byte, as it involves a lot of extra boilerplate\n\tNoticeList      []string\n\tScripts         []HScript\n\tPreScriptsAsync []HScript\n\tScriptsAsync    []HScript\n\t//Preload []string\n\tStylesheets []HScript\n\tWidgets     PageWidgets\n\tSite        *site\n\tSettings    SettingMap\n\t//Themes      map[string]*Theme // TODO: Use a slice containing every theme instead of the main map for speed?\n\tThemesSlice []*Theme\n\tTheme       *Theme\n\t//TemplateName string // TODO: Use this to move template calls to the router rather than duplicating them over and over and over?\n\tCurrentUser *User // TODO: Deprecate CurrentUser on the page structs and use a pointer here\n\tHooks       *HookTable\n\tZone        string\n\tZoneID      int\n\tZoneData    interface{}\n\tPath        string\n\tMetaDesc    string\n\t//OGImage string\n\tOGDesc         string\n\tGoogSiteVerify string\n\tIsoCode        string\n\tLooseCSP       bool\n\tExternalMedia  bool\n\t//StartedAt      time.Time\n\tStartedAt int64\n\tElapsed1  string\n\tWriter    http.ResponseWriter\n\tExtData   ExtData\n}\n\ntype HScript struct {\n\tName string\n\tHash string\n}\n\nfunc (h *Header) getScript(name string) HScript {\n\tif name[0] == '/' && name[1] == '/' {\n\t} else {\n\t\tfile, ok := StaticFiles.GetShort(name)\n\t\tif ok {\n\t\t\treturn HScript{file.OName, file.Sha256I}\n\t\t}\n\t}\n\treturn HScript{name, \"\"}\n}\n\nfunc (h *Header) AddScript(name string) {\n\t//log.Print(\"name:\", name)\n\th.Scripts = append(h.Scripts, h.getScript(name))\n}\n\nfunc (h *Header) AddPreScriptAsync(name string) {\n\th.PreScriptsAsync = append(h.PreScriptsAsync, h.getScript(name))\n}\n\nfunc (h *Header) AddScriptAsync(name string) {\n\th.ScriptsAsync = append(h.ScriptsAsync, h.getScript(name))\n}\n\n/*func (h *Header) Preload(name string) {\n\th.Preload = append(h.Preload, name)\n}*/\n\nfunc (h *Header) AddSheet(name string) {\n\th.Stylesheets = append(h.Stylesheets, h.getScript(name))\n}\n\n// ! Experimental\nfunc (h *Header) AddXRes(names ...string) {\n\tvar o string\n\tfor i, name := range names {\n\t\tif name[0] == '/' && name[1] == '/' {\n\t\t} else {\n\t\t\tfile, ok := StaticFiles.GetShort(name)\n\t\t\tif ok {\n\t\t\t\tname = file.OName\n\t\t\t}\n\t\t}\n\t\tif i != 0 {\n\t\t\to += \",\" + name\n\t\t} else {\n\t\t\to += name\n\t\t}\n\t}\n\th.Writer.Header().Set(\"X-Res\", o)\n}\n\nfunc (h *Header) AddNotice(name string) {\n\th.NoticeList = append(h.NoticeList, p.GetNoticePhrase(name))\n}\n\n// TODO: Add this to routes which don't use templates. E.g. Json APIs.\ntype HeaderLite struct {\n\tSite     *site\n\tSettings SettingMap\n\tHooks    *HookTable\n\tExtData  ExtData\n}\n\ntype PageWidgets struct {\n\tLeftSidebar  template.HTML\n\tRightSidebar template.HTML\n}\n\n// TODO: Add a ExtDataHolder interface with methods for manipulating the contents?\n// ? - Could we use a sync.Map instead?\ntype ExtData struct {\n\tItems map[string]interface{} // Key: pluginname\n\tsync.RWMutex\n}\n\ntype Page struct {\n\t*Header\n\tItemList  []interface{}\n\tSomething interface{}\n}\n\ntype SimplePage struct {\n\t*Header\n}\n\ntype ErrorPage struct {\n\t*Header\n\tMessage string\n}\n\ntype Paginator struct {\n\tPageList []int\n\tPage     int\n\tLastPage int\n}\n\ntype PaginatorMod struct {\n\tParams   template.URL\n\tPageList []int\n\tPage     int\n\tLastPage int\n}\n\ntype CustomPagePage struct {\n\t*Header\n\tPage *CustomPage\n}\n\ntype TopicCEditPost struct {\n\tID     int\n\tSource string\n\tRef    string\n}\ntype TopicCAttachItem struct {\n\tID       int\n\tImgSrc   string\n\tPath     string\n\tFullPath string\n}\ntype TopicCPollInput struct {\n\tIndex int\n\tPlace string\n}\n\ntype TopicPage struct {\n\t*Header\n\tItemList []*ReplyUser\n\tTopic    TopicUser\n\tForum    *Forum\n\tPoll     *Poll\n\tPaginator\n}\n\ntype TopicListSort struct {\n\tSortBy    string // lastupdate, mostviewed, mostviewedtoday, mostviewedthisweek, mostviewedthismonth\n\tAscending bool\n}\n\ntype QuickTools struct {\n\tCanDelete bool\n\tCanLock   bool\n\tCanMove   bool\n}\n\ntype TopicListPage struct {\n\t*Header\n\tTopicList    []TopicsRowMut\n\tForumList    []Forum\n\tDefaultForum int\n\tSort         TopicListSort\n\tSelectedFids []int\n\tQuickTools\n\tPaginator\n}\n\ntype ForumPage struct {\n\t*Header\n\tItemList []TopicsRowMut\n\tForum    *Forum\n\tCanLock  bool\n\tCanMove  bool\n\tPaginator\n}\n\ntype ForumsPage struct {\n\t*Header\n\tItemList []Forum\n}\n\ntype ProfilePage struct {\n\t*Header\n\tItemList     []*ReplyUser\n\tProfileOwner User\n\tCurrentScore int\n\tNextScore    int\n\tBlocked      bool\n\tCanMessage   bool\n\tCanComment   bool\n\tShowComments bool\n}\n\ntype CreateTopicPage struct {\n\t*Header\n\tItemList []Forum\n\tFID      int\n}\n\ntype IPSearchPage struct {\n\t*Header\n\tItemList map[int]*User\n\tIP       string\n}\n\n// WIP: Optional anti-bot methods\ntype RegisterVerifyImageGridImage struct {\n\tSrc string\n}\ntype RegisterVerifyImageGrid struct {\n\tQuestion string\n\tItems    []RegisterVerifyImageGridImage\n}\ntype RegisterVerify struct {\n\tNoScript bool\n\n\tImage *RegisterVerifyImageGrid\n}\n\ntype RegisterPage struct {\n\t*Header\n\tRequireEmail bool\n\tToken        string\n\tVerify       []RegisterVerify\n}\n\ntype Account struct {\n\t*Header\n\tHTMLID   string\n\tTmplName string\n\tInner    nobreak\n}\n\ntype EmailListPage struct {\n\t*Header\n\tItemList []Email\n}\n\ntype AccountLoginsPage struct {\n\t*Header\n\tItemList []LoginLogItem\n\tPaginator\n}\n\ntype AccountBlocksPage struct {\n\t*Header\n\tUsers []*User\n\tPaginator\n}\n\ntype AccountPrivacyPage struct {\n\t*Header\n\tProfileComments int\n\tReceiveConvos   int\n\tEnableEmbeds    bool\n}\n\ntype AccountDashPage struct {\n\t*Header\n\tMFASetup     bool\n\tCurrentScore int\n\tNextScore    int\n\tNextLevel    int\n\tPercentage   int\n}\n\ntype LevelListItem struct {\n\tLevel      int\n\tScore      int\n\tStatus     string\n\tPercentage int // 0 to 200 to fit with the CSS logic\n}\n\ntype LevelListPage struct {\n\t*Header\n\tLevels []LevelListItem\n}\n\ntype ResetPage struct {\n\t*Header\n\tUID   int\n\tToken string\n\tMFA   bool\n}\n\ntype ConvoListRow struct {\n\t*ConversationExtra\n\tShortUsers []*User\n\tOneOnOne   bool\n}\n\ntype ConvoListPage struct {\n\t*Header\n\tConvos []ConvoListRow\n\tPaginator\n}\n\ntype ConvoViewRow struct {\n\t*ConversationPost\n\tUser         *User\n\tClassName    string\n\tContentLines int\n\n\tCanModify bool\n}\n\ntype ConvoViewPage struct {\n\t*Header\n\tConvo    *Conversation\n\tPosts    []ConvoViewRow\n\tUsers    []*User\n\tCanReply bool\n\tPaginator\n}\n\ntype ConvoCreatePage struct {\n\t*Header\n\tRecpName string\n}\n\n/* WIP for dyntmpl */\ntype Panel struct {\n\t*BasePanelPage\n\tHTMLID     string\n\tClassNames string\n\tTmplName   string\n\tInner      nobreak\n}\ntype PanelAnalytics struct {\n\t*BasePanelPage\n\tFormAction string\n\tTmplName   string\n\tInner      nobreak\n}\ntype PanelAnalyticsStd struct {\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItem\n\tTimeRange string\n\tUnit      string\n\tTimeType  string\n}\ntype PanelAnalyticsStdUnit struct {\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItemUnit\n\tTimeRange string\n\tUnit      string\n\tTimeType  string\n}\ntype PanelAnalyticsActiveMemory struct {\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItemUnit\n\tTimeRange string\n\tUnit      string\n\tTimeType  string\n\tMemType   int\n}\ntype PanelAnalyticsPerf struct {\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItemUnit\n\tTimeRange string\n\tUnit      string\n\tTimeType  string\n\tPerfType  int\n}\n\ntype PanelStats struct {\n\tUsers       int\n\tGroups      int\n\tForums      int\n\tPages       int\n\tSettings    int\n\tWordFilters int\n\tThemes      int\n\tReports     int\n}\ntype BasePanelPage struct {\n\t*Header\n\tStats         PanelStats\n\tZone          string\n\tReportForumID int\n\tDebugAdmin    bool\n}\ntype PanelPage struct {\n\t*BasePanelPage\n\tItemList  []interface{}\n\tSomething interface{}\n}\n\ntype GridElement struct {\n\tID         string\n\tHref       string\n\tBody       string\n\tOrder      int // For future use\n\tClass      string\n\tBackground string\n\tTextColour string\n\tNote       string\n}\ntype DashGrids struct {\n\tGrid1 []GridElement\n\tGrid2 []GridElement\n}\ntype PanelDashboardPage struct {\n\t*BasePanelPage\n\tGrids DashGrids\n}\n\ntype PanelSetting struct {\n\t*Setting\n\tFriendlyName string\n}\ntype PanelSettingPage struct {\n\t*BasePanelPage\n\tItemList []OptionLabel\n\tSetting  *PanelSetting\n}\n\ntype PanelUserEditPage struct {\n\t*BasePanelPage\n\tGroups    []*Group\n\tUser      *User\n\tShowEmail bool\n}\n\ntype PanelCustomPagesPage struct {\n\t*BasePanelPage\n\tItemList []*CustomPage\n\tPaginator\n}\ntype PanelCustomPageEditPage struct {\n\t*BasePanelPage\n\tPage *CustomPage\n}\n\n/*type PanelTimeGraph struct {\n\tSeries []int64 // The counts on the left\n\tLabels []int64 // unixtimes for the bottom, gets converted into 1:00, 2:00, etc. with JS\n}*/\ntype PanelTimeGraph struct {\n\tSeries  [][]int64 // The counts on the left\n\tLabels  []int64   // unixtimes for the bottom, gets converted into 1:00, 2:00, etc. with JS\n\tLegends []string\n}\n\ntype PanelAnalyticsItem struct {\n\tTime  int64\n\tCount int64\n}\ntype PanelAnalyticsItemUnit struct {\n\tTime  int64\n\tCount int64\n\tUnit  string\n}\n\ntype PanelAnalyticsPage struct {\n\t*BasePanelPage\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItem\n\tTimeRange string\n\tUnit      string\n\tTimeType  string\n}\n\ntype PanelAnalyticsRoutesItem struct {\n\tRoute string\n\tCount int\n}\n\ntype PanelAnalyticsRoutesPage struct {\n\t*BasePanelPage\n\tItemList  []PanelAnalyticsRoutesItem\n\tGraph     PanelTimeGraph\n\tTimeRange string\n}\n\ntype PanelAnalyticsRoutesPerfItem struct {\n\tRoute string\n\tCount int\n\tUnit  string\n}\n\ntype PanelAnalyticsRoutesPerfPage struct {\n\t*BasePanelPage\n\tItemList  []PanelAnalyticsRoutesPerfItem\n\tGraph     PanelTimeGraph\n\tTimeRange string\n}\n\n// TODO: Rename the fields as this structure is being used in a generic way now\ntype PanelAnalyticsAgentsItem struct {\n\tAgent         string\n\tFriendlyAgent string\n\tCount         int\n}\n\ntype PanelAnalyticsAgentsPage struct {\n\t*BasePanelPage\n\tItemList  []PanelAnalyticsAgentsItem\n\tTimeRange string\n}\n\ntype PanelAnalyticsReferrersPage struct {\n\t*BasePanelPage\n\tItemList  []PanelAnalyticsAgentsItem\n\tTimeRange string\n\tShowSpam  bool\n}\n\ntype PanelAnalyticsRoutePage struct {\n\t*BasePanelPage\n\tRoute     string\n\tGraph     PanelTimeGraph\n\tViewItems []PanelAnalyticsItem\n\tTimeRange string\n}\n\ntype PanelAnalyticsAgentPage struct {\n\t*BasePanelPage\n\tAgent         string\n\tFriendlyAgent string\n\tGraph         PanelTimeGraph\n\tTimeRange     string\n}\n\ntype PanelAnalyticsDuoPage struct {\n\t*BasePanelPage\n\tItemList  []PanelAnalyticsAgentsItem\n\tGraph     PanelTimeGraph\n\tTimeRange string\n}\n\ntype PanelThemesPage struct {\n\t*BasePanelPage\n\tPrimaryThemes []*Theme\n\tVariantThemes []*Theme\n}\n\ntype PanelMenuListItem struct {\n\tName      string\n\tID        int\n\tItemCount int\n}\n\ntype PanelMenuListPage struct {\n\t*BasePanelPage\n\tItemList []PanelMenuListItem\n}\n\ntype PanelWidgetListPage struct {\n\t*BasePanelPage\n\tDocks       map[string][]WidgetEdit\n\tBlankWidget WidgetEdit\n}\n\ntype PanelMenuPage struct {\n\t*BasePanelPage\n\tMenuID   int\n\tItemList []MenuItem\n}\n\ntype PanelMenuItemPage struct {\n\t*BasePanelPage\n\tItem MenuItem\n}\n\ntype PanelUserPageSearch struct {\n\tName  string\n\tEmail string\n\tGroup int\n\n\tAny bool\n}\ntype PanelUserPage struct {\n\t*BasePanelPage\n\tItemList []*User\n\tGroups   []*Group\n\tSearch   PanelUserPageSearch\n\tPaginatorMod\n}\n\ntype PanelGroupPage struct {\n\t*BasePanelPage\n\tItemList []GroupAdmin\n\tPaginator\n}\n\ntype PanelEditGroupPage struct {\n\t*BasePanelPage\n\tID          int\n\tName        string\n\tTag         string\n\tRank        string\n\tDisableRank bool\n}\n\ntype GroupForumPermPreset struct {\n\tGroup         *Group\n\tPreset        string\n\tDefaultPreset bool\n}\n\ntype PanelEditForumPage struct {\n\t*BasePanelPage\n\tID      int\n\tName    string\n\tDesc    string\n\tActive  bool\n\tPreset  string\n\tGroups  []GroupForumPermPreset\n\tActions []*ForumActionAction\n}\n\ntype ForumActionAction struct {\n\t*ForumAction\n\tActionName string\n}\n\ntype NameLangToggle struct {\n\tName    string\n\tLangStr string\n\tToggle  bool\n}\n\ntype PanelEditForumGroupPage struct {\n\t*BasePanelPage\n\tForumID int\n\tGroupID int\n\tName    string\n\tDesc    string\n\tActive  bool\n\tPreset  string\n\tPerms   []NameLangToggle\n}\n\ntype PanelEditGroupPermsPage struct {\n\t*BasePanelPage\n\tID          int\n\tName        string\n\tLocalPerms  []NameLangToggle\n\tGlobalPerms []NameLangToggle\n\tModPerms    []NameLangToggle\n}\n\ntype GroupPromotionExtend struct {\n\t*GroupPromotion\n\tFromGroup *Group\n\tToGroup   *Group\n}\n\ntype PanelEditGroupPromotionsPage struct {\n\t*BasePanelPage\n\tID         int\n\tName       string\n\tPromotions []*GroupPromotionExtend\n\tGroups     []*Group\n}\n\ntype BackupItem struct {\n\tSQLURL string\n\n\t// TODO: Add an easier to parse format here for Gosora to be able to more easily reimport portions of the dump and to strip unnecessary data (e.g. table defs and parsed post data)\n\n\tTimestamp time.Time\n}\n\ntype PanelBackupPage struct {\n\t*BasePanelPage\n\tBackups []BackupItem\n}\n\ntype PageLogItem struct {\n\tAction template.HTML\n\tIP     string\n\tDoneAt string\n}\n\ntype PanelLogsPage struct {\n\t*BasePanelPage\n\tLogs []PageLogItem\n\tPaginator\n}\n\ntype PageRegLogItem struct {\n\tRegLogItem\n\tParsedReason string\n}\n\ntype PanelRegLogsPage struct {\n\t*BasePanelPage\n\tLogs []PageRegLogItem\n\tPaginator\n}\n\ntype DebugPageTasks struct {\n\tHalfSecond    int\n\tSecond        int\n\tFifteenMinute int\n\tHour          int\n\tDay           int\n\tShutdown      int\n}\n\ntype DebugPageCache struct {\n\tTopics  int\n\tUsers   int\n\tReplies int\n\n\tTCap int\n\tUCap int\n\tRCap int\n\n\tTopicListThaw bool\n}\n\ntype DebugPageDatabase struct {\n\tTopics         int\n\tUsers          int\n\tReplies        int\n\tProfileReplies int\n\tActivityStream int\n\tLikes          int\n\tAttachments    int\n\tPolls          int\n\n\tLoginLogs int\n\tRegLogs   int\n\tModLogs   int\n\tAdminLogs int\n\n\tViews          int\n\tViewsAgents    int\n\tViewsForums    int\n\tViewsLangs     int\n\tViewsReferrers int\n\tViewsSystems   int\n\tPostChunks     int\n\tTopicChunks    int\n}\n\ntype DebugPageDisk struct {\n\tStatic      int\n\tAttachments int\n\tAvatars     int\n\tLogs        int\n\tBackups     int\n\tGit         int\n}\n\ntype PanelDebugPage struct {\n\t*BasePanelPage\n\tGoVersion string\n\tDBVersion string\n\tUptime    string\n\n\tDBConns   int\n\tDBAdapter string\n\n\tGoroutines int\n\tCPUs       int\n\tHttpConns  int\n\n\tTasks    DebugPageTasks\n\tMemStats runtime.MemStats\n\tCache    DebugPageCache\n\tDatabase DebugPageDatabase\n\tDisk     DebugPageDisk\n}\n\ntype PanelTaskTask struct {\n\tName string\n\tType int // 0 = halfsec, 1 = sec, 2 = fifteenmin, 3 = hour, 4 = shutdown\n}\ntype PanelTaskType struct {\n\tName string\n\tFAvg string\n}\ntype PanelTaskPage struct {\n\t*BasePanelPage\n\tTasks []PanelTaskTask\n\tTypes []PanelTaskType\n}\n\ntype PageSimple struct {\n\tTitle     string\n\tSomething interface{}\n}\n\ntype AreYouSure struct {\n\tURL     string\n\tMessage string\n}\n\n// TODO: Write a test for this\nfunc DefaultHeader(w http.ResponseWriter, u *User) *Header {\n\treturn &Header{Site: Site, Theme: Themes[fallbackTheme], CurrentUser: u, Writer: w}\n}\nfunc SimpleDefaultHeader(w http.ResponseWriter) *Header {\n\treturn &Header{Site: Site, Theme: Themes[fallbackTheme], CurrentUser: &GuestUser, Writer: w}\n}\n"
  },
  {
    "path": "common/parser.go",
    "content": "package common\n\nimport (\n\t\"bytes\"\n\t//\"fmt\"\n\t//\"log\"\n\n\t\"net/url\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode/utf8\"\n)\n\n// TODO: Use the template system?\n// TODO: Somehow localise these?\nvar SpaceGap = []byte(\"          \")\nvar httpProtBytes = []byte(\"http://\")\nvar DoubleForwardSlash = []byte(\"//\")\nvar InvalidURL = []byte(\"<red>[Invalid URL]</red>\")\nvar InvalidTopic = []byte(\"<red>[Invalid Topic]</red>\")\nvar InvalidProfile = []byte(\"<red>[Invalid Profile]</red>\")\nvar InvalidForum = []byte(\"<red>[Invalid Forum]</red>\")\nvar unknownMedia = []byte(\"<red>[Unknown Media]</red>\")\nvar URLOpen = []byte(\"<a href='\")\nvar URLOpenUser = []byte(\"<a rel='ugc'href='\")\nvar URLOpen2 = []byte(\"'>\")\nvar bytesSinglequote = []byte(\"'\")\nvar bytesGreaterThan = []byte(\">\")\nvar urlMention = []byte(\"'class='mention'\")\nvar URLClose = []byte(\"</a>\")\nvar videoOpen = []byte(\"<video controls src=\\\"\")\nvar videoOpen2 = []byte(\"\\\"><a class='attach'href=\\\"\")\nvar videoClose = []byte(\"\\\"download>Attachment</a></video>\")\nvar audioOpen = []byte(\"<audio controls src=\\\"\")\nvar audioOpen2 = []byte(\"\\\"><a class='attach'href=\\\"\")\nvar audioClose = []byte(\"\\\"download>Attachment</a></audio>\")\nvar imageOpen = []byte(\"<a href=\\\"\")\nvar imageOpen2 = []byte(\"\\\"><img src='\")\nvar imageClose = []byte(\"'class='postImage'></a>\")\nvar attachOpen = []byte(\"<a class='attach'href=\\\"\")\nvar attachClose = []byte(\"\\\"download>Attachment</a>\")\nvar sidParam = []byte(\"?sid=\")\nvar stypeParam = []byte(\"&amp;stype=\")\n\n/*var textShortOpen = []byte(\"<a class='attach'href=\\\"\")\nvar textShortOpen2 = []byte(\"\\\">View</a> / <a class='attach'href=\\\"\")\nvar textShortClose = []byte(\"\\\"download>Download</a>\")*/\nvar textOpen = []byte(\"<div class='attach_box'><div class='attach_info'>\")\nvar textOpen2 = []byte(\"</div><div class='attach_opts'><a class='attach'href=\\\"\")\nvar textOpen3 = []byte(\"\\\">View</a> / <a class='attach'href=\\\"\")\nvar textClose = []byte(\"\\\"download>Download</a></div></div>\")\nvar urlPattern = `(?s)([ {1}])((http|https|ftp|mailto)*)(:{??)\\/\\/([\\.a-zA-Z\\/]+)([ {1}])`\nvar urlReg *regexp.Regexp\n\nconst imageSizeHint = len(\"<a href=\\\"\") + len(\"\\\"><img src='\") + len(\"'class='postImage'></a>\")\nconst videoSizeHint = len(\"<video controls src=\\\"\") + len(\"\\\"><a class='attach'href=\\\"\") + len(\"\\\"download>Attachment</a></video>\") + len(\"?sid=\") + len(\"&amp;stype=\") + 8\nconst audioSizeHint = len(\"<audio controls src=\\\"\") + len(\"\\\"><a class='attach'href=\\\"\") + len(\"\\\"download>Attachment</a></audio>\") + len(\"?sid=\") + len(\"&amp;stype=\") + 8\nconst mentionSizeHint = len(\"<a href='\") + len(\"'class='mention'\") + len(\">@\") + len(\"</a>\")\n\nfunc init() {\n\turlReg = regexp.MustCompile(urlPattern)\n}\n\nvar emojis map[string]string\n\ntype emojiHolder struct {\n\tNoDefault bool                `json:\"no_defaults\"`\n\tEmojis    []map[string]string `json:\"emojis\"`\n}\n\nfunc InitEmoji() error {\n\tvar emoji emojiHolder\n\terr := unmarshalJsonFile(\"./config/emoji_default.json\", &emoji)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\temojis = make(map[string]string, len(emoji.Emojis))\n\tif !emoji.NoDefault {\n\t\tfor _, item := range emoji.Emojis {\n\t\t\tfor ikey, ival := range item {\n\t\t\t\temojis[ikey] = ival\n\t\t\t}\n\t\t}\n\t}\n\n\temoji = emojiHolder{}\n\terr = unmarshalJsonFileIgnore404(\"./config/emoji.json\", &emoji)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif emoji.NoDefault {\n\t\temojis = make(map[string]string)\n\t}\n\n\tfor _, item := range emoji.Emojis {\n\t\tfor ikey, ival := range item {\n\t\t\temojis[ikey] = ival\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// TODO: Write a test for this\nfunc shortcodeToUnicode(msg string) string {\n\t//re := regexp.MustCompile(\":(.):\")\n\tfor shortcode, emoji := range emojis {\n\t\tmsg = strings.Replace(msg, shortcode, emoji, -1)\n\t}\n\treturn msg\n}\n\ntype TagToAction struct {\n\tSuffix      string\n\tDo          func(*TagToAction, bool, int, []rune) (int, string) // func(tagToAction,open,i,runes) (newI, output)\n\tDepth       int                                                 // For use by Do\n\tPartialMode bool\n}\n\n// TODO: Write a test for this\nfunc tryStepForward(i, step int, runes []rune) (int, bool) {\n\ti += step\n\tif i < len(runes) {\n\t\treturn i, true\n\t}\n\treturn i - step, false\n}\n\n// TODO: Write a test for this\nfunc tryStepBackward(i, step int, runes []rune) (int, bool) {\n\tif i == 0 {\n\t\treturn i, false\n\t}\n\treturn i - 1, true\n}\n\n// TODO: Preparse Markdown and normalize it into HTML?\n// TODO: Use a string builder\nfunc PreparseMessage(msg string) string {\n\t// TODO: Kick this check down a level into SanitiseBody?\n\tif !utf8.ValidString(msg) {\n\t\treturn \"\"\n\t}\n\tmsg = strings.Replace(msg, \"<p><br>\", \"\\n\\n\", -1)\n\tmsg = strings.Replace(msg, \"<p>\", \"\\n\\n\", -1)\n\tmsg = strings.Replace(msg, \"</p>\", \"\", -1)\n\t// TODO: Make this looser by moving it to the reverse HTML parser?\n\tmsg = strings.Replace(msg, \"<br>\", \"\\n\\n\", -1)\n\tmsg = strings.Replace(msg, \"<br />\", \"\\n\\n\", -1) // XHTML style\n\tmsg = strings.Replace(msg, \"&nbsp;\", \"\", -1)\n\tmsg = strings.Replace(msg, \"\\r\", \"\", -1) // Windows artifact\n\t//msg = strings.Replace(msg, \"\\n\\n\\n\\n\", \"\\n\\n\\n\", -1)\n\tmsg = GetHookTable().Sshook(\"preparse_preassign\", msg)\n\t// There are a few useful cases for having spaces, but I'd like to stop the WYSIWYG from inserting random lines here and there\n\tmsg = SanitiseBody(msg)\n\n\trunes := []rune(msg)\n\tmsg = \"\"\n\n\t// TODO: We can maybe reduce the size of this by using an offset?\n\t// TODO: Move some of these closures out of this function to make things a little more efficient\n\tallowedTags := [][]string{\n\t\t'e': {\"m\"},\n\t\t's': {\"\", \"trong\", \"poiler\", \"pan\"},\n\t\t'd': {\"el\"},\n\t\t'u': {\"\"},\n\t\t'b': {\"\", \"lockquote\"},\n\t\t'i': {\"\"},\n\t\t'h': {\"1\", \"2\", \"3\"},\n\t\t//'p': {\"\"},\n\t\t'g': {\"\"}, // Quick and dirty fix for Grammarly\n\t}\n\tbuildLitMatch := func(tag string) func(*TagToAction, bool, int, []rune) (int, string) {\n\t\treturn func(action *TagToAction, open bool, _ int, _ []rune) (int, string) {\n\t\t\tif open {\n\t\t\t\taction.Depth++\n\t\t\t\treturn -1, \"<\" + tag + \">\"\n\t\t\t}\n\t\t\tif action.Depth <= 0 {\n\t\t\t\treturn -1, \"\"\n\t\t\t}\n\t\t\taction.Depth--\n\t\t\treturn -1, \"</\" + tag + \">\"\n\t\t}\n\t}\n\ttagToAction := [][]*TagToAction{\n\t\t'e': {{\"m\", buildLitMatch(\"em\"), 0, false}},\n\t\t's': {\n\t\t\t{\"\", buildLitMatch(\"del\"), 0, false},\n\t\t\t{\"trong\", buildLitMatch(\"strong\"), 0, false},\n\t\t\t{\"poiler\", buildLitMatch(\"spoiler\"), 0, false},\n\t\t\t// Hides the span tags Trumbowyg loves blasting out randomly\n\t\t\t{\"pan\", func(act *TagToAction, open bool, i int, runes []rune) (int, string) {\n\t\t\t\tif open {\n\t\t\t\t\tact.Depth++\n\t\t\t\t\t//fmt.Println(\"skipping attributes\")\n\t\t\t\t\tfor ; i < len(runes); i++ {\n\t\t\t\t\t\tif runes[i] == '&' && peekMatch(i, \"gt;\", runes) {\n\t\t\t\t\t\t\t//fmt.Println(\"found tag exit\")\n\t\t\t\t\t\t\treturn i + 3, \" \"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn -1, \" \"\n\t\t\t\t}\n\t\t\t\tif act.Depth <= 0 {\n\t\t\t\t\treturn -1, \" \"\n\t\t\t\t}\n\t\t\t\tact.Depth--\n\t\t\t\treturn -1, \" \"\n\t\t\t}, 0, true},\n\t\t},\n\t\t'd': {{\"el\", buildLitMatch(\"del\"), 0, false}},\n\t\t'u': {{\"\", buildLitMatch(\"u\"), 0, false}},\n\t\t'b': {\n\t\t\t{\"\", buildLitMatch(\"strong\"), 0, false},\n\t\t\t{\"lockquote\", buildLitMatch(\"blockquote\"), 0, false},\n\t\t},\n\t\t'i': {{\"\", buildLitMatch(\"em\"), 0, false}},\n\t\t'h': {\n\t\t\t{\"1\", buildLitMatch(\"h2\"), 0, false},\n\t\t\t{\"2\", buildLitMatch(\"h3\"), 0, false},\n\t\t\t{\"3\", buildLitMatch(\"h4\"), 0, false},\n\t\t},\n\t\t//'p': {{\"\", buildLitMatch2(\"\\n\\n\", \"\"), 0, false}},\n\t\t'g': {\n\t\t\t{\"\", func(act *TagToAction, open bool, i int, runes []rune) (int, string) {\n\t\t\t\tif open {\n\t\t\t\t\tact.Depth++\n\t\t\t\t\t//fmt.Println(\"skipping attributes\")\n\t\t\t\t\tfor ; i < len(runes); i++ {\n\t\t\t\t\t\tif runes[i] == '&' && peekMatch(i, \"gt;\", runes) {\n\t\t\t\t\t\t\t//fmt.Println(\"found tag exit\")\n\t\t\t\t\t\t\treturn i + 3, \" \"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn -1, \" \"\n\t\t\t\t}\n\t\t\t\tif act.Depth <= 0 {\n\t\t\t\t\treturn -1, \" \"\n\t\t\t\t}\n\t\t\t\tact.Depth--\n\t\t\t\treturn -1, \" \"\n\t\t\t}, 0, true},\n\t\t},\n\t}\n\t// TODO: Implement a less literal parser\n\t// TODO: Use a string builder\n\t// TODO: Implement faster emoji parser\n\tfor i := 0; i < len(runes); i++ {\n\t\tchar := runes[i]\n\t\t// TODO: Make the slashes escapable too in case someone means to use a literaly slash, maybe as an example of how to escape elements?\n\t\tif char == '\\\\' {\n\t\t\tif peekMatch(i, \"&lt;\", runes) {\n\t\t\t\tmsg += \"&\"\n\t\t\t\ti++\n\t\t\t}\n\t\t} else if char == '&' && peekMatch(i, \"lt;\", runes) {\n\t\t\tvar ok bool\n\t\t\ti, ok = tryStepForward(i, 4, runes)\n\t\t\tif !ok {\n\t\t\t\tmsg += \"&lt;\"\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tchar := runes[i]\n\t\t\tif int(char) >= len(allowedTags) {\n\t\t\t\t//fmt.Println(\"sentinel char out of bounds\")\n\t\t\t\tmsg += \"&\"\n\t\t\t\ti -= 4\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tvar closeTag bool\n\t\t\tif char == '/' {\n\t\t\t\t//fmt.Println(\"found close tag\")\n\t\t\t\ti, ok = tryStepForward(i, 1, runes)\n\t\t\t\tif !ok {\n\t\t\t\t\tmsg += \"&lt;/\"\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tchar = runes[i]\n\t\t\t\tcloseTag = true\n\t\t\t}\n\n\t\t\ttags := allowedTags[char]\n\t\t\tif len(tags) == 0 {\n\t\t\t\t//fmt.Println(\"couldn't find char in allowedTags\")\n\t\t\t\tmsg += \"&\"\n\t\t\t\tif closeTag {\n\t\t\t\t\t//msg += \"&lt;/\"\n\t\t\t\t\t//msg += \"&\"\n\t\t\t\t\ti -= 5\n\t\t\t\t} else {\n\t\t\t\t\t//msg += \"&\"\n\t\t\t\t\ti -= 4\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// TODO: Scan through tags and make sure the suffix is present to reduce the number of false positives which hit the loop below\n\t\t\t//fmt.Printf(\"tags: %+v\\n\", tags)\n\n\t\t\tnewI := -1\n\t\t\tvar out string\n\t\t\ttoActionList := tagToAction[char]\n\t\t\tfor _, toAction := range toActionList {\n\t\t\t\t// TODO: Optimise this, maybe with goto or a function call to avoid scanning the text twice?\n\t\t\t\tif (toAction.PartialMode && !closeTag && peekMatch(i, toAction.Suffix, runes)) || peekMatch(i, toAction.Suffix+\"&gt;\", runes) {\n\t\t\t\t\tnewI, out = toAction.Do(toAction, !closeTag, i, runes)\n\t\t\t\t\tif newI != -1 {\n\t\t\t\t\t\ti = newI\n\t\t\t\t\t} else if out != \"\" {\n\t\t\t\t\t\ti += len(toAction.Suffix + \"&gt;\")\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif out == \"\" {\n\t\t\t\tmsg += \"&\"\n\t\t\t\tif closeTag {\n\t\t\t\t\ti -= 5\n\t\t\t\t} else {\n\t\t\t\t\ti -= 4\n\t\t\t\t}\n\t\t\t} else if out != \" \" {\n\t\t\t\tmsg += out\n\t\t\t}\n\t\t} else if char == '@' && (i == 0 || runes[i-1] < 33) {\n\t\t\t// TODO: Handle usernames containing spaces, maybe in the front-end with AJAX\n\t\t\t// Do not mention-ify ridiculously long things\n\t\t\tvar ok bool\n\t\t\ti, ok = tryStepForward(i, 1, runes)\n\t\t\tif !ok {\n\t\t\t\tmsg += \"@\"\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstart := i\n\n\t\t\tfor j := 0; i < len(runes) && j < Config.MaxUsernameLength; j++ {\n\t\t\t\tcchar := runes[i]\n\t\t\t\tif cchar < 33 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\ti++\n\t\t\t}\n\n\t\t\tusername := string(runes[start:i])\n\t\t\tif username == \"\" {\n\t\t\t\tmsg += \"@\"\n\t\t\t\ti = start - 1\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tuser, err := Users.GetByName(username)\n\t\t\tif err != nil {\n\t\t\t\tif err != ErrNoRows {\n\t\t\t\t\tLogError(err)\n\t\t\t\t}\n\t\t\t\tmsg += \"@\"\n\t\t\t\ti = start - 1\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmsg += \"@\" + strconv.Itoa(user.ID)\n\t\t\ti--\n\t\t} else {\n\t\t\tmsg += string(char)\n\t\t}\n\t}\n\n\tfor _, actionList := range tagToAction {\n\t\tfor _, toAction := range actionList {\n\t\t\tif toAction.Depth > 0 {\n\t\t\t\tfor ; toAction.Depth > 0; toAction.Depth-- {\n\t\t\t\t\t_, out := toAction.Do(toAction, false, len(runes), runes)\n\t\t\t\t\tif out != \"\" {\n\t\t\t\t\t\tmsg += out\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.TrimSpace(shortcodeToUnicode(msg))\n}\n\n// TODO: Test this\n// TODO: Use this elsewhere in the parser?\nfunc peek(cur, skip int, runes []rune) rune {\n\tif (cur + skip) < len(runes) {\n\t\treturn runes[cur+skip]\n\t}\n\treturn 0 // null byte\n}\n\n// TODO: Test this\nfunc peekMatch(cur int, phrase string, runes []rune) bool {\n\tif cur+len(phrase) > len(runes) {\n\t\treturn false\n\t}\n\tfor i, char := range phrase {\n\t\tif cur+i+1 >= len(runes) {\n\t\t\treturn false\n\t\t}\n\t\tif runes[cur+i+1] != char {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ! Not concurrency safe\nfunc AddHashLinkType(prefix string, h func(*strings.Builder, string, *int)) {\n\t// There can only be one hash link type starting with a specific character at the moment\n\thashType := hashLinkTypes[prefix[0]]\n\tif hashType != \"\" {\n\t\treturn\n\t}\n\thashLinkMap[prefix] = h\n\thashLinkTypes[prefix[0]] = prefix\n}\n\nfunc WriteURL(sb *strings.Builder, url, label string) {\n\tsb.Write(URLOpen)\n\tsb.WriteString(url)\n\tsb.Write(URLOpen2)\n\tsb.WriteString(label)\n\tsb.Write(URLClose)\n}\n\nvar hashLinkTypes = []string{'t': \"tid-\", 'r': \"rid-\", 'f': \"fid-\"}\nvar hashLinkMap = map[string]func(*strings.Builder, string, *int){\n\t\"tid-\": func(sb *strings.Builder, msg string, i *int) {\n\t\ttid, intLen := CoerceIntString(msg[*i:])\n\t\t*i += intLen\n\n\t\ttopic, err := Topics.Get(tid)\n\t\tif err != nil || !Forums.Exists(topic.ParentID) {\n\t\t\tsb.Write(InvalidTopic)\n\t\t\treturn\n\t\t}\n\t\tWriteURL(sb, BuildTopicURL(\"\", tid), \"#tid-\"+strconv.Itoa(tid))\n\t},\n\t\"rid-\": func(sb *strings.Builder, msg string, i *int) {\n\t\trid, intLen := CoerceIntString(msg[*i:])\n\t\t*i += intLen\n\n\t\ttopic, err := TopicByReplyID(rid)\n\t\tif err != nil || !Forums.Exists(topic.ParentID) {\n\t\t\tsb.Write(InvalidTopic)\n\t\t\treturn\n\t\t}\n\t\t// TODO: Send the user to the right page and post not just the right topic?\n\t\tWriteURL(sb, BuildTopicURL(\"\", topic.ID), \"#rid-\"+strconv.Itoa(rid))\n\t},\n\t\"fid-\": func(sb *strings.Builder, msg string, i *int) {\n\t\tfid, intLen := CoerceIntString(msg[*i:])\n\t\t*i += intLen\n\n\t\tif !Forums.Exists(fid) {\n\t\t\tsb.Write(InvalidForum)\n\t\t\treturn\n\t\t}\n\t\tWriteURL(sb, BuildForumURL(\"\", fid), \"#fid-\"+strconv.Itoa(fid))\n\t},\n\t// TODO: Forum Shortcode Link\n}\n\n// TODO: Pack multiple bit flags into an integer instead of using a struct?\nvar DefaultParseSettings = &ParseSettings{}\n\ntype ParseSettings struct {\n\tNoEmbed bool\n}\n\nfunc (ps *ParseSettings) CopyPtr() *ParseSettings {\n\tn := &ParseSettings{}\n\t*n = *ps\n\treturn n\n}\n\nfunc ParseMessage(msg string, sectionID int, sectionType string, settings *ParseSettings, user *User) string {\n\tmsg, _ = ParseMessage2(msg, sectionID, sectionType, settings, user)\n\treturn msg\n}\n\nvar litRepPrefix = []byte{':', ';'}\n\n//var litRep = [][]byte{':':{')','(','D','O','o','P','p'},';':{')'}}\nvar litRep = [][]string{':': {')': \"😀\", '(': \"😞\", 'D': \"😃\", 'O': \"😲\", 'o': \"😲\", 'P': \"😛\", 'p': \"😛\"}, ';': {')': \"😉\"}}\n\n// TODO: Write a test for this\n// TODO: We need a lot more hooks here. E.g. To add custom media types and handlers.\n// TODO: Use templates to reduce the amount of boilerplate?\nfunc ParseMessage2(msg string, sectionID int, sectionType string, settings *ParseSettings, user *User) (string, bool) {\n\tif settings == nil {\n\t\tsettings = DefaultParseSettings\n\t}\n\tif user == nil {\n\t\tuser = &GuestUser\n\t}\n\t// TODO: Word boundary detection for these to avoid mangling code\n\t/*rep := func(find, replace string) {\n\t\tmsg = strings.Replace(msg, find, replace, -1)\n\t}\n\trep(\":)\", \"😀\")\n\trep(\":(\", \"😞\")\n\trep(\":D\", \"😃\")\n\trep(\":P\", \"😛\")\n\trep(\":O\", \"😲\")\n\trep(\":p\", \"😛\")\n\trep(\":o\", \"😲\")\n\trep(\";)\", \"😉\")*/\n\n\t// Word filter list. E.g. Swear words and other things the admins don't like\n\tfilters, err := WordFilters.GetAll()\n\tif err != nil {\n\t\tLogError(err)\n\t\treturn \"\", false\n\t}\n\tfor _, f := range filters {\n\t\tmsg = strings.Replace(msg, f.Find, f.Replace, -1)\n\t}\n\tif len(msg) < 2 {\n\t\tmsg = strings.Replace(msg, \"\\n\", \"<br>\", -1)\n\t\tmsg = GetHookTable().Sshook(\"parse_assign\", msg)\n\t\treturn msg, false\n\t}\n\n\t// Search for URLs, mentions and hashlinks in the messages...\n\tvar sb strings.Builder\n\tlastItem := 0\n\ti := 0\n\tvar externalHead bool\n\t//var c bool\n\t//fmt.Println(\"msg:\", \"'\"+msg+\"'\")\n\tfor ; len(msg) > i; i++ {\n\t\t//fmt.Printf(\"msg[%d]: %s\\n\",i,string(msg[i]))\n\t\tif (i == 0 && (msg[0] > 32)) || (len(msg) > (i+1) && (msg[i] < 33) && (msg[i+1] > 32)) {\n\t\t\t//fmt.Println(\"s1\")\n\t\t\tif (i != 0) || msg[i] < 33 {\n\t\t\t\ti++\n\t\t\t}\n\t\t\tif len(msg) <= (i + 1) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\t//fmt.Println(\"s2\")\n\t\t\tch := msg[i]\n\n\t\t\t// Very short literal matcher\n\t\t\tif len(litRep) > int(ch) {\n\t\t\t\tsl := litRep[ch]\n\t\t\t\tif sl != nil {\n\t\t\t\t\ti++\n\t\t\t\t\tch := msg[i]\n\t\t\t\t\tif len(sl) > int(ch) {\n\t\t\t\t\t\tval := sl[ch]\n\t\t\t\t\t\tif val != \"\" {\n\t\t\t\t\t\t\ti--\n\t\t\t\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\t\t\t\ti++\n\t\t\t\t\t\t\tsb.WriteString(val)\n\t\t\t\t\t\t\ti++\n\t\t\t\t\t\t\tlastItem = i\n\t\t\t\t\t\t\ti--\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\ti--\n\t\t\t\t}\n\t\t\t\t//lastItem = i\n\t\t\t\t//i--\n\t\t\t\t//continue\n\t\t\t}\n\n\t\t\tswitch ch {\n\t\t\tcase '#':\n\t\t\t\t//fmt.Println(\"msg[i+1]:\", msg[i+1])\n\t\t\t\t//fmt.Println(\"string(msg[i+1]):\", string(msg[i+1]))\n\t\t\t\thashType := hashLinkTypes[msg[i+1]]\n\t\t\t\tif hashType == \"\" {\n\t\t\t\t\t//fmt.Println(\"uh1\")\n\t\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\t\ti++\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t//fmt.Println(\"hashType:\", hashType)\n\t\t\t\tif len(msg) <= (i + len(hashType) + 1) {\n\t\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif msg[i+1:i+len(hashType)+1] != hashType {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t//fmt.Println(\"msg[lastItem:i]:\", msg[lastItem:i])\n\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\ti += len(hashType) + 1\n\t\t\t\thashLinkMap[hashType](&sb, msg, &i)\n\t\t\t\tlastItem = i\n\t\t\t\ti--\n\t\t\tcase '@':\n\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\ti++\n\t\t\t\tstart := i\n\t\t\t\tuid, intLen := CoerceIntString(msg[start:])\n\t\t\t\ti += intLen\n\n\t\t\t\tvar menUser *User\n\t\t\t\tif uid != 0 && user.ID == uid {\n\t\t\t\t\tmenUser = user\n\t\t\t\t} else {\n\t\t\t\t\tmenUser = Users.Getn(uid)\n\t\t\t\t\tif menUser == nil {\n\t\t\t\t\t\tsb.Write(InvalidProfile)\n\t\t\t\t\t\tlastItem = i\n\t\t\t\t\t\ti--\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tsb.Grow(mentionSizeHint + len(menUser.Link) + len(menUser.Name))\n\t\t\t\tsb.Write(URLOpen)\n\t\t\t\tsb.WriteString(menUser.Link)\n\t\t\t\tsb.Write(urlMention)\n\t\t\t\tsb.Write(bytesGreaterThan)\n\t\t\t\tsb.WriteByte('@')\n\t\t\t\tsb.WriteString(menUser.Name)\n\t\t\t\tsb.Write(URLClose)\n\t\t\t\tlastItem = i\n\t\t\t\ti--\n\t\t\tcase 'h', 'f', 'g', '/', 'i':\n\t\t\t\t//fmt.Println(\"s3\")\n\t\t\t\tfch := msg[i+1]\n\t\t\t\tif msg[i] == 'h' && fch == 't' && len(msg) > i+5 && msg[i+2] == 't' && msg[i+3] == 'p' {\n\t\t\t\t\tif msg[i+4] == 's' && msg[i+5] == ':' && len(msg) > i+6 && msg[i+6] == '/' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else if msg[i+4] == ':' && msg[i+5] == '/' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t} else if len(msg) > i+4 {\n\t\t\t\t\tif fch == 't' && msg[i+2] == 'p' && msg[i+3] == ':' && msg[i+4] == '/' && msg[i] == 'f' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else if fch == 'i' && msg[i+2] == 't' && msg[i+3] == ':' && msg[i+4] == '/' && msg[i] == 'g' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else if msg[i] == 'i' && fch == 'p' && msg[i+2] == 'f' && msg[i+3] == 's' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else if msg[i] == 'i' && fch == 'p' && msg[i+2] == 'n' && msg[i+3] == 's' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else if fch == '/' && msg[i] == '/' {\n\t\t\t\t\t\t// Do nothing\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t} else if fch == '/' && msg[i] == '/' {\n\t\t\t\t\t// Do nothing\n\t\t\t\t} else {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !user.Perms.AutoLink {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t//fmt.Println(\"p1:\",i)\n\t\t\t\tsb.WriteString(msg[lastItem:i])\n\t\t\t\turlLen, ok := PartialURLStringLen(msg[i:])\n\t\t\t\tif len(msg) < i+urlLen {\n\t\t\t\t\t//fmt.Println(\"o1\")\n\t\t\t\t\tif urlLen == 2 {\n\t\t\t\t\t\tsb.Write(DoubleForwardSlash)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsb.Write(InvalidURL)\n\t\t\t\t\t}\n\t\t\t\t\ti += len(msg) - 1\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif urlLen == 2 {\n\t\t\t\t\tsb.Write(DoubleForwardSlash)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\ti--\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t//fmt.Println(\"msg[i:i+urlLen]:\", \"'\"+msg[i:i+urlLen]+\"'\")\n\t\t\t\tif !ok {\n\t\t\t\t\t//fmt.Printf(\"o2: i = %d; i+urlLen = %d\\n\",i,i+urlLen)\n\t\t\t\t\tsb.Write(InvalidURL)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\ti--\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tmedia, ok := parseMediaString(msg[i:i+urlLen], settings)\n\t\t\t\tif !ok {\n\t\t\t\t\t//fmt.Println(\"o3\")\n\t\t\t\t\tsb.Write(InvalidURL)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t//fmt.Println(\"p2\")\n\n\t\t\t\taddImage := func(url string) {\n\t\t\t\t\tsb.Grow(imageSizeHint + len(url) + len(url))\n\t\t\t\t\tsb.Write(imageOpen)\n\t\t\t\t\tsb.WriteString(url)\n\t\t\t\t\tsb.Write(imageOpen2)\n\t\t\t\t\tsb.WriteString(url)\n\t\t\t\t\tsb.Write(imageClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t}\n\n\t\t\t\t// TODO: Reduce the amount of code duplication\n\t\t\t\t// TODO: Avoid allocating a string for media.Type?\n\t\t\t\tswitch media.Type {\n\t\t\t\tcase AImage:\n\t\t\t\t\taddImage(media.URL + \"?sid=\" + strconv.Itoa(sectionID) + \"&amp;stype=\" + sectionType)\n\t\t\t\t\tcontinue\n\t\t\t\tcase AVideo:\n\t\t\t\t\tsb.Grow(videoSizeHint + (len(media.URL) + len(sectionType)*2))\n\t\t\t\t\tsb.Write(videoOpen)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(strconv.Itoa(sectionID))\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(videoOpen2)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(strconv.Itoa(sectionID))\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(videoClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\tcase AAudio:\n\t\t\t\t\tsb.Grow(audioSizeHint + (len(media.URL) + len(sectionType)*2))\n\t\t\t\t\tsb.Write(audioOpen)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(strconv.Itoa(sectionID))\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(audioOpen2)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(strconv.Itoa(sectionID))\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(audioClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\tcase EImage:\n\t\t\t\t\taddImage(media.URL)\n\t\t\t\t\tcontinue\n\t\t\t\tcase AText:\n\t\t\t\t\t/*sb.Write(textOpen)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsid := strconv.Itoa(sectionID)\n\t\t\t\t\tsb.WriteString(sid)\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(textOpen2)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(sid)\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(textClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue*/\n\t\t\t\t\tsb.Write(textOpen)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(textOpen2)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsid := strconv.Itoa(sectionID)\n\t\t\t\t\tsb.WriteString(sid)\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(textOpen3)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(sid)\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(textClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\tcase AOther:\n\t\t\t\t\tsb.Write(attachOpen)\n\t\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\t\tsb.Write(sidParam)\n\t\t\t\t\tsb.WriteString(strconv.Itoa(sectionID))\n\t\t\t\t\tsb.Write(stypeParam)\n\t\t\t\t\tsb.WriteString(sectionType)\n\t\t\t\t\tsb.Write(attachClose)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\tcase ERaw:\n\t\t\t\t\tsb.WriteString(media.Body)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\tcontinue\n\t\t\t\tcase ERawExternal:\n\t\t\t\t\tsb.WriteString(media.Body)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tlastItem = i\n\t\t\t\t\texternalHead = true\n\t\t\t\t\tcontinue\n\t\t\t\tcase ENone:\n\t\t\t\t\t// Do nothing\n\t\t\t\t// TODO: Add support for media plugins\n\t\t\t\tdefault:\n\t\t\t\t\tsb.Write(unknownMedia)\n\t\t\t\t\ti += urlLen\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t//fmt.Println(\"p3\")\n\n\t\t\t\t// TODO: Add support for rel=\"ugc\"\n\t\t\t\tsb.Grow(len(URLOpen) + (len(msg[i:i+urlLen]) * 2) + len(URLOpen2) + len(URLClose))\n\t\t\t\tif media.Trusted {\n\t\t\t\t\tsb.Write(URLOpen)\n\t\t\t\t} else {\n\t\t\t\t\tsb.Write(URLOpenUser)\n\t\t\t\t}\n\t\t\t\tsb.WriteString(media.URL)\n\t\t\t\tsb.Write(URLOpen2)\n\t\t\t\tsb.WriteString(media.FURL)\n\t\t\t\tsb.Write(URLClose)\n\t\t\t\ti += urlLen\n\t\t\t\tlastItem = i\n\t\t\t\ti--\n\t\t\t}\n\t\t}\n\t}\n\tif lastItem != i && sb.Len() != 0 {\n\t\t/*calclen := len(msg)\n\t\tif calclen <= lastItem {\n\t\t\tcalclen = lastItem\n\t\t}*/\n\t\t//if i == len(msg) {\n\t\tsb.WriteString(msg[lastItem:])\n\t\t/*} else {\n\t\t\tsb.WriteString(msg[lastItem:calclen])\n\t\t}*/\n\t}\n\tif sb.Len() != 0 {\n\t\tmsg = sb.String()\n\t\t//fmt.Println(\"sb.String():\", \"'\"+sb.String()+\"'\")\n\t}\n\n\tmsg = strings.Replace(msg, \"\\n\", \"<br>\", -1)\n\tmsg = GetHookTable().Sshook(\"parse_assign\", msg)\n\treturn msg, externalHead\n}\n\n// 6, 7, 8, 6, 2, 7\n// ftp://, http://, https://, git://, ipfs://, ipns://, //, mailto: (not a URL, just here for length comparison purposes)\n// TODO: Write a test for this\nfunc validateURLString(d string) bool {\n\ti := 0\n\tif len(d) >= 6 {\n\t\tif d[0:6] == \"ftp://\" || d[0:6] == \"git://\" {\n\t\t\ti = 6\n\t\t} else if len(d) >= 7 && (d[0:7] == \"http://\" || d[0:7] == \"ipfs://\" || d[0:7] == \"ipns://\") {\n\t\t\ti = 7\n\t\t} else if len(d) >= 8 && d[0:8] == \"https://\" {\n\t\t\ti = 8\n\t\t}\n\t} else if len(d) >= 2 && d[0] == '/' && d[1] == '/' {\n\t\ti = 2\n\t}\n\n\t// ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s.\n\tfor ; len(d) > i; i++ {\n\t\tch := d[i]\n\t\tif ch != '\\\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// TODO: Write a test for this\nfunc validatedURLBytes(data []byte) (url []byte) {\n\tdatalen := len(data)\n\ti := 0\n\tif datalen >= 6 {\n\t\tif bytes.Equal(data[0:6], []byte(\"ftp://\")) || bytes.Equal(data[0:6], []byte(\"git://\")) {\n\t\t\ti = 6\n\t\t} else if datalen >= 7 && bytes.Equal(data[0:7], httpProtBytes) {\n\t\t\ti = 7\n\t\t} else if datalen >= 8 && bytes.Equal(data[0:8], []byte(\"https://\")) {\n\t\t\ti = 8\n\t\t}\n\t} else if datalen >= 2 && data[0] == '/' && data[1] == '/' {\n\t\ti = 2\n\t}\n\n\t// ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s.\n\tfor ; datalen > i; i++ {\n\t\tch := data[i]\n\t\tif ch != '\\\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [\n\t\t\treturn InvalidURL\n\t\t}\n\t}\n\n\turl = append(url, data...)\n\treturn url\n}\n\n// TODO: Write a test for this\nfunc PartialURLString(d string) (url []byte) {\n\ti := 0\n\tend := len(d) - 1\n\tif len(d) >= 6 {\n\t\tif d[0:6] == \"ftp://\" || d[0:6] == \"git://\" {\n\t\t\ti = 6\n\t\t} else if len(d) >= 7 && (d[0:7] == \"http://\" || d[0:7] == \"ipfs://\" || d[0:7] == \"ipns://\") {\n\t\t\ti = 7\n\t\t} else if len(d) >= 8 && d[0:8] == \"https://\" {\n\t\t\ti = 8\n\t\t}\n\t} else if len(d) >= 2 && d[0] == '/' && d[1] == '/' {\n\t\ti = 2\n\t}\n\n\t// ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s.\n\tfor ; end >= i; i++ {\n\t\tch := d[i]\n\t\tif ch != '\\\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [\n\t\t\tend = i\n\t\t}\n\t}\n\n\turl = append(url, []byte(d[0:end])...)\n\treturn url\n}\n\n// TODO: Write a test for this\n// TODO: Handle the host bits differently from the paths...\nfunc PartialURLStringLen(d string) (int, bool) {\n\ti := 0\n\tif len(d) >= 6 {\n\t\t//log.Print(string(d[0:5]))\n\t\tif d[0:6] == \"ftp://\" || d[0:6] == \"git://\" {\n\t\t\ti = 6\n\t\t} else if len(d) >= 7 && (d[0:7] == \"http://\" || d[0:7] == \"ipfs://\" || d[0:7] == \"ipns://\") {\n\t\t\ti = 7\n\t\t} else if len(d) >= 8 && d[0:8] == \"https://\" {\n\t\t\ti = 8\n\t\t}\n\t} else if len(d) >= 2 && d[0] == '/' && d[1] == '/' {\n\t\ti = 2\n\t}\n\t//fmt.Println(\"Data Length: \",len(d))\n\tif len(d) < i {\n\t\t//fmt.Println(\"e1:\",i)\n\t\treturn i + 1, false\n\t}\n\n\t// ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s.\n\tf := i\n\t//fmt.Println(\"f:\",f)\n\tfor ; len(d) > i; i++ {\n\t\tch := d[i]   //char\n\t\tif ch < 33 { // space and invisibles\n\t\t\t//fmt.Println(\"e2:\",i)\n\t\t\treturn i, i != f\n\t\t} else if ch != '\\\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [\n\t\t\t//log.Print(\"Bad Character: \", ch)\n\t\t\t//fmt.Println(\"e3\")\n\t\t\treturn i, false\n\t\t}\n\t}\n\n\t//fmt.Println(\"e4:\", i)\n\t/*if data[i-1] < 33 {\n\t\treturn i-1, i != f\n\t}*/\n\t//fmt.Println(\"e5\")\n\treturn i, i != f\n}\n\n// TODO: Write a test for this\n// TODO: Get this to support IPv6 hosts, this isn't currently done as this is used in the bbcode plugin where it thinks the [ is a IPv6 host\nfunc PartialURLStringLen2(d string) int {\n\ti := 0\n\tif len(d) >= 6 {\n\t\t//log.Print(string(d[0:5]))\n\t\tif d[0:6] == \"ftp://\" || d[0:6] == \"git://\" {\n\t\t\ti = 6\n\t\t} else if len(d) >= 7 && (d[0:7] == \"http://\" || d[0:7] == \"ipfs://\" || d[0:7] == \"ipns://\") {\n\t\t\ti = 7\n\t\t} else if len(d) >= 8 && d[0:8] == \"https://\" {\n\t\t\ti = 8\n\t\t}\n\t} else if len(d) >= 2 && d[0] == '/' && d[1] == '/' {\n\t\ti = 2\n\t}\n\n\t// ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s.\n\tfor ; len(d) > i; i++ {\n\t\tch := d[i]\n\t\tif ch != '\\\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 91) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [\n\t\t\t//log.Print(\"Bad Character: \", ch)\n\t\t\treturn i\n\t\t}\n\t}\n\t//log.Print(\"Data Length: \",len(d))\n\treturn len(d)\n}\n\ntype MediaEmbed struct {\n\t//Type string //image\n\tType int\n\tURL  string\n\tFURL string\n\tBody string\n\n\tTrusted bool // samesite urls\n}\n\nconst (\n\tENone = iota\n\tERaw\n\tERawExternal\n\tEImage\n\tAImage\n\tAVideo\n\tAAudio\n\tAText\n\tAOther\n)\n\nvar LastEmbedID = AOther\n\n// TODO: Write a test for this\nfunc parseMediaString(data string, settings *ParseSettings) (media MediaEmbed, ok bool) {\n\tif !validateURLString(data) {\n\t\treturn media, false\n\t}\n\tuurl, err := url.Parse(data)\n\tif err != nil {\n\t\treturn media, false\n\t}\n\thost := uurl.Hostname()\n\tscheme := uurl.Scheme\n\tif scheme == \"ipfs\" {\n\t\tmedia.FURL = data\n\t\tmedia.URL = media.FURL\n\t\treturn media, true\n\t}\n\tport := uurl.Port()\n\tquery, err := url.ParseQuery(uurl.RawQuery)\n\tif err != nil {\n\t\treturn media, false\n\t}\n\t//fmt.Println(\"host:\", host)\n\t//log.Print(\"Site.URL:\",Site.URL)\n\n\tsamesite := (host == \"localhost\" || host == \"127.0.0.1\" || host == \"::1\" || host == Site.URL) && scheme != \"ipns\"\n\tif samesite {\n\t\thost = strings.Split(Site.URL, \":\")[0]\n\t\t// ?- Test this as I'm not sure it'll do what it should. If someone's running SSL on port 80 or non-SSL on port 443 then... Well... They're in far worse trouble than this...\n\t\tport = Site.Port\n\t\tif Config.SslSchema {\n\t\t\tscheme = \"https\"\n\t\t}\n\t}\n\tif scheme != \"\" {\n\t\tscheme += \":\"\n\t}\n\tmedia.Trusted = samesite\n\n\tpath := uurl.EscapedPath()\n\t//fmt.Println(\"path:\", path)\n\tpathFrags := strings.Split(path, \"/\")\n\tif len(pathFrags) >= 2 {\n\t\tif samesite && pathFrags[1] == \"attachs\" && (scheme == \"http:\" || scheme == \"https:\") {\n\t\t\tvar sport string\n\t\t\t// ? - Assumes the sysadmin hasn't mixed up the two standard ports\n\t\t\tif port != \"443\" && port != \"80\" && port != \"\" {\n\t\t\t\tsport = \":\" + port\n\t\t\t}\n\t\t\tmedia.URL = scheme + \"//\" + host + sport + path\n\t\t\text := strings.TrimPrefix(filepath.Ext(path), \".\")\n\t\t\tif len(ext) == 0 {\n\t\t\t\t// TODO: Write a unit test for this\n\t\t\t\treturn media, false\n\t\t\t}\n\t\t\tswitch {\n\t\t\tcase ImageFileExts.Contains(ext):\n\t\t\t\tmedia.Type = AImage\n\t\t\tcase WebVideoFileExts.Contains(ext):\n\t\t\t\tmedia.Type = AVideo\n\t\t\tcase WebAudioFileExts.Contains(ext):\n\t\t\t\tmedia.Type = AAudio\n\t\t\tcase TextFileExts.Contains(ext):\n\t\t\t\tmedia.Type = AText\n\t\t\tdefault:\n\t\t\t\tmedia.Type = AOther\n\t\t\t}\n\t\t\treturn media, true\n\t\t}\n\t}\n\n\t//fmt.Printf(\"settings.NoEmbed: %+v\\n\", settings.NoEmbed)\n\t//settings.NoEmbed = false\n\tif !settings.NoEmbed {\n\t\t// ? - I don't think this hostname will hit every YT domain\n\t\t// TODO: Make this a more customisable handler rather than hard-coding it in here\n\t\tytInvalid := func(v string) bool {\n\t\t\tfor _, ch := range v {\n\t\t\t\tif !((ch > 47 && ch < 58) || (ch > 64 && ch < 91) || (ch > 96 && ch < 123) || ch == '-' || ch == '_') {\n\t\t\t\t\tvar sport string\n\t\t\t\t\tif port != \"443\" && port != \"80\" && port != \"\" {\n\t\t\t\t\t\tsport = \":\" + port\n\t\t\t\t\t}\n\t\t\t\t\tvar q string\n\t\t\t\t\tif len(uurl.RawQuery) > 0 {\n\t\t\t\t\t\tq = \"?\" + uurl.RawQuery\n\t\t\t\t\t}\n\t\t\t\t\tvar frag string\n\t\t\t\t\tif len(uurl.Fragment) > 0 {\n\t\t\t\t\t\tfrag = \"#\" + uurl.Fragment\n\t\t\t\t\t}\n\t\t\t\t\tmedia.FURL = host + sport + path + q + frag\n\t\t\t\t\tmedia.URL = scheme + \"//\" + media.FURL\n\t\t\t\t\t//fmt.Printf(\"ytInvalid true: %+v\\n\",v)\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t\tytInvalid2 := func(t string) bool {\n\t\t\tfor _, ch := range t {\n\t\t\t\tif !((ch > 47 && ch < 58) || ch == 'h' || ch == 'm' || ch == 's') {\n\t\t\t\t\t//fmt.Printf(\"ytInvalid2 true: %+v\\n\",t)\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t\tif strings.HasSuffix(host, \".youtube.com\") && path == \"/watch\" {\n\t\t\tvideo, ok := query[\"v\"]\n\t\t\tif ok && len(video) >= 1 && video[0] != \"\" {\n\t\t\t\tv := video[0]\n\t\t\t\tif ytInvalid(v) {\n\t\t\t\t\treturn media, true\n\t\t\t\t}\n\t\t\t\tvar t, t2 string\n\t\t\t\ttt, ok := query[\"t\"]\n\t\t\t\tif ok && len(tt) >= 1 {\n\t\t\t\t\tt, t2 = tt[0], tt[0]\n\t\t\t\t}\n\t\t\t\tmedia.Type = ERawExternal\n\t\t\t\tif t != \"\" && !ytInvalid2(t) {\n\t\t\t\t\ts, m, h := parseDuration(t2)\n\t\t\t\t\tcalc := s + (m * 60) + (h * 60 * 60)\n\t\t\t\t\tif calc > 0 {\n\t\t\t\t\t\tt = \"&t=\" + t\n\t\t\t\t\t\tt2 = \"?start=\" + strconv.Itoa(calc)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt, t2 = \"\", \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tl := \"https://\" + host + path + \"?v=\" + v + t\n\t\t\t\tmedia.Body = \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/\" + v + t2 + \"'frameborder=0 allowfullscreen></iframe><noscript><a href='\" + l + \"'>\" + l + \"</a></noscript>\"\n\t\t\t\treturn media, true\n\t\t\t}\n\t\t} else if host == \"youtu.be\" {\n\t\t\tv := strings.TrimPrefix(path, \"/\")\n\t\t\tif ytInvalid(v) {\n\t\t\t\treturn media, true\n\t\t\t}\n\t\t\tl := \"https://youtu.be/\" + v\n\t\t\tmedia.Type = ERawExternal\n\t\t\tmedia.Body = \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/\" + v + \"'frameborder=0 allowfullscreen></iframe><noscript><a href='\" + l + \"'>\" + l + \"</a></noscript>\"\n\t\t\treturn media, true\n\t\t} else if strings.HasPrefix(host, \"www.nicovideo.jp\") && strings.HasPrefix(path, \"/watch/sm\") {\n\t\t\tvid, err := strconv.ParseInt(strings.TrimPrefix(path, \"/watch/sm\"), 10, 64)\n\t\t\tif err == nil {\n\t\t\t\tvar sport string\n\t\t\t\tif port != \"443\" && port != \"80\" && port != \"\" {\n\t\t\t\t\tsport = \":\" + port\n\t\t\t\t}\n\t\t\t\tmedia.Type = ERawExternal\n\t\t\t\tsm := strconv.FormatInt(vid, 10)\n\t\t\t\tl := \"https://\" + host + sport + path\n\t\t\t\tmedia.Body = \"<iframe class='postIframe'src='https://embed.nicovideo.jp/watch/sm\" + sm + \"?jsapi=1&amp;playerId=1'frameborder=0 allowfullscreen></iframe><noscript><a href='\" + l + \"'>\" + l + \"</a></noscript>\"\n\t\t\t\treturn media, true\n\t\t\t}\n\t\t}\n\n\t\tif lastFrag := pathFrags[len(pathFrags)-1]; lastFrag != \"\" {\n\t\t\t// TODO: Write a function for getting the file extension of a string\n\t\t\text := strings.TrimPrefix(filepath.Ext(lastFrag), \".\")\n\t\t\tif len(ext) != 0 {\n\t\t\t\tif ImageFileExts.Contains(ext) {\n\t\t\t\t\tmedia.Type = EImage\n\t\t\t\t\tvar sport string\n\t\t\t\t\tif port != \"443\" && port != \"80\" && port != \"\" {\n\t\t\t\t\t\tsport = \":\" + port\n\t\t\t\t\t}\n\t\t\t\t\tmedia.URL = scheme + \"//\" + host + sport + path\n\t\t\t\t\treturn media, true\n\t\t\t\t}\n\t\t\t\t// TODO: Support external videos\n\t\t\t}\n\t\t}\n\t}\n\n\tvar sport string\n\tif port != \"443\" && port != \"80\" && port != \"\" {\n\t\tsport = \":\" + port\n\t}\n\tvar q string\n\tif len(uurl.RawQuery) > 0 {\n\t\tq = \"?\" + uurl.RawQuery\n\t}\n\tvar frag string\n\tif len(uurl.Fragment) > 0 {\n\t\tfrag = \"#\" + uurl.Fragment\n\t}\n\tmedia.FURL = host + sport + path + q + frag\n\tmedia.URL = scheme + \"//\" + media.FURL\n\n\treturn media, true\n}\n\nfunc parseDuration(dur string) (s, m, h int) {\n\tvar ibuf []byte\n\tfor _, ch := range dur {\n\t\tswitch {\n\t\tcase ch > 47 && ch < 58:\n\t\t\tibuf = append(ibuf, byte(ch))\n\t\tcase ch == 'h':\n\t\t\th, _ = strconv.Atoi(string(ibuf))\n\t\t\tibuf = ibuf[:0]\n\t\tcase ch == 'm':\n\t\t\tm, _ = strconv.Atoi(string(ibuf))\n\t\t\tibuf = ibuf[:0]\n\t\tcase ch == 's':\n\t\t\ts, _ = strconv.Atoi(string(ibuf))\n\t\t\tibuf = ibuf[:0]\n\t\t}\n\t}\n\t// Stop accidental uses of timestamps\n\tif h == 0 && m == 0 && s < 2 {\n\t\ts = 0\n\t}\n\treturn s, m, h\n}\n\n// TODO: Write a test for this\nfunc CoerceIntString(data string) (res, length int) {\n\tif !(data[0] > 47 && data[0] < 58) {\n\t\treturn 0, 1\n\t}\n\ti := 0\n\tfor ; len(data) > i; i++ {\n\t\tif !(data[i] > 47 && data[i] < 58) {\n\t\t\tconv, err := strconv.Atoi(data[0:i])\n\t\t\tif err != nil {\n\t\t\t\treturn 0, i\n\t\t\t}\n\t\t\treturn conv, i\n\t\t}\n\t}\n\n\tconv, err := strconv.Atoi(data)\n\tif err != nil {\n\t\treturn 0, i\n\t}\n\treturn conv, i\n}\n\n// TODO: Write tests for this\n// Make sure we reflect changes to this in the JS port in /public/global.js\nfunc Paginate(currentPage, lastPage, maxPages int) (out []int) {\n\tdiff := lastPage - currentPage\n\tpre := 3\n\tif diff < 3 {\n\t\tpre = maxPages - diff\n\t}\n\n\tpage := currentPage - pre\n\tif page < 0 {\n\t\tpage = 0\n\t}\n\tfor len(out) < maxPages && page < lastPage {\n\t\tpage++\n\t\tout = append(out, page)\n\t}\n\treturn out\n}\n\n// TODO: Write tests for this\n// Make sure we reflect changes to this in the JS port in /public/global.js\nfunc PageOffset(count, page, perPage int) (int, int, int) {\n\tvar offset int\n\tlastPage := LastPage(count, perPage)\n\tif page > 1 {\n\t\toffset = (perPage * page) - perPage\n\t} else if page == -1 {\n\t\tpage = lastPage\n\t\toffset = (perPage * page) - perPage\n\t} else {\n\t\tpage = 1\n\t}\n\n\t// ? - This has been commented out as it created a bug in the user manager where the first user on a page wouldn't be accessible\n\t// We don't want the offset to overflow the slices, if everything's in memory\n\t/*if offset >= (count - 1) {\n\t\toffset = 0\n\t}*/\n\treturn offset, page, lastPage\n}\n\n// TODO: Write tests for this\n// Make sure we reflect changes to this in the JS port in /public/global.js\nfunc LastPage(count, perPage int) int {\n\treturn (count / perPage) + 1\n}\n"
  },
  {
    "path": "common/password_reset.go",
    "content": "package common\n\nimport (\n\t\"crypto/subtle\"\n\t\"database/sql\"\n\t\"errors\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar PasswordResetter *DefaultPasswordResetter\nvar ErrBadResetToken = errors.New(\"This reset token has expired.\")\n\ntype DefaultPasswordResetter struct {\n\tgetTokens *sql.Stmt\n\tcreate    *sql.Stmt\n\tdelete    *sql.Stmt\n}\n\n/*\n\ttype PasswordReset struct {\n\t\tEmail string `q:\"email\"`\n\t\tUid int `q:\"uid\"`\n\t\tValidated bool `q:\"validated\"`\n\t\tToken string `q:\"token\"`\n\t\tCreatedAt time.Time `q:\"createdAt\"`\n\t}\n*/\n\nfunc NewDefaultPasswordResetter(acc *qgen.Accumulator) (*DefaultPasswordResetter, error) {\n\tpr := \"password_resets\"\n\treturn &DefaultPasswordResetter{\n\t\tgetTokens: acc.Select(pr).Columns(\"token\").Where(\"uid=?\").Prepare(),\n\t\tcreate:    acc.Insert(pr).Columns(\"email,uid,validated,token,createdAt\").Fields(\"?,?,0,?,UTC_TIMESTAMP()\").Prepare(),\n\t\t//create: acc.Insert(pr).Cols(\"email,uid,validated=0,token,createdAt=UTC_TIMESTAMP()\").Prep(),\n\t\tdelete: acc.Delete(pr).Where(\"uid=?\").Prepare(),\n\t\t//model:  acc.Model(w).Cols(\"email,uid,validated=0,token\").Key(\"uid\").CreatedAt(\"createdAt\").Prep(),\n\t}, acc.FirstError()\n}\n\nfunc (r *DefaultPasswordResetter) Create(email string, uid int, token string) error {\n\t_, err := r.create.Exec(email, uid, token)\n\treturn err\n}\n\nfunc (r *DefaultPasswordResetter) FlushTokens(uid int) error {\n\t_, err := r.delete.Exec(uid)\n\treturn err\n}\n\nfunc (r *DefaultPasswordResetter) ValidateToken(uid int, token string) error {\n\trows, err := r.getTokens.Query(uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tsuccess := false\n\tfor rows.Next() {\n\t\tvar rtoken string\n\t\tif err := rows.Scan(&rtoken); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif subtle.ConstantTimeCompare([]byte(token), []byte(rtoken)) == 1 {\n\t\t\tsuccess = true\n\t\t}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\tif !success {\n\t\treturn ErrBadResetToken\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/permissions.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Refactor the perms system\nvar BlankPerms Perms\nvar GuestPerms Perms\n\n// AllPerms is a set of global permissions with everything set to true\nvar AllPerms Perms\nvar AllPluginPerms = make(map[string]bool)\n\n// ? - Can we avoid duplicating the items in this list in a bunch of places?\nvar GlobalPermList = []string{\n\t\"BanUsers\",\n\t\"ActivateUsers\",\n\t\"EditUser\",\n\t\"EditUserEmail\",\n\t\"EditUserPassword\",\n\t\"EditUserGroup\",\n\t\"EditUserGroupSuperMod\",\n\t\"EditUserGroupAdmin\",\n\t\"EditGroup\",\n\t\"EditGroupLocalPerms\",\n\t\"EditGroupGlobalPerms\",\n\t\"EditGroupSuperMod\",\n\t\"EditGroupAdmin\",\n\t\"ManageForums\",\n\t\"EditSettings\",\n\t\"ManageThemes\",\n\t\"ManagePlugins\",\n\t\"ViewAdminLogs\",\n\t\"ViewIPs\",\n\t\"UploadFiles\",\n\t\"UploadAvatars\",\n\t\"UseConvos\",\n\t\"UseConvosOnlyWithMod\",\n\t\"CreateProfileReply\",\n\t\"AutoEmbed\",\n\t\"AutoLink\",\n}\n\n// Permission Structure: ActionComponent[Subcomponent]Flag\ntype Perms struct {\n\t// Global Permissions\n\tBanUsers              bool `json:\",omitempty\"`\n\tActivateUsers         bool `json:\",omitempty\"`\n\tEditUser              bool `json:\",omitempty\"`\n\tEditUserEmail         bool `json:\",omitempty\"`\n\tEditUserPassword      bool `json:\",omitempty\"`\n\tEditUserGroup         bool `json:\",omitempty\"`\n\tEditUserGroupSuperMod bool `json:\",omitempty\"`\n\tEditUserGroupAdmin    bool `json:\",omitempty\"`\n\tEditGroup             bool `json:\",omitempty\"`\n\tEditGroupLocalPerms   bool `json:\",omitempty\"`\n\tEditGroupGlobalPerms  bool `json:\",omitempty\"`\n\tEditGroupSuperMod     bool `json:\",omitempty\"`\n\tEditGroupAdmin        bool `json:\",omitempty\"`\n\tManageForums          bool `json:\",omitempty\"` // This could be local, albeit limited for per-forum managers?\n\tEditSettings          bool `json:\",omitempty\"`\n\tManageThemes          bool `json:\",omitempty\"`\n\tManagePlugins         bool `json:\",omitempty\"`\n\tViewAdminLogs         bool `json:\",omitempty\"`\n\tViewIPs               bool `json:\",omitempty\"`\n\n\t// Global non-staff permissions\n\tUploadFiles          bool `json:\",omitempty\"`\n\tUploadAvatars        bool `json:\",omitempty\"`\n\tUseConvos            bool `json:\",omitempty\"`\n\tUseConvosOnlyWithMod bool `json:\",omitempty\"`\n\tCreateProfileReply   bool `json:\",omitempty\"`\n\tAutoEmbed            bool `json:\",omitempty\"`\n\tAutoLink             bool `json:\",omitempty\"`\n\n\t// Forum permissions\n\tViewTopic bool `json:\",omitempty\"`\n\t//ViewOwnTopic bool `json:\",omitempty\"`\n\tLikeItem    bool `json:\",omitempty\"`\n\tCreateTopic bool `json:\",omitempty\"`\n\tEditTopic   bool `json:\",omitempty\"`\n\tDeleteTopic bool `json:\",omitempty\"`\n\tCreateReply bool `json:\",omitempty\"`\n\t//CreateReplyToOwn bool `json:\",omitempty\"`\n\tEditReply bool `json:\",omitempty\"`\n\t//EditOwnReply bool `json:\",omitempty\"`\n\tDeleteReply bool `json:\",omitempty\"`\n\t//DeleteOwnReply bool `json:\",omitempty\"`\n\tPinTopic   bool `json:\",omitempty\"`\n\tCloseTopic bool `json:\",omitempty\"`\n\t//CloseOwnTopic bool `json:\",omitempty\"`\n\tMoveTopic bool `json:\",omitempty\"`\n\n\t//ExtData map[string]bool `json:\",omitempty\"`\n}\n\nfunc init() {\n\tBlankPerms = Perms{\n\t\t//ExtData: make(map[string]bool),\n\t}\n\n\tGuestPerms = Perms{\n\t\tViewTopic: true,\n\t\t//ExtData: make(map[string]bool),\n\t}\n\n\tAllPerms = Perms{\n\t\tBanUsers:              true,\n\t\tActivateUsers:         true,\n\t\tEditUser:              true,\n\t\tEditUserEmail:         true,\n\t\tEditUserPassword:      true,\n\t\tEditUserGroup:         true,\n\t\tEditUserGroupSuperMod: true,\n\t\tEditUserGroupAdmin:    true,\n\t\tEditGroup:             true,\n\t\tEditGroupLocalPerms:   true,\n\t\tEditGroupGlobalPerms:  true,\n\t\tEditGroupSuperMod:     true,\n\t\tEditGroupAdmin:        true,\n\t\tManageForums:          true,\n\t\tEditSettings:          true,\n\t\tManageThemes:          true,\n\t\tManagePlugins:         true,\n\t\tViewAdminLogs:         true,\n\t\tViewIPs:               true,\n\n\t\tUploadFiles:          true,\n\t\tUploadAvatars:        true,\n\t\tUseConvos:            true,\n\t\tUseConvosOnlyWithMod: true,\n\t\tCreateProfileReply:   true,\n\t\tAutoEmbed:            true,\n\t\tAutoLink:             true,\n\n\t\tViewTopic:   true,\n\t\tLikeItem:    true,\n\t\tCreateTopic: true,\n\t\tEditTopic:   true,\n\t\tDeleteTopic: true,\n\t\tCreateReply: true,\n\t\tEditReply:   true,\n\t\tDeleteReply: true,\n\t\tPinTopic:    true,\n\t\tCloseTopic:  true,\n\t\tMoveTopic:   true,\n\n\t\t//ExtData: make(map[string]bool),\n\t}\n\n\tGuestUser.Perms = GuestPerms\n\tDebugLogf(\"Guest Perms: %+v\\n\", GuestPerms)\n\tDebugLogf(\"All Perms: %+v\\n\", AllPerms)\n}\n\nfunc StripInvalidGroupForumPreset(preset string) string {\n\tswitch preset {\n\tcase \"read_only\", \"can_post\", \"can_moderate\", \"no_access\", \"default\", \"custom\":\n\t\treturn preset\n\t}\n\treturn \"\"\n}\n\nfunc StripInvalidPreset(preset string) string {\n\tswitch preset {\n\tcase \"all\", \"announce\", \"members\", \"staff\", \"admins\", \"archive\", \"custom\":\n\t\treturn preset\n\t}\n\treturn \"\"\n}\n\n// TODO: Move this into the phrase system?\nfunc PresetToLang(preset string) string {\n\tphrases := phrases.GetAllPermPresets()\n\tphrase, ok := phrases[preset]\n\tif !ok {\n\t\tphrase = phrases[\"unknown\"]\n\t}\n\treturn phrase\n}\n\n// TODO: Is this racey?\n// TODO: Test this along with the rest of the perms system\nfunc RebuildGroupPermissions(g *Group) error {\n\tvar permstr []byte\n\tlog.Print(\"Reloading a group\")\n\n\t// TODO: Avoid re-initting this all the time\n\tgetGroupPerms, e := qgen.Builder.SimpleSelect(\"users_groups\", \"permissions\", \"gid=?\", \"\", \"\")\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer getGroupPerms.Close()\n\n\te = getGroupPerms.QueryRow(g.ID).Scan(&permstr)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\ttmpPerms := Perms{\n\t\t//ExtData: make(map[string]bool),\n\t}\n\te = json.Unmarshal(permstr, &tmpPerms)\n\tif e != nil {\n\t\treturn e\n\t}\n\tg.Perms = tmpPerms\n\treturn nil\n}\n\nfunc OverridePerms(p *Perms, status bool) {\n\tif status {\n\t\t*p = AllPerms\n\t} else {\n\t\t*p = BlankPerms\n\t}\n}\n\n// TODO: We need a better way of overriding forum perms rather than setting them one by one\nfunc OverrideForumPerms(p *Perms, status bool) {\n\tp.ViewTopic = status\n\tp.LikeItem = status\n\tp.CreateTopic = status\n\tp.EditTopic = status\n\tp.DeleteTopic = status\n\tp.CreateReply = status\n\tp.EditReply = status\n\tp.DeleteReply = status\n\tp.PinTopic = status\n\tp.CloseTopic = status\n\tp.MoveTopic = status\n}\n\nfunc RegisterPluginPerm(name string) {\n\tAllPluginPerms[name] = true\n}\n\nfunc DeregisterPluginPerm(name string) {\n\tdelete(AllPluginPerms, name)\n}\n"
  },
  {
    "path": "common/phrases/phrases.go",
    "content": "/*\n*\n* Gosora Phrase System\n* Copyright Azareal 2017 - 2020\n*\n */\npackage phrases\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n)\n\n// TODO: Add a phrase store?\n// TODO: Let the admin edit phrases from inside the Control Panel? How should we persist these? Should we create a copy of the langpack or edit the primaries? Use the changeLangpack mutex for this?\n// nolint Be quiet megacheck, this *is* used\nvar currentLangPack atomic.Value\nvar langPackCount int // TODO: Use atomics for this\n\n// TODO: We'll be implementing the level phrases in the software proper very very soon!\ntype LevelPhrases struct {\n\tLevel    string\n\tLevelMax string // ? Add a max level setting?\n\n\t// Override the phrase for individual levels, if the phrases exist\n\tLevels []string // index = level\n}\n\n// ! For the sake of thread safety, you must never modify a *LanguagePack directly, but to create a copy of it and overwrite the entry in the sync.Map\ntype LanguagePack struct {\n\tName    string\n\tIsoCode string\n\tModTime time.Time\n\t//LastUpdated string\n\n\t// Should we use a sync map or a struct for these? It would be nice, if we could keep all the phrases consistent.\n\tLevels              LevelPhrases\n\tPerms               map[string]string\n\tSettingPhrases      map[string]string\n\tPermPresets         map[string]string\n\tAccounts            map[string]string // TODO: Apply these phrases in the software proper\n\tUserAgents          map[string]string\n\tOperatingSystems    map[string]string\n\tHumanLanguages      map[string]string\n\tErrors              map[string]string // Temp stand-in\n\tErrorsBytes         map[string][]byte\n\tNoticePhrases       map[string]string\n\tPageTitles          map[string]string\n\tTmplPhrases         map[string]string\n\tTmplPhrasesPrefixes map[string]map[string]string // [prefix][name]phrase\n\n\tTmplIndicesToPhrases [][][]byte // [tmplID][index]phrase\n}\n\n// TODO: Add the ability to edit language JSON files from the Control Panel and automatically scan the files for changes\nvar langPacks sync.Map                // nolint it is used\nvar langTmplIndicesToNames [][]string // [tmplID][index]phraseName\n\nfunc InitPhrases(lang string) error {\n\tlog.Print(\"Loading the language packs\")\n\terr := filepath.Walk(\"./langs\", func(path string, f os.FileInfo, err error) error {\n\t\tif f.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\text := filepath.Ext(\"/langs/\" + path)\n\t\tif ext != \".json\" {\n\t\t\tlog.Printf(\"Found a '%s' in /langs/\", ext)\n\t\t\treturn nil\n\t\t}\n\n\t\tdata, err := ioutil.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar langPack LanguagePack\n\t\terr = json.Unmarshal(data, &langPack)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlangPack.ModTime = f.ModTime()\n\n\t\tlangPack.ErrorsBytes = make(map[string][]byte)\n\t\tfor name, phrase := range langPack.Errors {\n\t\t\tlangPack.ErrorsBytes[name] = []byte(phrase)\n\t\t}\n\n\t\t// [prefix][name]phrase\n\t\tlangPack.TmplPhrasesPrefixes = make(map[string]map[string]string)\n\t\tconMap := make(map[string]string) // Cache phrase strings so we can de-dupe items to reduce memory use. There appear to be some minor improvements with this, although we would need a more thorough check to be sure.\n\t\tfor name, phrase := range langPack.TmplPhrases {\n\t\t\t_, ok := conMap[phrase]\n\t\t\tif !ok {\n\t\t\t\tconMap[phrase] = phrase\n\t\t\t}\n\t\t\tcItem := conMap[phrase]\n\t\t\tprefix := strings.Split(name, \".\")[0]\n\t\t\t_, ok = langPack.TmplPhrasesPrefixes[prefix]\n\t\t\tif !ok {\n\t\t\t\tlangPack.TmplPhrasesPrefixes[prefix] = make(map[string]string)\n\t\t\t}\n\t\t\tlangPack.TmplPhrasesPrefixes[prefix][name] = cItem\n\t\t}\n\n\t\t// [prefix][name]phrase\n\t\t/*langPack.TmplPhrasesPrefixes = make(map[string]map[string]string)\n\t\tfor name, phrase := range langPack.TmplPhrases {\n\t\t\tprefix := strings.Split(name, \".\")[0]\n\t\t\t_, ok := langPack.TmplPhrasesPrefixes[prefix]\n\t\t\tif !ok {\n\t\t\t\tlangPack.TmplPhrasesPrefixes[prefix] = make(map[string]string)\n\t\t\t}\n\t\t\tlangPack.TmplPhrasesPrefixes[prefix][name] = phrase\n\t\t}*/\n\n\t\tlangPack.TmplIndicesToPhrases = make([][][]byte, len(langTmplIndicesToNames))\n\t\tfor tmplID, phraseNames := range langTmplIndicesToNames {\n\t\t\tphraseSet := make([][]byte, len(phraseNames))\n\t\t\tfor index, phraseName := range phraseNames {\n\t\t\t\tphrase, ok := langPack.TmplPhrases[phraseName]\n\t\t\t\tif !ok {\n\t\t\t\t\tlog.Printf(\"langPack.TmplPhrases: %+v\\n\", langPack.TmplPhrases)\n\t\t\t\t\tpanic(\"Couldn't find template phrase '\" + phraseName + \"'\")\n\t\t\t\t}\n\t\t\t\tphraseSet[index] = []byte(phrase)\n\t\t\t}\n\t\t\tlangPack.TmplIndicesToPhrases[tmplID] = phraseSet\n\t\t\tTmplIndexCallback(tmplID, phraseSet)\n\t\t}\n\n\t\tlog.Print(\"Adding the '\" + langPack.Name + \"' language pack\")\n\t\tlangPacks.Store(langPack.Name, &langPack)\n\t\tlangPackCount++\n\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tif langPackCount == 0 {\n\t\treturn errors.New(\"You don't have any language packs\")\n\t}\n\n\tlangPack, ok := langPacks.Load(lang)\n\tif !ok {\n\t\treturn errors.New(\"Couldn't find the \" + lang + \" language pack\")\n\t}\n\tcurrentLangPack.Store(langPack)\n\treturn nil\n}\n\n// TODO: Implement this\nfunc LoadLangPack(name string) error {\n\t_ = name\n\treturn nil\n}\n\n// TODO: Implement this\nfunc SaveLangPack(langPack *LanguagePack) error {\n\t_ = langPack\n\treturn nil\n}\n\nfunc GetLangPack() *LanguagePack {\n\treturn currentLangPack.Load().(*LanguagePack)\n}\n\nfunc GetLevelPhrase(level int) string {\n\tlevelPhrases := currentLangPack.Load().(*LanguagePack).Levels\n\tif len(levelPhrases.Levels) > 0 && level < len(levelPhrases.Levels) {\n\t\treturn strings.Replace(levelPhrases.Levels[level], \"{0}\", strconv.Itoa(level), -1)\n\t}\n\treturn strings.Replace(levelPhrases.Level, \"{0}\", strconv.Itoa(level), -1)\n}\n\nfunc GetPermPhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).Perms[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"perms\", name)\n\t}\n\treturn res\n}\n\nfunc GetSettingPhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).SettingPhrases[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"settings\", name)\n\t}\n\treturn res\n}\n\nfunc GetAllSettingPhrases() map[string]string {\n\treturn currentLangPack.Load().(*LanguagePack).SettingPhrases\n}\n\nfunc GetAllPermPresets() map[string]string {\n\treturn currentLangPack.Load().(*LanguagePack).PermPresets\n}\n\nfunc GetAccountPhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).Accounts[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"account\", name)\n\t}\n\treturn res\n}\n\nfunc GetUserAgentPhrase(name string) (string, bool) {\n\tres, ok := currentLangPack.Load().(*LanguagePack).UserAgents[name]\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\treturn res, true\n}\n\nfunc GetOSPhrase(name string) (string, bool) {\n\tres, ok := currentLangPack.Load().(*LanguagePack).OperatingSystems[name]\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\treturn res, true\n}\n\nfunc GetHumanLangPhrase(name string) (string, bool) {\n\tres, ok := currentLangPack.Load().(*LanguagePack).HumanLanguages[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"humanlang\", name), false\n\t}\n\treturn res, true\n}\n\n// TODO: Does comma ok work with multi-dimensional maps?\nfunc GetErrorPhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).Errors[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"error\", name)\n\t}\n\treturn res\n}\nfunc GetErrorPhraseBytes(name string) []byte {\n\tres, ok := currentLangPack.Load().(*LanguagePack).ErrorsBytes[name]\n\tif !ok {\n\t\treturn getPlaceholderBytes(\"error\", name)\n\t}\n\treturn res\n}\n\nfunc GetNoticePhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).NoticePhrases[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"notices\", name)\n\t}\n\treturn res\n}\n\nfunc GetTitlePhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).PageTitles[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"title\", name)\n\t}\n\treturn res\n}\n\nfunc GetTitlePhrasef(name string, params ...interface{}) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).PageTitles[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"title\", name)\n\t}\n\treturn fmt.Sprintf(res, params...)\n}\n\nfunc GetTmplPhrase(name string) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).TmplPhrases[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"tmpl\", name)\n\t}\n\treturn res\n}\n\nfunc GetTmplPhrasef(name string, params ...interface{}) string {\n\tres, ok := currentLangPack.Load().(*LanguagePack).TmplPhrases[name]\n\tif !ok {\n\t\treturn getPlaceholder(\"tmpl\", name)\n\t}\n\treturn fmt.Sprintf(res, params...)\n}\n\nfunc GetTmplPhrases() map[string]string {\n\treturn currentLangPack.Load().(*LanguagePack).TmplPhrases\n}\n\nfunc GetTmplPhrasesByPrefix(prefix string) (phrases map[string]string, ok bool) {\n\tres, ok := currentLangPack.Load().(*LanguagePack).TmplPhrasesPrefixes[prefix]\n\treturn res, ok\n}\n\nfunc getPlaceholder(prefix, suffix string) string {\n\treturn \"{lang.\" + prefix + \"[\" + suffix + \"]}\"\n}\nfunc getPlaceholderBytes(prefix, suffix string) []byte {\n\treturn []byte(\"{lang.\" + prefix + \"[\" + suffix + \"]}\")\n}\n\n// ! Please don't mutate *LanguagePack\nfunc GetCurrentLangPack() *LanguagePack {\n\treturn currentLangPack.Load().(*LanguagePack)\n}\n\n// ? - Use runtime reflection for updating phrases?\n// TODO: Implement these\nfunc AddPhrase() {\n\n}\nfunc UpdatePhrase() {\n\n}\nfunc DeletePhrase() {\n\n}\n\n// TODO: Use atomics to store the pointer of the current active langpack?\n// nolint\nfunc ChangeLanguagePack(name string) (exists bool) {\n\tpack, ok := langPacks.Load(name)\n\tif !ok {\n\t\treturn false\n\t}\n\tcurrentLangPack.Store(pack)\n\treturn true\n}\n\nfunc CurrentLanguagePackName() (name string) {\n\treturn currentLangPack.Load().(*LanguagePack).Name\n}\n\nfunc GetLanguagePackByName(name string) (pack *LanguagePack, ok bool) {\n\tpackInt, ok := langPacks.Load(name)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn packInt.(*LanguagePack), true\n}\n\n// Template Transpiler Stuff\n\nfunc RegisterTmplPhraseNames(phraseNames []string) (tmplID int) {\n\tlangTmplIndicesToNames = append(langTmplIndicesToNames, phraseNames)\n\treturn len(langTmplIndicesToNames) - 1\n}\n\nfunc GetTmplPhrasesBytes(tmplID int) [][]byte {\n\treturn currentLangPack.Load().(*LanguagePack).TmplIndicesToPhrases[tmplID]\n}\n\n// New\n\nvar indexCallbacks []func([][]byte)\n\nfunc TmplIndexCallback(tmplID int, phraseSet [][]byte) {\n\tindexCallbacks[tmplID](phraseSet)\n}\n\nfunc AddTmplIndexCallback(h func([][]byte)) {\n\tindexCallbacks = append(indexCallbacks, h)\n}\n"
  },
  {
    "path": "common/pluginlangs.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io/ioutil\"\n\t\"path/filepath\"\n)\n\nvar pluginLangs = make(map[string]PluginLang)\n\n// For non-native plugins to bind JSON files to. E.g. JS and Lua\ntype PluginMeta struct {\n\tUName    string\n\tName     string\n\tAuthor   string\n\tURL      string\n\tSettings string\n\tTag      string\n\n\tSkip  bool              // Skip this folder?\n\tMain  string            // The main file\n\tHooks map[string]string // Hooks mapped to functions\n}\n\ntype PluginLang interface {\n\tGetName() string\n\tGetExts() []string\n\n\tInit() error\n\tAddPlugin(meta PluginMeta) (*Plugin, error)\n\t//AddHook(name string, handler interface{}) error\n\t//RemoveHook(name string, handler interface{})\n\t//RunHook(name string, data interface{}) interface{}\n\t//RunVHook(name string data ...interface{}) interface{}\n}\n\n/*\nvar ext = filepath.Ext(pluginFile.Name())\nif ext == \".txt\" || ext == \".go\" {\n\tcontinue\n}\n*/\n\nfunc InitPluginLangs() error {\n\tfor _, pluginLang := range pluginLangs {\n\t\tpluginLang.Init()\n\t}\n\tpluginList, err := GetPluginFiles()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, pluginItem := range pluginList {\n\t\tpluginFile, err := ioutil.ReadFile(\"./extend/\" + pluginItem + \"/plugin.json\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar plugin PluginMeta\n\t\terr = json.Unmarshal(pluginFile, &plugin)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif plugin.Skip {\n\t\t\tcontinue\n\t\t}\n\n\t\te := func(field, name string) error {\n\t\t\treturn errors.New(\"The \" + field + \" field must not be blank on plugin '\" + name + \"'\")\n\t\t}\n\t\tif plugin.UName == \"\" {\n\t\t\treturn e(\"UName\", pluginItem)\n\t\t}\n\t\tif plugin.Name == \"\" {\n\t\t\treturn e(\"Name\", pluginItem)\n\t\t}\n\t\tif plugin.Author == \"\" {\n\t\t\treturn e(\"Author\", pluginItem)\n\t\t}\n\t\tif plugin.Main == \"\" {\n\t\t\treturn errors.New(\"Couldn't find a main file for plugin '\" + pluginItem + \"'\")\n\t\t}\n\n\t\text := filepath.Ext(plugin.Main)\n\t\tpluginLang, err := ExtToPluginLang(ext)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpplugin, err := pluginLang.AddPlugin(plugin)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tPlugins[plugin.UName] = pplugin\n\t}\n\treturn nil\n}\n\nfunc GetPluginFiles() (pluginList []string, err error) {\n\tpluginFiles, err := ioutil.ReadDir(\"./extend\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, pluginFile := range pluginFiles {\n\t\tif !pluginFile.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tpluginList = append(pluginList, pluginFile.Name())\n\t}\n\treturn pluginList, nil\n}\n\nfunc ExtToPluginLang(ext string) (PluginLang, error) {\n\tfor _, pluginLang := range pluginLangs {\n\t\tfor _, registeredExt := range pluginLang.GetExts() {\n\t\t\tif registeredExt == ext {\n\t\t\t\treturn pluginLang, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, errors.New(\"No plugin lang handlers are capable of handling extension '\" + ext + \"'\")\n}\n"
  },
  {
    "path": "common/poll.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar pollStmts PollStmts\n\ntype Poll struct {\n\tID          int\n\tParentID    int\n\tParentTable string\n\tType        int  // 0: Single choice, 1: Multiple choice, 2: Multiple choice w/ points\n\tAntiCheat   bool // Apply various mitigations for cheating\n\t// GroupPower map[gid]points // The number of points a group can spend in this poll, defaults to 1\n\n\tOptions      map[int]string\n\tResults      map[int]int  // map[optionIndex]points\n\tQuickOptions []PollOption // TODO: Fix up the template transpiler so we don't need to use this hack anymore\n\tVoteCount    int\n}\n\n// TODO: Use a transaction for this?\n// TODO: Add a voters table with castAt / IP data and only populate it when poll anti-cheat is on\nfunc (p *Poll) CastVote(optionIndex, uid int, ip string) error {\n\tif Config.DisablePollIP || !p.AntiCheat {\n\t\tip = \"\"\n\t}\n\t_, e := pollStmts.addVote.Exec(p.ID, uid, optionIndex, ip)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = pollStmts.incVoteCount.Exec(p.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = pollStmts.incVoteCountForOption.Exec(optionIndex, p.ID)\n\treturn e\n}\n\nfunc (p *Poll) Delete() error {\n\t_, e := pollStmts.deletePollVotes.Exec(p.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = pollStmts.deletePollOptions.Exec(p.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = pollStmts.deletePoll.Exec(p.ID)\n\t_ = Polls.GetCache().Remove(p.ID)\n\treturn e\n}\n\nfunc (p *Poll) Resultsf(f func(votes int) error) error {\n\trows, e := pollStmts.getResults.Query(p.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\n\tvar votes int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&votes); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e := f(votes); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nfunc (p *Poll) Copy() Poll {\n\treturn *p\n}\n\ntype PollStmts struct {\n\tgetResults *sql.Stmt\n\n\taddVote               *sql.Stmt\n\tincVoteCount          *sql.Stmt\n\tincVoteCountForOption *sql.Stmt\n\n\tdeletePoll        *sql.Stmt\n\tdeletePollOptions *sql.Stmt\n\tdeletePollVotes   *sql.Stmt\n}\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tp := \"polls\"\n\t\twh := \"pollID=?\"\n\t\tpollStmts = PollStmts{\n\t\t\tgetResults: acc.Select(\"polls_options\").Columns(\"votes\").Where(\"pollID=?\").Orderby(\"option ASC\").Prepare(),\n\n\t\t\taddVote:               acc.Insert(\"polls_votes\").Columns(\"pollID,uid,option,castAt,ip\").Fields(\"?,?,?,UTC_TIMESTAMP(),?\").Prepare(),\n\t\t\tincVoteCount:          acc.Update(p).Set(\"votes=votes+1\").Where(wh).Prepare(),\n\t\t\tincVoteCountForOption: acc.Update(\"polls_options\").Set(\"votes=votes+1\").Where(\"option=? AND pollID=?\").Prepare(),\n\n\t\t\tdeletePoll:        acc.Delete(p).Where(wh).Prepare(),\n\t\t\tdeletePollOptions: acc.Delete(\"polls_options\").Where(wh).Prepare(),\n\t\t\tdeletePollVotes:   acc.Delete(\"polls_votes\").Where(wh).Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n"
  },
  {
    "path": "common/poll_cache.go",
    "content": "package common\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// PollCache is an interface which spits out polls from a fast cache rather than the database, whether from memory or from an application like Redis. Polls may not be present in the cache but may be in the database\ntype PollCache interface {\n\tGet(id int) (*Poll, error)\n\tGetUnsafe(id int) (*Poll, error)\n\tBulkGet(ids []int) (list []*Poll)\n\tSet(item *Poll) error\n\tAdd(item *Poll) error\n\tAddUnsafe(item *Poll) error\n\tRemove(id int) error\n\tRemoveUnsafe(id int) error\n\tFlush()\n\tLength() int\n\tSetCapacity(capacity int)\n\tGetCapacity() int\n}\n\n// MemoryPollCache stores and pulls polls out of the current process' memory\ntype MemoryPollCache struct {\n\titems    map[int]*Poll\n\tlength   int64\n\tcapacity int\n\n\tsync.RWMutex\n}\n\n// NewMemoryPollCache gives you a new instance of MemoryPollCache\nfunc NewMemoryPollCache(capacity int) *MemoryPollCache {\n\treturn &MemoryPollCache{\n\t\titems:    make(map[int]*Poll),\n\t\tcapacity: capacity,\n\t}\n}\n\n// Get fetches a poll by ID. Returns ErrNoRows if not present.\nfunc (s *MemoryPollCache) Get(id int) (*Poll, error) {\n\ts.RLock()\n\titem, ok := s.items[id]\n\ts.RUnlock()\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// BulkGet fetches multiple polls by their IDs. Indices without polls will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing.\nfunc (s *MemoryPollCache) BulkGet(ids []int) (list []*Poll) {\n\tlist = make([]*Poll, len(ids))\n\ts.RLock()\n\tfor i, id := range ids {\n\t\tlist[i] = s.items[id]\n\t}\n\ts.RUnlock()\n\treturn list\n}\n\n// GetUnsafe fetches a poll by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryPollCache) GetUnsafe(id int) (*Poll, error) {\n\titem, ok := s.items[id]\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// Set overwrites the value of a poll in the cache, whether it's present or not. May return a capacity overflow error.\nfunc (s *MemoryPollCache) Set(item *Poll) error {\n\ts.Lock()\n\tuser, ok := s.items[item.ID]\n\tif ok {\n\t\ts.Unlock()\n\t\t*user = *item\n\t} else if int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t} else {\n\t\ts.items[item.ID] = item\n\t\ts.Unlock()\n\t\tatomic.AddInt64(&s.length, 1)\n\t}\n\treturn nil\n}\n\n// Add adds a poll to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error.\n// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used?\nfunc (s *MemoryPollCache) Add(item *Poll) error {\n\ts.Lock()\n\tif int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\ts.Unlock()\n\treturn nil\n}\n\n// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryPollCache) AddUnsafe(item *Poll) error {\n\tif int(s.length) >= s.capacity {\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\treturn nil\n}\n\n// Remove removes a poll from the cache by ID, if they exist. Returns ErrNoRows if no items exist.\nfunc (s *MemoryPollCache) Remove(id int) error {\n\ts.Lock()\n\t_, ok := s.items[id]\n\tif !ok {\n\t\ts.Unlock()\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryPollCache) RemoveUnsafe(id int) error {\n\t_, ok := s.items[id]\n\tif !ok {\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// Flush removes all the polls from the cache, useful for tests.\nfunc (s *MemoryPollCache) Flush() {\n\tm := make(map[int]*Poll)\n\ts.Lock()\n\ts.items = m\n\ts.length = 0\n\ts.Unlock()\n}\n\n// ! Is this concurrent?\n// Length returns the number of polls in the memory cache\nfunc (s *MemoryPollCache) Length() int {\n\treturn int(s.length)\n}\n\n// SetCapacity sets the maximum number of polls which this cache can hold\nfunc (s *MemoryPollCache) SetCapacity(capacity int) {\n\t// Ints are moved in a single instruction, so this should be thread-safe\n\ts.capacity = capacity\n}\n\n// GetCapacity returns the maximum number of polls this cache can hold\nfunc (s *MemoryPollCache) GetCapacity() int {\n\treturn s.capacity\n}\n\n// NullPollCache is a poll cache to be used when you don't want a cache and just want queries to passthrough to the database\ntype NullPollCache struct {\n}\n\n// NewNullPollCache gives you a new instance of NullPollCache\nfunc NewNullPollCache() *NullPollCache {\n\treturn &NullPollCache{}\n}\n\n// nolint\nfunc (s *NullPollCache) Get(id int) (*Poll, error) {\n\treturn nil, ErrNoRows\n}\nfunc (s *NullPollCache) BulkGet(ids []int) (list []*Poll) {\n\treturn make([]*Poll, len(ids))\n}\nfunc (s *NullPollCache) GetUnsafe(id int) (*Poll, error) {\n\treturn nil, ErrNoRows\n}\nfunc (s *NullPollCache) Set(_ *Poll) error {\n\treturn nil\n}\nfunc (s *NullPollCache) Add(_ *Poll) error {\n\treturn nil\n}\nfunc (s *NullPollCache) AddUnsafe(_ *Poll) error {\n\treturn nil\n}\nfunc (s *NullPollCache) Remove(id int) error {\n\treturn nil\n}\nfunc (s *NullPollCache) RemoveUnsafe(id int) error {\n\treturn nil\n}\nfunc (s *NullPollCache) Flush() {\n}\nfunc (s *NullPollCache) Length() int {\n\treturn 0\n}\nfunc (s *NullPollCache) SetCapacity(_ int) {\n}\nfunc (s *NullPollCache) GetCapacity() int {\n\treturn 0\n}\n"
  },
  {
    "path": "common/poll_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"log\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Polls PollStore\n\ntype PollOption struct {\n\tID    int\n\tValue string\n}\n\ntype Pollable interface {\n\tGetID() int\n\tGetTable() string\n\tSetPoll(pollID int) error\n}\n\ntype PollStore interface {\n\tGet(id int) (*Poll, error)\n\tExists(id int) bool\n\tClearIPs() error\n\tCreate(parent Pollable, pollType int, pollOptions map[int]string) (int, error)\n\tReload(id int) error\n\tCount() int\n\n\tSetCache(cache PollCache)\n\tGetCache() PollCache\n}\n\ntype DefaultPollStore struct {\n\tcache PollCache\n\n\tget              *sql.Stmt\n\texists           *sql.Stmt\n\tcreatePoll       *sql.Stmt\n\tcreatePollOption *sql.Stmt\n\tdelete           *sql.Stmt\n\tcount            *sql.Stmt\n\n\tclearIPs *sql.Stmt\n}\n\nfunc NewDefaultPollStore(cache PollCache) (*DefaultPollStore, error) {\n\tacc := qgen.NewAcc()\n\tif cache == nil {\n\t\tcache = NewNullPollCache()\n\t}\n\t// TODO: Add an admin version of registerStmt with more flexibility?\n\tp := \"polls\"\n\treturn &DefaultPollStore{\n\t\tcache:            cache,\n\t\tget:              acc.Select(p).Columns(\"parentID,parentTable,type,options,votes\").Where(\"pollID=?\").Stmt(),\n\t\texists:           acc.Select(p).Columns(\"pollID\").Where(\"pollID=?\").Stmt(),\n\t\tcreatePoll:       acc.Insert(p).Columns(\"parentID,parentTable,type,options\").Fields(\"?,?,?,?\").Prepare(),\n\t\tcreatePollOption: acc.Insert(\"polls_options\").Columns(\"pollID,option,votes\").Fields(\"?,?,0\").Prepare(),\n\t\tcount:            acc.Count(p).Prepare(),\n\n\t\tclearIPs: acc.Update(\"polls_votes\").Set(\"ip=''\").Where(\"ip!=''\").Stmt(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultPollStore) Exists(id int) bool {\n\te := s.exists.QueryRow(id).Scan(&id)\n\tif e != nil && e != ErrNoRows {\n\t\tLogError(e)\n\t}\n\treturn e != ErrNoRows\n}\n\nfunc (s *DefaultPollStore) Get(id int) (*Poll, error) {\n\tp, err := s.cache.Get(id)\n\tif err == nil {\n\t\treturn p, nil\n\t}\n\n\tp = &Poll{ID: id}\n\tvar optionTxt []byte\n\terr = s.get.QueryRow(id).Scan(&p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = json.Unmarshal(optionTxt, &p.Options)\n\tif err == nil {\n\t\tp.QuickOptions = s.unpackOptionsMap(p.Options)\n\t\ts.cache.Set(p)\n\t}\n\treturn p, err\n}\n\n// TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts?\n// TODO: ID of 0 should always error?\nfunc (s *DefaultPollStore) BulkGetMap(ids []int) (list map[int]*Poll, err error) {\n\tidCount := len(ids)\n\tlist = make(map[int]*Poll)\n\tif idCount == 0 {\n\t\treturn list, nil\n\t}\n\n\tvar stillHere []int\n\tsliceList := s.cache.BulkGet(ids)\n\tfor i, sliceItem := range sliceList {\n\t\tif sliceItem != nil {\n\t\t\tlist[sliceItem.ID] = sliceItem\n\t\t} else {\n\t\t\tstillHere = append(stillHere, ids[i])\n\t\t}\n\t}\n\tids = stillHere\n\n\t// If every user is in the cache, then return immediately\n\tif len(ids) == 0 {\n\t\treturn list, nil\n\t}\n\n\tidList, q := inqbuild(ids)\n\trows, err := qgen.NewAcc().Select(\"polls\").Columns(\"pollID,parentID,parentTable,type,options,votes\").Where(\"pollID IN(\" + q + \")\").Query(idList...)\n\tif err != nil {\n\t\treturn list, err\n\t}\n\n\tfor rows.Next() {\n\t\tp := &Poll{ID: 0}\n\t\tvar optionTxt []byte\n\t\terr := rows.Scan(&p.ID, &p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount)\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\n\t\terr = json.Unmarshal(optionTxt, &p.Options)\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\t\tp.QuickOptions = s.unpackOptionsMap(p.Options)\n\t\ts.cache.Set(p)\n\n\t\tlist[p.ID] = p\n\t}\n\n\t// Did we miss any polls?\n\tif idCount > len(list) {\n\t\tvar sidList string\n\t\tfor _, id := range ids {\n\t\t\tif _, ok := list[id]; !ok {\n\t\t\t\tsidList += strconv.Itoa(id) + \",\"\n\t\t\t}\n\t\t}\n\n\t\t// We probably don't need this, but it might be useful in case of bugs in BulkCascadeGetMap\n\t\tif sidList == \"\" {\n\t\t\t// TODO: Bulk log this\n\t\t\tif Dev.DebugMode {\n\t\t\t\tlog.Print(\"This data is sampled later in the BulkCascadeGetMap function, so it might miss the cached IDs\")\n\t\t\t\tlog.Print(\"idCount\", idCount)\n\t\t\t\tlog.Print(\"ids\", ids)\n\t\t\t\tlog.Print(\"list\", list)\n\t\t\t}\n\t\t\treturn list, errors.New(\"We weren't able to find a poll, but we don't know which one\")\n\t\t}\n\t\tsidList = sidList[0 : len(sidList)-1]\n\n\t\terr = errors.New(\"Unable to find the polls with the following IDs: \" + sidList)\n\t}\n\n\treturn list, err\n}\n\nfunc (s *DefaultPollStore) Reload(id int) error {\n\tp := &Poll{ID: id}\n\tvar optionTxt []byte\n\te := s.get.QueryRow(id).Scan(&p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount)\n\tif e != nil {\n\t\t_ = s.cache.Remove(id)\n\t\treturn e\n\t}\n\te = json.Unmarshal(optionTxt, &p.Options)\n\tif e != nil {\n\t\t_ = s.cache.Remove(id)\n\t\treturn e\n\t}\n\tp.QuickOptions = s.unpackOptionsMap(p.Options)\n\t_ = s.cache.Set(p)\n\treturn nil\n}\n\nfunc (s *DefaultPollStore) unpackOptionsMap(rawOptions map[int]string) []PollOption {\n\topts := make([]PollOption, len(rawOptions))\n\tfor id, opt := range rawOptions {\n\t\topts[id] = PollOption{id, opt}\n\t}\n\treturn opts\n}\n\nfunc (s *DefaultPollStore) ClearIPs() error {\n\t_, e := s.clearIPs.Exec()\n\treturn e\n}\n\n// TODO: Use a transaction for this\nfunc (s *DefaultPollStore) Create(parent Pollable, pollType int, pollOptions map[int]string) (id int, e error) {\n\t// TODO: Move the option names into the polls_options table and get rid of this json sludge?\n\tpollOptionsTxt, e := json.Marshal(pollOptions)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tres, e := s.createPoll.Exec(parent.GetID(), parent.GetTable(), pollType, pollOptionsTxt)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\n\tfor i := 0; i < len(pollOptions); i++ {\n\t\t_, e := s.createPollOption.Exec(lastID, i)\n\t\tif e != nil {\n\t\t\treturn 0, e\n\t\t}\n\t}\n\n\tid = int(lastID)\n\treturn id, parent.SetPoll(id) // TODO: Delete the poll (and options) if SetPoll fails\n}\n\nfunc (s *DefaultPollStore) Count() int {\n\treturn Count(s.count)\n}\n\nfunc (s *DefaultPollStore) SetCache(cache PollCache) {\n\ts.cache = cache\n}\n\n// TODO: We're temporarily doing this so that you can do ucache != nil in getTopicUser. Refactor it.\nfunc (s *DefaultPollStore) GetCache() PollCache {\n\t_, ok := s.cache.(*NullPollCache)\n\tif ok {\n\t\treturn nil\n\t}\n\treturn s.cache\n}\n"
  },
  {
    "path": "common/profile_reply.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"html\"\n\t\"strconv\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar profileReplyStmts ProfileReplyStmts\n\ntype ProfileReply struct {\n\tID           int\n\tParentID     int\n\tContent      string\n\tCreatedBy    int\n\tGroup        int\n\tCreatedAt    time.Time\n\tLastEdit     int\n\tLastEditBy   int\n\tContentLines int\n\tIP           string\n}\n\ntype ProfileReplyStmts struct {\n\tedit   *sql.Stmt\n\tdelete *sql.Stmt\n}\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tur := \"users_replies\"\n\t\tprofileReplyStmts = ProfileReplyStmts{\n\t\t\tedit:   acc.Update(ur).Set(\"content=?,parsed_content=?\").Where(\"rid=?\").Prepare(),\n\t\t\tdelete: acc.Delete(ur).Where(\"rid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// Mostly for tests, so we don't wind up with out-of-date profile reply initialisation logic there\nfunc BlankProfileReply(id int) *ProfileReply {\n\treturn &ProfileReply{ID: id}\n}\n\n// TODO: Write tests for this\nfunc (r *ProfileReply) Delete() error {\n\t_, err := profileReplyStmts.delete.Exec(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// TODO: Better coupling between the two paramsextra queries\n\taids, err := Activity.AidsByParamsExtra(\"reply\", r.ParentID, \"user\", strconv.Itoa(r.ID))\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, aid := range aids {\n\t\tDismissAlert(r.ParentID, aid)\n\t}\n\terr = Activity.DeleteByParamsExtra(\"reply\", r.ParentID, \"user\", strconv.Itoa(r.ID))\n\treturn err\n}\n\nfunc (r *ProfileReply) SetBody(content string) error {\n\tcontent = PreparseMessage(html.UnescapeString(content))\n\t_, err := profileReplyStmts.edit.Exec(content, ParseMessage(content, 0, \"\", nil, nil), r.ID)\n\treturn err\n}\n\n// TODO: We can get this from the topic store instead of a query which will always miss the cache...\nfunc (r *ProfileReply) Creator() (*User, error) {\n\treturn Users.Get(r.CreatedBy)\n}\n"
  },
  {
    "path": "common/profile_reply_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Prstore ProfileReplyStore\n\ntype ProfileReplyStore interface {\n\tGet(id int) (*ProfileReply, error)\n\tExists(id int) bool\n\tClearIPs() error\n\tCreate(profileID int, content string, createdBy int, ip string) (id int, err error)\n\tCount() (count int)\n}\n\n// TODO: Refactor this to stop using the global stmt store\n// TODO: Add more methods to this like Create()\ntype SQLProfileReplyStore struct {\n\tget    *sql.Stmt\n\texists *sql.Stmt\n\tcreate *sql.Stmt\n\tcount  *sql.Stmt\n\n\tclearIPs *sql.Stmt\n}\n\nfunc NewSQLProfileReplyStore(acc *qgen.Accumulator) (*SQLProfileReplyStore, error) {\n\tur := \"users_replies\"\n\treturn &SQLProfileReplyStore{\n\t\tget:    acc.Select(ur).Columns(\"uid,content,createdBy,createdAt,lastEdit,lastEditBy,ip\").Where(\"rid=?\").Stmt(),\n\t\texists: acc.Exists(ur, \"rid\").Prepare(),\n\t\tcreate: acc.Insert(ur).Columns(\"uid,content,parsed_content,createdAt,createdBy,ip\").Fields(\"?,?,?,UTC_TIMESTAMP(),?,?\").Prepare(),\n\t\tcount:  acc.Count(ur).Stmt(),\n\n\t\tclearIPs: acc.Update(ur).Set(\"ip=''\").Where(\"ip!=''\").Stmt(),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLProfileReplyStore) Get(id int) (*ProfileReply, error) {\n\tr := ProfileReply{ID: id}\n\te := s.get.QueryRow(id).Scan(&r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP)\n\treturn &r, e\n}\n\nfunc (s *SQLProfileReplyStore) Exists(id int) bool {\n\te := s.exists.QueryRow(id).Scan(&id)\n\tif e != nil && e != ErrNoRows {\n\t\tLogError(e)\n\t}\n\treturn e != ErrNoRows\n}\n\nfunc (s *SQLProfileReplyStore) ClearIPs() error {\n\t_, e := s.clearIPs.Exec()\n\treturn e\n}\n\nfunc (s *SQLProfileReplyStore) Create(profileID int, content string, createdBy int, ip string) (id int, e error) {\n\tif Config.DisablePostIP {\n\t\tip = \"\"\n\t}\n\tres, e := s.create.Exec(profileID, content, ParseMessage(content, 0, \"\", nil, nil), createdBy, ip)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\t// Should we reload the user?\n\treturn int(lastID), e\n}\n\n// TODO: Write a test for this\n// Count returns the total number of topic replies on these forums\nfunc (s *SQLProfileReplyStore) Count() (count int) {\n\treturn Count(s.count)\n}\n"
  },
  {
    "path": "common/promotions.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t//\"log\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar GroupPromotions GroupPromotionStore\n\ntype GroupPromotion struct {\n\tID     int\n\tFrom   int\n\tTo     int\n\tTwoWay bool\n\n\tLevel         int\n\tPosts         int\n\tMinTime       int\n\tRegisteredFor int\n}\n\ntype GroupPromotionStore interface {\n\tGetByGroup(gid int) (gps []*GroupPromotion, err error)\n\tGet(id int) (*GroupPromotion, error)\n\tPromoteIfEligible(u *User, level, posts int, registeredAt time.Time) error\n\tDelete(id int) error\n\tCreate(from, to int, twoWay bool, level, posts, registeredFor int) (int, error)\n}\n\ntype DefaultGroupPromotionStore struct {\n\tgetByGroup *sql.Stmt\n\tget        *sql.Stmt\n\tdelete     *sql.Stmt\n\tcreate     *sql.Stmt\n\n\tgetByUser     *sql.Stmt\n\tgetByUserMins *sql.Stmt\n\tupdateUser    *sql.Stmt\n\tupdateGeneric *sql.Stmt\n}\n\nfunc NewDefaultGroupPromotionStore(acc *qgen.Accumulator) (*DefaultGroupPromotionStore, error) {\n\tugp := \"users_groups_promotions\"\n\tprs := &DefaultGroupPromotionStore{\n\t\tgetByGroup: acc.Select(ugp).Columns(\"pid, from_gid, to_gid, two_way, level, posts, minTime, registeredFor\").Where(\"from_gid=? OR to_gid=?\").Prepare(),\n\t\tget:        acc.Select(ugp).Columns(\"from_gid, to_gid, two_way, level, posts, minTime, registeredFor\").Where(\"pid=?\").Prepare(),\n\t\tdelete:     acc.Delete(ugp).Where(\"pid=?\").Prepare(),\n\t\tcreate:     acc.Insert(ugp).Columns(\"from_gid, to_gid, two_way, level, posts, minTime, registeredFor\").Fields(\"?,?,?,?,?,?,?\").Prepare(),\n\n\t\tgetByUserMins: acc.Select(ugp).Columns(\"pid, to_gid, two_way, level, posts, minTime, registeredFor\").Where(\"from_gid=? AND level<=? AND posts<=? AND registeredFor<=?\").Orderby(\"level DESC\").Limit(\"1\").Prepare(),\n\t\tgetByUser:     acc.Select(ugp).Columns(\"pid, to_gid, two_way, level, posts, minTime, registeredFor\").Where(\"from_gid=? AND level<=? AND posts<=?\").Orderby(\"level DESC\").Limit(\"1\").Prepare(),\n\t\tupdateUser:    acc.Update(\"users\").Set(\"group=?\").Where(\"group=? AND uid=?\").Prepare(),\n\t\tupdateGeneric: acc.Update(\"users\").Set(\"group=?\").Where(\"group=? AND level>=? AND posts>=?\").Prepare(),\n\t}\n\tTasks.FifteenMin.Add(prs.Tick)\n\treturn prs, acc.FirstError()\n}\n\nfunc (s *DefaultGroupPromotionStore) Tick() error {\n\treturn nil\n}\n\nfunc (s *DefaultGroupPromotionStore) GetByGroup(gid int) (gps []*GroupPromotion, err error) {\n\trows, err := s.getByGroup.Query(gid, gid)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tg := &GroupPromotion{}\n\t\terr := rows.Scan(&g.ID, &g.From, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tgps = append(gps, g)\n\t}\n\treturn gps, rows.Err()\n}\n\n// TODO: Cache the group promotions to avoid hitting the database as much\nfunc (s *DefaultGroupPromotionStore) Get(id int) (*GroupPromotion, error) {\n\t/*g, err := s.cache.Get(id)\n\tif err == nil {\n\t\treturn u, nil\n\t}*/\n\n\tg := &GroupPromotion{ID: id}\n\terr := s.get.QueryRow(id).Scan(&g.From, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor)\n\tif err == nil {\n\t\t//s.cache.Set(u)\n\t}\n\treturn g, err\n}\n\n// TODO: Optimise this to avoid the query\nfunc (s *DefaultGroupPromotionStore) PromoteIfEligible(u *User, level, posts int, registeredAt time.Time) error {\n\tmins := time.Since(registeredAt).Minutes()\n\tg := &GroupPromotion{From: u.Group}\n\t//log.Printf(\"pre getByUserMins: %+v\\n\", u)\n\terr := s.getByUserMins.QueryRow(u.Group, level, posts, mins).Scan(&g.ID, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"no matches found\")\n\t\treturn nil\n\t} else if err != nil {\n\t\treturn err\n\t}\n\t//log.Printf(\"g: %+v\\n\", g)\n\tif g.RegisteredFor == 0 {\n\t\t_, err = s.updateGeneric.Exec(g.To, g.From, g.Level, g.Posts)\n\t} else {\n\t\t_, err = s.updateUser.Exec(g.To, g.From, u.ID)\n\t}\n\treturn err\n}\n\nfunc (s *DefaultGroupPromotionStore) Delete(id int) error {\n\t_, err := s.delete.Exec(id)\n\treturn err\n}\n\nfunc (s *DefaultGroupPromotionStore) Create(from, to int, twoWay bool, level, posts, registeredFor int) (int, error) {\n\tres, err := s.create.Exec(from, to, twoWay, level, posts, 0, registeredFor)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n"
  },
  {
    "path": "common/ratelimit.go",
    "content": "package common\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n)\n\nvar ErrBadRateLimiter = errors.New(\"That rate limiter doesn't exist\")\nvar ErrExceededRateLimit = errors.New(\"You're exceeding a rate limit. Please wait a while before trying again.\")\n\n// TODO: Persist rate limits to disk\ntype RateLimiter interface {\n\tLimitIP(limit, ip string) error\n\tLimitUser(limit string, user int) error\n}\n\ntype RateData struct {\n\tvalue     int\n\tfloorTime int\n}\n\ntype RateFence struct {\n\tduration int\n\tmax      int\n}\n\n// TODO: Optimise this by using something other than a string when possible\ntype RateLimit struct {\n\tdata   map[string][]RateData\n\tfences []RateFence\n\n\tsync.RWMutex\n}\n\nfunc NewRateLimit(fences []RateFence) *RateLimit {\n\tfor i, fence := range fences {\n\t\tfences[i].duration = fence.duration * 1000 * 1000 * 1000\n\t}\n\treturn &RateLimit{data: make(map[string][]RateData), fences: fences}\n}\n\nfunc (l *RateLimit) Limit(name string, ltype int) error {\n\tl.Lock()\n\tdefer l.Unlock()\n\n\tdata, ok := l.data[name]\n\tif !ok {\n\t\tdata = make([]RateData, len(l.fences))\n\t\tfor i, _ := range data {\n\t\t\tdata[i] = RateData{0, int(time.Now().Unix())}\n\t\t}\n\t}\n\n\tfor i, field := range data {\n\t\tfence := l.fences[i]\n\t\tdiff := int(time.Now().Unix()) - field.floorTime\n\n\t\tif diff >= fence.duration {\n\t\t\tfield = RateData{0, int(time.Now().Unix())}\n\t\t\tdata[i] = field\n\t\t}\n\n\t\tif field.value > fence.max {\n\t\t\treturn ErrExceededRateLimit\n\t\t}\n\n\t\tfield.value++\n\t\tdata[i] = field\n\t}\n\n\treturn nil\n}\n\ntype DefaultRateLimiter struct {\n\tlimits map[string]*RateLimit\n}\n\nfunc NewDefaultRateLimiter() *DefaultRateLimiter {\n\treturn &DefaultRateLimiter{map[string]*RateLimit{\n\t\t\"register\": NewRateLimit([]RateFence{{int(time.Hour / 2), 1}}),\n\t}}\n}\n\nfunc (l *DefaultRateLimiter) LimitIP(limit, ip string) error {\n\tlimiter, ok := l.limits[limit]\n\tif !ok {\n\t\treturn ErrBadRateLimiter\n\t}\n\treturn limiter.Limit(ip, 0)\n}\n\nfunc (l *DefaultRateLimiter) LimitUser(limit string, user int) error {\n\tlimiter, ok := l.limits[limit]\n\tif !ok {\n\t\treturn ErrBadRateLimiter\n\t}\n\treturn limiter.Limit(strconv.Itoa(user), 1)\n}\n"
  },
  {
    "path": "common/recalc.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t//\"log\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Recalc RecalcInt\n\ntype RecalcInt interface {\n\tReplies() (count int, err error)\n\tForums() (count int, err error)\n\tSubscriptions() (count int, err error)\n\tActivityStream() (count int, err error)\n\tUsers() error\n\tAttachments() (count int, err error)\n}\n\ntype DefaultRecalc struct {\n\tgetActivitySubscriptions *sql.Stmt\n\tgetActivityStream        *sql.Stmt\n\tgetAttachments           *sql.Stmt\n\tgetTopicCount            *sql.Stmt\n\tresetTopicCount          *sql.Stmt\n}\n\nfunc NewDefaultRecalc(acc *qgen.Accumulator) (*DefaultRecalc, error) {\n\treturn &DefaultRecalc{\n\t\tgetActivitySubscriptions: acc.Select(\"activity_subscriptions\").Columns(\"targetID,targetType\").Prepare(),\n\t\tgetActivityStream:        acc.Select(\"activity_stream\").Columns(\"asid,event,elementID,elementType,extra\").Prepare(),\n\t\tgetAttachments:           acc.Select(\"attachments\").Columns(\"attachID,originID,originTable\").Prepare(),\n\t\tgetTopicCount:            acc.Count(\"topics\").Where(\"parentID=?\").Prepare(),\n\t\t//resetTopicCount:          acc.SimpleUpdateSelect(\"forums\", \"topicCount = tc\", \"topics\", \"count(*) as tc\", \"parentID=?\", \"\", \"\"),\n\t\t// TODO: Avoid using RawPrepare\n\t\tresetTopicCount: acc.RawPrepare(\"UPDATE forums, (SELECT COUNT(*) as tc FROM topics WHERE parentID=?) AS src SET forums.topicCount=src.tc WHERE forums.fid=?\"),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultRecalc) Replies() (count int, err error) {\n\tvar ltid int\n\terr = Rstore.Each(func(r *Reply) error {\n\t\tif ltid == r.ParentID && r.ParentID > 0 {\n\t\t\t//return nil\n\t\t}\n\t\tif !Topics.Exists(r.ParentID) {\n\t\t\t// TODO: Delete in chunks not one at a time?\n\t\t\tif err := r.Delete(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcount++\n\t\t}\n\t\treturn nil\n\t})\n\treturn count, err\n}\n\nfunc (s *DefaultRecalc) Forums() (count int, err error) {\n\terr = Forums.Each(func(f *Forum) error {\n\t\t_, err := s.resetTopicCount.Exec(f.ID, f.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcount++\n\t\treturn nil\n\t})\n\treturn count, err\n}\n\nfunc (s *DefaultRecalc) Subscriptions() (count int, err error) {\n\terr = eachall(s.getActivitySubscriptions, func(r *sql.Rows) error {\n\t\tvar targetID int\n\t\tvar targetType string\n\t\terr := r.Scan(&targetID, &targetType)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif targetType == \"topic\" {\n\t\t\tif !Topics.Exists(targetID) {\n\t\t\t\t// TODO: Delete in chunks not one at a time?\n\t\t\t\terr := Subscriptions.DeleteResource(targetID, targetType)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tcount++\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\treturn count, err\n}\n\ntype Existable interface {\n\tExists(id int) bool\n}\n\nfunc (s *DefaultRecalc) ActivityStream() (count int, err error) {\n\terr = eachall(s.getActivityStream, func(r *sql.Rows) error {\n\t\tvar asid, elementID int\n\t\tvar event, elementType, extra string\n\t\terr := r.Scan(&asid, &event, &elementID, &elementType, &extra)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t//log.Print(\"asid:\",asid)\n\t\tvar s Existable\n\t\tswitch elementType {\n\t\tcase \"user\":\n\t\t\tif event == \"reply\" {\n\t\t\t\textraI, _ := strconv.Atoi(extra)\n\t\t\t\tif extraI > 0 {\n\t\t\t\t\ts = Prstore\n\t\t\t\t\telementID = extraI\n\t\t\t\t} else {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn nil\n\t\t\t}\n\t\tcase \"topic\":\n\t\t\ts = Topics\n\t\t\t// TODO: Delete reply events with an empty extra field\n\t\t\tif event == \"reply\" {\n\t\t\t\textraI, _ := strconv.Atoi(extra)\n\t\t\t\tif extraI > 0 {\n\t\t\t\t\ts = Rstore\n\t\t\t\t\telementID = extraI\n\t\t\t\t}\n\t\t\t}\n\t\tcase \"post\":\n\t\t\ts = Rstore\n\t\t\t// TODO: Add a TopicExistsByReplyID for efficiency\n\t\t\t/*_, err = TopicByReplyID(elementID)\n\t\t\tif err == sql.ErrNoRows {\n\t\t\t\t// TODO: Delete in chunks not one at a time?\n\t\t\t\terr := Activity.Delete(asid)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tcount++\n\t\t\t} else if err != nil {\n\t\t\t\treturn err\n\t\t\t}*/\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif !s.Exists(elementID) {\n\t\t\t// TODO: Delete in chunks not one at a time?\n\t\t\terr := Activity.Delete(asid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcount++\n\t\t}\n\t\treturn nil\n\t})\n\treturn count, err\n}\n\nfunc (s *DefaultRecalc) Users() error {\n\treturn Users.Each(func(u *User) error {\n\t\treturn u.RecalcPostStats()\n\t})\n}\n\nfunc (s *DefaultRecalc) Attachments() (count int, err error) {\n\terr = eachall(s.getAttachments, func(r *sql.Rows) error {\n\t\tvar aid, originID int\n\t\tvar originType string\n\t\terr := r.Scan(&aid, &originID, &originType)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tvar s Existable\n\t\tswitch originType {\n\t\tcase \"topics\":\n\t\t\ts = Topics\n\t\tcase \"replies\":\n\t\t\ts = Rstore\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif !s.Exists(originID) {\n\t\t\t// TODO: Delete in chunks not one at a time?\n\t\t\terr := Attachments.Delete(aid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcount++\n\t\t}\n\t\treturn nil\n\t})\n\treturn count, err\n}\n"
  },
  {
    "path": "common/relations.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar UserBlocks BlockStore\n\n//var UserFriends FriendStore\n\ntype BlockStore interface {\n\tIsBlockedBy(blocker, blockee int) (bool, error)\n\tBulkIsBlockedBy(blockers []int, blockee int) (bool, error)\n\tAdd(blocker, blockee int) error\n\tRemove(blocker, blockee int) error\n\tBlockedByOffset(blocker, offset, perPage int) ([]int, error)\n\tBlockedByCount(blocker int) int\n}\n\ntype DefaultBlockStore struct {\n\tisBlocked      *sql.Stmt\n\tadd            *sql.Stmt\n\tremove         *sql.Stmt\n\tblockedBy      *sql.Stmt\n\tblockedByCount *sql.Stmt\n}\n\nfunc NewDefaultBlockStore(acc *qgen.Accumulator) (*DefaultBlockStore, error) {\n\tub := \"users_blocks\"\n\treturn &DefaultBlockStore{\n\t\tisBlocked:      acc.Select(ub).Cols(\"blocker\").Where(\"blocker=? AND blockedUser=?\").Prepare(),\n\t\tadd:            acc.Insert(ub).Columns(\"blocker,blockedUser\").Fields(\"?,?\").Prepare(),\n\t\tremove:         acc.Delete(ub).Where(\"blocker=? AND blockedUser=?\").Prepare(),\n\t\tblockedBy:      acc.Select(ub).Columns(\"blockedUser\").Where(\"blocker=?\").Limit(\"?,?\").Prepare(),\n\t\tblockedByCount: acc.Count(ub).Where(\"blocker=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultBlockStore) IsBlockedBy(blocker, blockee int) (bool, error) {\n\te := s.isBlocked.QueryRow(blocker, blockee).Scan(&blocker)\n\tif e == ErrNoRows {\n\t\treturn false, nil\n\t}\n\treturn e == nil, e\n}\n\n// TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts?\nfunc (s *DefaultBlockStore) BulkIsBlockedBy(blockers []int, blockee int) (bool, error) {\n\tif len(blockers) == 0 {\n\t\treturn false, nil\n\t}\n\tif len(blockers) == 1 {\n\t\treturn s.IsBlockedBy(blockers[0], blockee)\n\t}\n\tidList, q := inqbuild(blockers)\n\tcount, e := qgen.NewAcc().Count(\"users_blocks\").Where(\"blocker IN(\" + q + \") AND blockedUser=?\").TotalP(idList...)\n\tif e == ErrNoRows {\n\t\treturn false, nil\n\t}\n\treturn count == 0, e\n}\n\nfunc (s *DefaultBlockStore) Add(blocker, blockee int) error {\n\t_, e := s.add.Exec(blocker, blockee)\n\treturn e\n}\n\nfunc (s *DefaultBlockStore) Remove(blocker, blockee int) error {\n\t_, e := s.remove.Exec(blocker, blockee)\n\treturn e\n}\n\nfunc (s *DefaultBlockStore) BlockedByOffset(blocker, offset, perPage int) (uids []int, err error) {\n\trows, e := s.blockedBy.Query(blocker, offset, perPage)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar uid int\n\t\tif e := rows.Scan(&uid); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tuids = append(uids, uid)\n\t}\n\treturn uids, rows.Err()\n}\n\nfunc (s *DefaultBlockStore) BlockedByCount(blocker int) (count int) {\n\te := s.blockedByCount.QueryRow(blocker).Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n\ntype FriendInvite struct {\n\tRequester int\n\tTarget    int\n}\n\ntype FriendStore interface {\n\tAddInvite(requester, target int) error\n\tConfirm(requester, target int) error\n\tGetOwSentInvites(uid int) ([]FriendInvite, error)\n\tGetOwnRecvInvites(uid int) ([]FriendInvite, error)\n}\n\ntype DefaultFriendStore struct {\n\taddInvite         *sql.Stmt\n\tconfirm           *sql.Stmt\n\tgetOwnSentInvites *sql.Stmt\n\tgetOwnRecvInvites *sql.Stmt\n}\n\nfunc NewDefaultFriendStore(acc *qgen.Accumulator) (*DefaultFriendStore, error) {\n\tufi := \"users_friends_invites\"\n\treturn &DefaultFriendStore{\n\t\taddInvite:         acc.Insert(ufi).Columns(\"requester,target\").Fields(\"?,?\").Prepare(),\n\t\tconfirm:           acc.Insert(\"users_friends\").Columns(\"uid,uid2\").Fields(\"?,?\").Prepare(),\n\t\tgetOwnSentInvites: acc.Select(ufi).Cols(\"requester,target\").Where(\"requester=?\").Prepare(),\n\t\tgetOwnRecvInvites: acc.Select(ufi).Cols(\"requester,target\").Where(\"target=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultFriendStore) AddInvite(requester, target int) error {\n\t_, e := s.addInvite.Exec(requester, target)\n\treturn e\n}\n\nfunc (s *DefaultFriendStore) Confirm(requester, target int) error {\n\t_, e := s.confirm.Exec(requester, target)\n\treturn e\n}\n\nfunc (s *DefaultFriendStore) GetOwnSentInvites(uid int) ([]FriendInvite, error) {\n\treturn nil, nil\n}\nfunc (s *DefaultFriendStore) GetOwnRecvInvites(uid int) ([]FriendInvite, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "common/reply.go",
    "content": "/*\n*\n* Reply Resources File\n* Copyright Azareal 2016 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"html\"\n\t\"strconv\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype ReplyUser struct {\n\tReply\n\n\tContentHtml   string\n\tUserLink      string\n\tCreatedByName string\n\tAvatar        string\n\tMicroAvatar   string\n\tClassName     string\n\tTag           string\n\tURL           string\n\t//URLPrefix string\n\t//URLName   string\n\tGroup      int\n\tLevel      int\n\tActionIcon string\n\n\tAttachments []*MiniAttachment\n\tDeletable   bool\n}\n\ntype Reply struct {\n\tID        int\n\tParentID  int\n\tContent   string\n\tCreatedBy int\n\t//Group        int\n\tCreatedAt    time.Time\n\tLastEdit     int\n\tLastEditBy   int\n\tContentLines int\n\tIP           string\n\tLiked        bool\n\tLikeCount    int\n\tAttachCount  uint16\n\tActionType   string\n}\n\nvar ErrAlreadyLiked = errors.New(\"You already liked this!\")\nvar replyStmts ReplyStmts\n\ntype ReplyStmts struct {\n\tisLiked                *sql.Stmt\n\tcreateLike             *sql.Stmt\n\tedit                   *sql.Stmt\n\tsetPoll                *sql.Stmt\n\tdelete                 *sql.Stmt\n\taddLikesToReply        *sql.Stmt\n\tremoveRepliesFromTopic *sql.Stmt\n\tdeleteLikesForReply    *sql.Stmt\n\tdeleteActivity         *sql.Stmt\n\tdeleteActivitySubs     *sql.Stmt\n\n\tupdateTopicReplies  *sql.Stmt\n\tupdateTopicReplies2 *sql.Stmt\n\n\tgetAidsOfReply *sql.Stmt\n}\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tre := \"replies\"\n\t\treplyStmts = ReplyStmts{\n\t\t\tisLiked:                acc.Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? and targetItem=? and targetType='replies'\").Prepare(),\n\t\t\tcreateLike:             acc.Insert(\"likes\").Columns(\"weight,targetItem,targetType,sentBy,createdAt\").Fields(\"?,?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t\t\tedit:                   acc.Update(re).Set(\"content=?,parsed_content=?\").Where(\"rid=? AND poll=0\").Prepare(),\n\t\t\tsetPoll:                acc.Update(re).Set(\"poll=?\").Where(\"rid=? AND poll=0\").Prepare(),\n\t\t\tdelete:                 acc.Delete(re).Where(\"rid=?\").Prepare(),\n\t\t\taddLikesToReply:        acc.Update(re).Set(\"likeCount=likeCount+?\").Where(\"rid=?\").Prepare(),\n\t\t\tremoveRepliesFromTopic: acc.Update(\"topics\").Set(\"postCount=postCount-?\").Where(\"tid=?\").Prepare(),\n\t\t\tdeleteLikesForReply:    acc.Delete(\"likes\").Where(\"targetItem=? AND targetType='replies'\").Prepare(),\n\t\t\tdeleteActivity:         acc.Delete(\"activity_stream\").Where(\"elementID=? AND elementType='post'\").Prepare(),\n\t\t\tdeleteActivitySubs:     acc.Delete(\"activity_subscriptions\").Where(\"targetID=? AND targetType='post'\").Prepare(),\n\n\t\t\t// TODO: Optimise this to avoid firing an update if it's not the last reply in a topic. We will need to set lastReplyID properly in other places and in the patcher first so we can use it here.\n\t\t\tupdateTopicReplies:  acc.RawPrepare(\"UPDATE topics t INNER JOIN replies r ON t.tid=r.tid SET t.lastReplyBy=r.createdBy, t.lastReplyAt=r.createdAt, t.lastReplyID=r.rid WHERE t.tid=? ORDER BY r.rid DESC\"),\n\t\t\tupdateTopicReplies2: acc.Update(\"topics\").Set(\"lastReplyAt=createdAt,lastReplyBy=createdBy,lastReplyID=0\").Where(\"postCount=1 AND tid=?\").Prepare(),\n\n\t\t\tgetAidsOfReply: acc.Select(\"attachments\").Columns(\"attachID\").Where(\"originID=? AND originTable='replies'\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// TODO: Write tests for this\n// TODO: Wrap these queries in a transaction to make sure the state is consistent\nfunc (r *Reply) Like(uid int) (err error) {\n\tvar rid int // unused, just here to avoid mutating reply.ID\n\terr = replyStmts.isLiked.QueryRow(uid, r.ID).Scan(&rid)\n\tif err != nil && err != ErrNoRows {\n\t\treturn err\n\t} else if err != ErrNoRows {\n\t\treturn ErrAlreadyLiked\n\t}\n\n\tscore := 1\n\t_, err = replyStmts.createLike.Exec(score, r.ID, \"replies\", uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.addLikesToReply.Exec(1, r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = userStmts.incLiked.Exec(1, uid)\n\t_ = Rstore.GetCache().Remove(r.ID)\n\treturn err\n}\n\n// TODO: Use a transaction\nfunc (r *Reply) Unlike(uid int) error {\n\terr := Likes.Delete(r.ID, \"replies\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.addLikesToReply.Exec(-1, r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = userStmts.decLiked.Exec(1, uid)\n\t_ = Rstore.GetCache().Remove(r.ID)\n\treturn err\n}\n\n// TODO: Refresh topic list?\nfunc (r *Reply) Delete() error {\n\tcreator, err := Users.Get(r.CreatedBy)\n\tif err == nil {\n\t\terr = creator.DecreasePostStats(WordCount(r.Content), false)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if err != ErrNoRows {\n\t\treturn err\n\t}\n\n\t_, err = replyStmts.delete.Exec(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// TODO: Move this bit to *Topic\n\t_, err = replyStmts.removeRepliesFromTopic.Exec(1, r.ParentID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.updateTopicReplies.Exec(r.ParentID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.updateTopicReplies2.Exec(r.ParentID)\n\ttc := Topics.GetCache()\n\tif tc != nil {\n\t\ttc.Remove(r.ParentID)\n\t}\n\t_ = Rstore.GetCache().Remove(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.deleteLikesForReply.Exec(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = handleReplyAttachments(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = Activity.DeleteByParamsExtra(\"reply\", r.ParentID, \"topic\", strconv.Itoa(r.ID))\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.deleteActivitySubs.Exec(r.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = replyStmts.deleteActivity.Exec(r.ID)\n\treturn err\n}\n\nfunc (r *Reply) SetPost(content string) error {\n\ttopic, err := r.Topic()\n\tif err != nil {\n\t\treturn err\n\t}\n\tcontent = PreparseMessage(html.UnescapeString(content))\n\tparsedContent := ParseMessage(content, topic.ParentID, \"forums\", nil, nil)\n\t_, err = replyStmts.edit.Exec(content, parsedContent, r.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll\n\t_ = Rstore.GetCache().Remove(r.ID)\n\treturn err\n}\n\n// TODO: Write tests for this\nfunc (r *Reply) SetPoll(pollID int) error {\n\t_, err := replyStmts.setPoll.Exec(pollID, r.ID) // TODO: Sniff if this changed anything to see if we hit a poll\n\t_ = Rstore.GetCache().Remove(r.ID)\n\treturn err\n}\n\nfunc (r *Reply) Topic() (*Topic, error) {\n\treturn Topics.Get(r.ParentID)\n}\n\nfunc (r *Reply) GetID() int {\n\treturn r.ID\n}\n\nfunc (r *Reply) GetTable() string {\n\treturn \"replies\"\n}\n\n// Copy gives you a non-pointer concurrency safe copy of the reply\nfunc (r *Reply) Copy() Reply {\n\treturn *r\n}\n"
  },
  {
    "path": "common/reply_cache.go",
    "content": "package common\n\nimport (\n\t//\"log\"\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// ReplyCache is an interface which spits out replies from a fast cache rather than the database, whether from memory or from an application like Redis. Replies may not be present in the cache but may be in the database\ntype ReplyCache interface {\n\tGet(id int) (*Reply, error)\n\tGetUnsafe(id int) (*Reply, error)\n\tBulkGet(ids []int) (list []*Reply)\n\tSet(item *Reply) error\n\tAdd(item *Reply) error\n\tAddUnsafe(item *Reply) error\n\tRemove(id int) error\n\tRemoveUnsafe(id int) error\n\tFlush()\n\tLength() int\n\tSetCapacity(cap int)\n\tGetCapacity() int\n}\n\n// MemoryReplyCache stores and pulls replies out of the current process' memory\ntype MemoryReplyCache struct {\n\titems    map[int]*Reply\n\tlength   int64 // sync/atomic only lets us operate on int32s and int64s\n\tcapacity int\n\n\tsync.RWMutex\n}\n\n// NewMemoryReplyCache gives you a new instance of MemoryReplyCache\nfunc NewMemoryReplyCache(cap int) *MemoryReplyCache {\n\treturn &MemoryReplyCache{\n\t\titems:    make(map[int]*Reply),\n\t\tcapacity: cap,\n\t}\n}\n\n// Get fetches a reply by ID. Returns ErrNoRows if not present.\nfunc (s *MemoryReplyCache) Get(id int) (*Reply, error) {\n\ts.RLock()\n\titem, ok := s.items[id]\n\ts.RUnlock()\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// GetUnsafe fetches a reply by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryReplyCache) GetUnsafe(id int) (*Reply, error) {\n\titem, ok := s.items[id]\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// BulkGet fetches multiple replies by their IDs. Indices without replies will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing.\nfunc (s *MemoryReplyCache) BulkGet(ids []int) (list []*Reply) {\n\tlist = make([]*Reply, len(ids))\n\ts.RLock()\n\tfor i, id := range ids {\n\t\tlist[i] = s.items[id]\n\t}\n\ts.RUnlock()\n\treturn list\n}\n\n// Set overwrites the value of a reply in the cache, whether it's present or not. May return a capacity overflow error.\nfunc (s *MemoryReplyCache) Set(item *Reply) error {\n\ts.Lock()\n\t_, ok := s.items[item.ID]\n\tif ok {\n\t\ts.items[item.ID] = item\n\t} else if int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t} else {\n\t\ts.items[item.ID] = item\n\t\tatomic.AddInt64(&s.length, 1)\n\t}\n\ts.Unlock()\n\treturn nil\n}\n\n// Add adds a reply to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error.\n// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used?\nfunc (s *MemoryReplyCache) Add(item *Reply) error {\n\t//log.Print(\"MemoryReplyCache.Add\")\n\ts.Lock()\n\tif int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, 1)\n\treturn nil\n}\n\n// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryReplyCache) AddUnsafe(item *Reply) error {\n\tif int(s.length) >= s.capacity {\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\treturn nil\n}\n\n// Remove removes a reply from the cache by ID, if they exist. Returns ErrNoRows if no items exist.\nfunc (s *MemoryReplyCache) Remove(id int) error {\n\ts.Lock()\n\t_, ok := s.items[id]\n\tif !ok {\n\t\ts.Unlock()\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryReplyCache) RemoveUnsafe(id int) error {\n\t_, ok := s.items[id]\n\tif !ok {\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// Flush removes all the replies from the cache, useful for tests.\nfunc (s *MemoryReplyCache) Flush() {\n\ts.Lock()\n\ts.items = make(map[int]*Reply)\n\ts.length = 0\n\ts.Unlock()\n}\n\n// ! Is this concurrent?\n// Length returns the number of replies in the memory cache\nfunc (s *MemoryReplyCache) Length() int {\n\treturn int(s.length)\n}\n\n// SetCapacity sets the maximum number of replies which this cache can hold\nfunc (s *MemoryReplyCache) SetCapacity(cap int) {\n\t// Ints are moved in a single instruction, so this should be thread-safe\n\ts.capacity = cap\n}\n\n// GetCapacity returns the maximum number of replies this cache can hold\nfunc (s *MemoryReplyCache) GetCapacity() int {\n\treturn s.capacity\n}\n"
  },
  {
    "path": "common/reply_store.go",
    "content": "package common\n\n//import \"log\"\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Rstore ReplyStore\n\ntype ReplyStore interface {\n\tGet(id int) (*Reply, error)\n\tEach(f func(*Reply) error) error\n\tExists(id int) bool\n\tClearIPs() error\n\tCreate(t *Topic, content, ip string, uid int) (id int, err error)\n\tCount() (count int)\n\tCountUser(uid int) (count int)\n\tCountMegaUser(uid int) (count int)\n\tCountBigUser(uid int) (count int)\n\n\tSetCache(cache ReplyCache)\n\tGetCache() ReplyCache\n}\n\ntype SQLReplyStore struct {\n\tcache ReplyCache\n\n\tget           *sql.Stmt\n\tgetAll        *sql.Stmt\n\texists        *sql.Stmt\n\tcreate        *sql.Stmt\n\tcount         *sql.Stmt\n\tcountUser     *sql.Stmt\n\tcountWordUser *sql.Stmt\n\n\tclearIPs *sql.Stmt\n}\n\nfunc NewSQLReplyStore(acc *qgen.Accumulator, cache ReplyCache) (*SQLReplyStore, error) {\n\tif cache == nil {\n\t\tcache = NewNullReplyCache()\n\t}\n\tre := \"replies\"\n\treturn &SQLReplyStore{\n\t\tcache:         cache,\n\t\tget:           acc.Select(re).Columns(\"tid,content,createdBy,createdAt,lastEdit,lastEditBy,ip,likeCount,attachCount,actionType\").Where(\"rid=?\").Prepare(),\n\t\tgetAll:        acc.Select(re).Columns(\"rid,tid,content,createdBy,createdAt,lastEdit,lastEditBy,ip,likeCount,attachCount,actionType\").Prepare(),\n\t\texists:        acc.Exists(re, \"rid\").Prepare(),\n\t\tcreate:        acc.Insert(re).Columns(\"tid,content,parsed_content,createdAt,lastUpdated,ip,words,createdBy\").Fields(\"?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),?,?,?\").Prepare(),\n\t\tcount:         acc.Count(re).Prepare(),\n\t\tcountUser:     acc.Count(re).Where(\"createdBy=?\").Prepare(),\n\t\tcountWordUser: acc.Count(re).Where(\"createdBy=? AND words>=?\").Prepare(),\n\n\t\tclearIPs: acc.Update(re).Set(\"ip=''\").Where(\"ip!=''\").Stmt(),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLReplyStore) Get(id int) (*Reply, error) {\n\tr, err := s.cache.Get(id)\n\tif err == nil {\n\t\treturn r, nil\n\t}\n\n\tr = &Reply{ID: id}\n\terr = s.get.QueryRow(id).Scan(&r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType)\n\tif err == nil {\n\t\t_ = s.cache.Set(r)\n\t}\n\treturn r, err\n}\n\n/*func (s *SQLReplyStore) eachr(f func(*sql.Rows) error) error {\n\treturn eachall(s.getAll, f)\n}*/\n\nfunc (s *SQLReplyStore) Each(f func(*Reply) error) error {\n\trows, err := s.getAll.Query()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tr := new(Reply)\n\t\tif err := rows.Scan(&r.ID, &r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := f(r); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nfunc (s *SQLReplyStore) Exists(id int) bool {\n\terr := s.exists.QueryRow(id).Scan(&id)\n\tif err != nil && err != ErrNoRows {\n\t\tLogError(err)\n\t}\n\treturn err != ErrNoRows\n}\n\nfunc (s *SQLReplyStore) ClearIPs() error {\n\t_, e := s.clearIPs.Exec()\n\treturn e\n}\n\n// TODO: Write a test for this\nfunc (s *SQLReplyStore) Create(t *Topic, content, ip string, uid int) (id int, err error) {\n\tif Config.DisablePostIP {\n\t\tip = \"\"\n\t}\n\tres, err := s.create.Exec(t.ID, content, ParseMessage(content, t.ParentID, \"forums\", nil, nil), ip, WordCount(content), uid)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tid = int(lastID)\n\treturn id, t.AddReply(id, uid)\n}\n\n// TODO: Write a test for this\n// Count returns the total number of topic replies on these forums\nfunc (s *SQLReplyStore) Count() (count int) {\n\treturn Countf(s.count)\n}\nfunc (s *SQLReplyStore) CountUser(uid int) (count int) {\n\treturn Countf(s.countUser, uid)\n}\nfunc (s *SQLReplyStore) CountMegaUser(uid int) (count int) {\n\treturn Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)[\"megapost_min_words\"].(int))\n}\nfunc (s *SQLReplyStore) CountBigUser(uid int) (count int) {\n\treturn Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)[\"bigpost_min_words\"].(int))\n}\n\nfunc (s *SQLReplyStore) SetCache(cache ReplyCache) {\n\ts.cache = cache\n}\n\nfunc (s *SQLReplyStore) GetCache() ReplyCache {\n\treturn s.cache\n}\n"
  },
  {
    "path": "common/report_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Make the default report forum ID configurable\n// TODO: Make sure this constant is used everywhere for the report forum ID\nconst ReportForumID = 1\n\nvar Reports ReportStore\nvar ErrAlreadyReported = errors.New(\"This item has already been reported\")\n\n// The report system mostly wraps around the topic system for simplicty\ntype ReportStore interface {\n\tCreate(title, content string, u *User, itemType string, itemID int) (int, error)\n}\n\ntype DefaultReportStore struct {\n\tcreate *sql.Stmt\n\texists *sql.Stmt\n}\n\nfunc NewDefaultReportStore(acc *qgen.Accumulator) (*DefaultReportStore, error) {\n\tt := \"topics\"\n\treturn &DefaultReportStore{\n\t\tcreate: acc.Insert(t).Columns(\"title, content, parsed_content, ip, createdAt, lastReplyAt, createdBy, lastReplyBy, data, parentID, css_class\").Fields(\"?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),?,?,?,?,'report'\").Prepare(),\n\t\texists: acc.Count(t).Where(\"data=? AND data!='' AND parentID=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\n// ! There's a data race in this. If two users report one item at the exact same time, then both reports will go through\nfunc (s *DefaultReportStore) Create(title, content string, u *User, itemType string, itemID int) (tid int, err error) {\n\tvar count int\n\terr = s.exists.QueryRow(itemType+\"_\"+strconv.Itoa(itemID), ReportForumID).Scan(&count)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn 0, err\n\t}\n\tif count != 0 {\n\t\treturn 0, ErrAlreadyReported\n\t}\n\n\tip := u.GetIP()\n\tif Config.DisablePostIP {\n\t\tip = \"\"\n\t}\n\tres, err := s.create.Exec(title, content, ParseMessage(content, 0, \"\", nil, nil), ip, u.ID, u.ID, itemType+\"_\"+strconv.Itoa(itemID), ReportForumID)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\ttid = int(lastID)\n\n\treturn tid, Forums.AddTopic(tid, u.ID, ReportForumID)\n}\n"
  },
  {
    "path": "common/routes_common.go",
    "content": "package common\n\nimport (\n\t\"crypto/subtle\"\n\t\"html\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\t\"github.com/Azareal/Gosora/uutils\"\n)\n\n// nolint\nvar PreRoute func(http.ResponseWriter, *http.Request) (User, bool) = preRoute\n\n// TODO: Come up with a better middleware solution\n// nolint We need these types so people can tell what they are without scrolling to the bottom of the file\nvar PanelUserCheck func(http.ResponseWriter, *http.Request, *User) (*Header, PanelStats, RouteError) = panelUserCheck\nvar SimplePanelUserCheck func(http.ResponseWriter, *http.Request, *User) (*HeaderLite, RouteError) = simplePanelUserCheck\nvar SimpleForumUserCheck func(w http.ResponseWriter, r *http.Request, u *User, fid int) (headerLite *HeaderLite, err RouteError) = simpleForumUserCheck\nvar ForumUserCheck func(h *Header, w http.ResponseWriter, r *http.Request, u *User, fid int) (err RouteError) = forumUserCheck\nvar SimpleUserCheck func(w http.ResponseWriter, r *http.Request, u *User) (headerLite *HeaderLite, err RouteError) = simpleUserCheck\nvar UserCheck func(w http.ResponseWriter, r *http.Request, u *User) (h *Header, err RouteError) = userCheck\nvar UserCheckNano func(w http.ResponseWriter, r *http.Request, u *User, nano int64) (h *Header, err RouteError) = userCheck2\n\nfunc simpleForumUserCheck(w http.ResponseWriter, r *http.Request, u *User, fid int) (h *HeaderLite, rerr RouteError) {\n\th, rerr = SimpleUserCheck(w, r, u)\n\tif rerr != nil {\n\t\treturn h, rerr\n\t}\n\tif !Forums.Exists(fid) {\n\t\treturn nil, PreError(\"The target forum doesn't exist.\", w, r)\n\t}\n\n\t// Is there a better way of doing the skip AND the success flag on this hook like multiple returns?\n\t/*skip, rerr := h.Hooks.VhookSkippable(\"simple_forum_check_pre_perms\", w, r, u, &fid, h)\n\tif skip || rerr != nil {\n\t\treturn h, rerr\n\t}*/\n\tskip, rerr := H_simple_forum_check_pre_perms_hook(h.Hooks, w, r, u, &fid, h)\n\tif skip || rerr != nil {\n\t\treturn h, rerr\n\t}\n\n\tfp, err := FPStore.Get(fid, u.Group)\n\tif err == ErrNoRows {\n\t\tfp = BlankForumPerms()\n\t} else if err != nil {\n\t\treturn h, InternalError(err, w, r)\n\t}\n\tcascadeForumPerms(fp, u)\n\treturn h, nil\n}\n\nfunc forumUserCheck(h *Header, w http.ResponseWriter, r *http.Request, u *User, fid int) (rerr RouteError) {\n\tif !Forums.Exists(fid) {\n\t\treturn NotFound(w, r, h)\n\t}\n\n\t/*skip, rerr := h.Hooks.VhookSkippable(\"forum_check_pre_perms\", w, r, u, &fid, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}*/\n\t/*skip, rerr := VhookSkippableTest(h.Hooks, \"forum_check_pre_perms\", w, r, u, &fid, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}*/\n\tskip, rerr := H_forum_check_pre_perms_hook(h.Hooks, w, r, u, &fid, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tfp, err := FPStore.Get(fid, u.Group)\n\tif err == ErrNoRows {\n\t\tfp = BlankForumPerms()\n\t} else if err != nil {\n\t\treturn InternalError(err, w, r)\n\t}\n\tcascadeForumPerms(fp, u)\n\th.CurrentUser = u // TODO: Use a pointer instead for CurrentUser, so we don't have to do this\n\treturn rerr\n}\n\n// TODO: Put this on the user instance? Do we really want forum specific logic in there? Maybe, a method which spits a new pointer with the same contents as user?\nfunc cascadeForumPerms(fp *ForumPerms, u *User) {\n\tif fp.Overrides && !u.IsSuperAdmin {\n\t\tu.Perms.ViewTopic = fp.ViewTopic\n\t\tu.Perms.LikeItem = fp.LikeItem\n\t\tu.Perms.CreateTopic = fp.CreateTopic\n\t\tu.Perms.EditTopic = fp.EditTopic\n\t\tu.Perms.DeleteTopic = fp.DeleteTopic\n\t\tu.Perms.CreateReply = fp.CreateReply\n\t\tu.Perms.EditReply = fp.EditReply\n\t\tu.Perms.DeleteReply = fp.DeleteReply\n\t\tu.Perms.PinTopic = fp.PinTopic\n\t\tu.Perms.CloseTopic = fp.CloseTopic\n\t\tu.Perms.MoveTopic = fp.MoveTopic\n\n\t\tif len(fp.ExtData) != 0 {\n\t\t\tfor name, perm := range fp.ExtData {\n\t\t\t\tu.PluginPerms[name] = perm\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Even if they have the right permissions, the control panel is only open to supermods+. There are many areas without subpermissions which assume that the current user is a supermod+ and admins are extremely unlikely to give these permissions to someone who isn't at-least a supermod to begin with\n// TODO: Do a panel specific theme?\nfunc panelUserCheck(w http.ResponseWriter, r *http.Request, u *User) (h *Header, stats PanelStats, rerr RouteError) {\n\ttheme := GetThemeByReq(r)\n\th = &Header{\n\t\tSite:     Site,\n\t\tSettings: SettingBox.Load().(SettingMap),\n\t\t//Themes:      Themes,\n\t\tThemesSlice: ThemesSlice,\n\t\tTheme:       theme,\n\t\tCurrentUser: u,\n\t\tHooks:       GetHookTable(),\n\t\tZone:        \"panel\",\n\t\tWriter:      w,\n\t\tIsoCode:     phrases.GetLangPack().IsoCode,\n\t\t//StartedAt:   time.Now(),\n\t\tStartedAt: uutils.Nanotime(),\n\t}\n\t// TODO: We should probably initialise header.ExtData\n\t// ? - Should we only show this in debug mode? It might be useful for detecting issues in production, if we show it there as-well\n\t//if user.IsAdmin {\n\t//h.StartedAt = time.Now()\n\t//}\n\n\th.AddSheet(theme.Name + \"/main.css\")\n\th.AddSheet(theme.Name + \"/panel.css\")\n\tif len(theme.Resources) > 0 {\n\t\trlist := theme.Resources\n\t\tfor _, res := range rlist {\n\t\t\tif res.LocID == LocGlobal || res.LocID == LocPanel {\n\t\t\t\tif res.Type == ResTypeSheet {\n\t\t\t\t\th.AddSheet(res.Name)\n\t\t\t\t} else if res.Type == ResTypeScript {\n\t\t\t\t\tif res.Async {\n\t\t\t\t\t\th.AddScriptAsync(res.Name)\n\t\t\t\t\t} else {\n\t\t\t\t\t\th.AddScript(res.Name)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t//h := w.Header()\n\t//h.Set(\"Content-Security-Policy\", \"default-src 'self'\")\n\n\t// TODO: GDPR. Add a global control panel notice warning the admins of staff members who don't have 2FA enabled\n\tstats.Users = Users.Count()\n\tstats.Groups = Groups.Count()\n\tstats.Forums = Forums.Count()\n\tstats.Pages = Pages.Count()\n\tstats.Settings = len(h.Settings)\n\tstats.WordFilters = WordFilters.EstCount()\n\tstats.Themes = len(Themes)\n\tstats.Reports = 0 // TODO: Do the report count. Only show open threads?\n\n\taddPreScript := func(name string, i int) {\n\t\t// TODO: Optimise this by removing a superfluous string alloc\n\t\tif theme.OverridenMap != nil {\n\t\t\t//fmt.Printf(\"name %+v\\n\", name)\n\t\t\t//fmt.Printf(\"theme.OverridenMap %+v\\n\", theme.OverridenMap)\n\t\t\tif _, ok := theme.OverridenMap[name]; ok {\n\t\t\t\ttname := \"_\" + theme.Name\n\t\t\t\t//fmt.Printf(\"tname %+v\\n\", tname)\n\t\t\t\th.AddPreScriptAsync(\"tmpl_\" + name + tname + \".js\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t//fmt.Printf(\"tname %+v\\n\", tname)\n\t\th.AddPreScriptAsync(ucstrs[i])\n\t}\n\taddPreScript(\"alert\", 3)\n\taddPreScript(\"notice\", 4)\n\n\treturn h, stats, nil\n}\n\nfunc simplePanelUserCheck(w http.ResponseWriter, r *http.Request, u *User) (lite *HeaderLite, rerr RouteError) {\n\treturn SimpleUserCheck(w, r, u)\n}\n\n// SimpleUserCheck is back from the grave, yay :D\nfunc simpleUserCheck(w http.ResponseWriter, r *http.Request, u *User) (lite *HeaderLite, rerr RouteError) {\n\treturn &HeaderLite{\n\t\tSite:     Site,\n\t\tSettings: SettingBox.Load().(SettingMap),\n\t\tHooks:    GetHookTable(),\n\t}, nil\n}\n\nfunc GetThemeByReq(r *http.Request) *Theme {\n\ttheme := &Theme{Name: \"\"}\n\tcookie, e := r.Cookie(\"current_theme\")\n\tif e == nil {\n\t\tinTheme, ok := Themes[html.EscapeString(cookie.Value)]\n\t\tif ok && !theme.HideFromThemes {\n\t\t\ttheme = inTheme\n\t\t}\n\t}\n\tif theme.Name == \"\" {\n\t\ttheme = Themes[DefaultThemeBox.Load().(string)]\n\t}\n\treturn theme\n}\n\nfunc userCheck(w http.ResponseWriter, r *http.Request, u *User) (h *Header, rerr RouteError) {\n\treturn userCheck2(w, r, u, uutils.Nanotime())\n}\n\n// TODO: Add the ability for admins to restrict certain themes to certain groups?\n// ! Be careful about firing errors off here as CustomError uses this\nfunc userCheck2(w http.ResponseWriter, r *http.Request, u *User, nano int64) (h *Header, rerr RouteError) {\n\ttheme := GetThemeByReq(r)\n\th = &Header{\n\t\tSite:     Site,\n\t\tSettings: SettingBox.Load().(SettingMap),\n\t\t//Themes:      Themes,\n\t\tThemesSlice: ThemesSlice,\n\t\tTheme:       theme,\n\t\tCurrentUser: u, // ! Some things rely on this being a pointer downstream from this function\n\t\tHooks:       GetHookTable(),\n\t\tZone:        ucstrs[0],\n\t\tWriter:      w,\n\t\tIsoCode:     phrases.GetLangPack().IsoCode,\n\t\tStartedAt:   nano,\n\t}\n\t// TODO: Optimise this by avoiding accessing a map string index\n\tif !u.Loggedin {\n\t\th.GoogSiteVerify = h.Settings[\"google_site_verify\"].(string)\n\t}\n\n\tif u.IsBanned {\n\t\th.AddNotice(\"account_banned\")\n\t}\n\tif u.Loggedin && !u.Active {\n\t\th.AddNotice(\"account_inactive\")\n\t}\n\t/*h.Scripts, _ = StrSlicePool.Get().([]string)\n\tif h.Scripts != nil {\n\t\th.Scripts = h.Scripts[:0]\n\t}\n\th.PreScriptsAsync, _ = StrSlicePool.Get().([]string)\n\tif h.PreScriptsAsync != nil {\n\t\th.PreScriptsAsync = h.PreScriptsAsync[:0]\n\t}*/\n\n\t// An optimisation so we don't populate StartedAt for users who shouldn't see the stat anyway\n\t// ? - Should we only show this in debug mode? It might be useful for detecting issues in production, if we show it there as-well\n\t//if u.IsAdmin {\n\t//h.StartedAt = time.Now()\n\t//}\n\n\t//PrepResources(u,h,theme)\n\treturn h, nil\n}\n\nfunc PrepResources(u *User, h *Header, theme *Theme) {\n\th.AddSheet(theme.Name + \"/main.css\")\n\n\tif len(theme.Resources) > 0 {\n\t\trlist := theme.Resources\n\t\tfor _, res := range rlist {\n\t\t\tif res.Loggedin && !u.Loggedin {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif res.LocID == LocGlobal || res.LocID == LocFront {\n\t\t\t\tif res.Type == ResTypeSheet {\n\t\t\t\t\th.AddSheet(res.Name)\n\t\t\t\t} else if res.Type == ResTypeScript {\n\t\t\t\t\tif res.Async {\n\t\t\t\t\t\th.AddScriptAsync(res.Name)\n\t\t\t\t\t} else {\n\t\t\t\t\t\th.AddScript(res.Name)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\taddPreScript := func(name string, i int) {\n\t\t// TODO: Optimise this by removing a superfluous string alloc\n\t\tif theme.OverridenMap != nil {\n\t\t\t//fmt.Printf(\"name %+v\\n\", name)\n\t\t\t//fmt.Printf(\"theme.OverridenMap %+v\\n\", theme.OverridenMap)\n\t\t\tif _, ok := theme.OverridenMap[name]; ok {\n\t\t\t\ttname := \"_\" + theme.Name\n\t\t\t\t//fmt.Printf(\"tname %+v\\n\", tname)\n\t\t\t\th.AddPreScriptAsync(\"tmpl_\" + name + tname + \".js\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\t//fmt.Printf(\"tname %+v\\n\", tname)\n\t\th.AddPreScriptAsync(ucstrs[i])\n\t}\n\taddPreScript(\"topics_topic\", 1)\n\taddPreScript(\"paginator\", 2)\n\taddPreScript(\"alert\", 3)\n\taddPreScript(\"notice\", 4)\n\tif u.Loggedin {\n\t\taddPreScript(\"topic_c_edit_post\", 5)\n\t\taddPreScript(\"topic_c_attach_item\", 6)\n\t\taddPreScript(\"topic_c_poll_input\", 7)\n\t}\n}\n\nfunc pstr(name string) string {\n\treturn \"tmpl_\" + name + \".js\"\n}\n\nvar ucstrs = [...]string{\n\t\"frontend\",\n\n\tpstr(\"topics_topic\"),\n\tpstr(\"paginator\"),\n\tpstr(\"alert\"),\n\tpstr(\"notice\"),\n\n\tpstr(\"topic_c_edit_post\"),\n\tpstr(\"topic_c_attach_item\"),\n\tpstr(\"topic_c_poll_input\"),\n}\n\nfunc preRoute(w http.ResponseWriter, r *http.Request) (User, bool) {\n\tuserptr, halt := Auth.SessionCheck(w, r)\n\tif halt {\n\t\treturn *userptr, false\n\t}\n\tvar usercpy *User = BlankUser()\n\t*usercpy = *userptr\n\tusercpy.Init() // TODO: Can we reduce the amount of work we do here?\n\n\t// TODO: Add a config setting to disable this header\n\t// TODO: Have this header cover more things\n\tif Config.SslSchema {\n\t\tw.Header().Set(\"Content-Security-Policy\", \"upgrade-insecure-requests\")\n\t}\n\n\t// TODO: WIP. Refactor this to eliminate the unnecessary query\n\t// TODO: Better take proxies into consideration\n\tif !Config.DisableIP {\n\t\tvar host string\n\t\t// TODO: Prefer Cf-Connecting-Ip header, fewer shenanigans\n\t\tif Site.HasProxy {\n\t\t\t// TODO: Check the right-most IP, might get tricky with multiple proxies, maybe have a setting for the number of hops we jump through\n\t\t\txForwardedFor := r.Header.Get(\"X-Forwarded-For\")\n\t\t\tif xForwardedFor != \"\" {\n\t\t\t\tforwardedFor := strings.Split(xForwardedFor, \",\")\n\t\t\t\t// TODO: Check if this is a valid IP Address, reject if not\n\t\t\t\thost = forwardedFor[len(forwardedFor)-1]\n\t\t\t}\n\t\t}\n\n\t\tif host == \"\" {\n\t\t\tvar e error\n\t\t\thost, _, e = net.SplitHostPort(r.RemoteAddr)\n\t\t\tif e != nil {\n\t\t\t\t_ = PreError(\"Bad IP\", w, r)\n\t\t\t\treturn *usercpy, false\n\t\t\t}\n\t\t}\n\n\t\tif !Config.DisableLastIP && usercpy.Loggedin && host != usercpy.GetIP() {\n\t\t\tmon := time.Now().Month()\n\t\t\te := usercpy.UpdateIP(strconv.Itoa(int(mon)) + \"-\" + host)\n\t\t\tif e != nil {\n\t\t\t\t_ = InternalError(e, w, r)\n\t\t\t\treturn *usercpy, false\n\t\t\t}\n\t\t}\n\t\tusercpy.LastIP = host\n\t}\n\n\treturn *usercpy, true\n}\n\nfunc UploadAvatar(w http.ResponseWriter, r *http.Request, u *User, tuid int) (ext string, ferr RouteError) {\n\t// We don't want multiple files\n\t// TODO: Are we doing this correctly?\n\tfilenameMap := make(map[string]bool)\n\tfor _, fheaders := range r.MultipartForm.File {\n\t\tfor _, hdr := range fheaders {\n\t\t\tif hdr.Filename == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfilenameMap[hdr.Filename] = true\n\t\t}\n\t}\n\tif len(filenameMap) > 1 {\n\t\treturn \"\", LocalError(\"You may only upload one avatar\", w, r, u)\n\t}\n\n\tfor _, fheaders := range r.MultipartForm.File {\n\t\tfor _, hdr := range fheaders {\n\t\t\tif hdr.Filename == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tinFile, err := hdr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", LocalError(\"Upload failed\", w, r, u)\n\t\t\t}\n\t\t\tdefer inFile.Close()\n\n\t\t\tif ext == \"\" {\n\t\t\t\textarr := strings.Split(hdr.Filename, \".\")\n\t\t\t\tif len(extarr) < 2 {\n\t\t\t\t\treturn \"\", LocalError(\"Bad file\", w, r, u)\n\t\t\t\t}\n\t\t\t\text = extarr[len(extarr)-1]\n\n\t\t\t\t// TODO: Can we do this without a regex?\n\t\t\t\treg, err := regexp.Compile(\"[^A-Za-z0-9]+\")\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn \"\", LocalError(\"Bad file extension\", w, r, u)\n\t\t\t\t}\n\t\t\t\text = reg.ReplaceAllString(ext, \"\")\n\t\t\t\text = strings.ToLower(ext)\n\n\t\t\t\tif !ImageFileExts.Contains(ext) {\n\t\t\t\t\treturn \"\", LocalError(\"You can only use an image for your avatar\", w, r, u)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// TODO: Centralise this string, so we don't have to change it in two different places when it changes\n\t\t\toutFile, err := os.Create(\"./uploads/avatar_\" + strconv.Itoa(tuid) + \".\" + ext)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", LocalError(\"Upload failed [File Creation Failed]\", w, r, u)\n\t\t\t}\n\t\t\tdefer outFile.Close()\n\n\t\t\t_, err = io.Copy(outFile, inFile)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", LocalError(\"Upload failed [Copy Failed]\", w, r, u)\n\t\t\t}\n\t\t}\n\t}\n\tif ext == \"\" {\n\t\treturn \"\", LocalError(\"No file\", w, r, u)\n\t}\n\treturn ext, nil\n}\n\nfunc ChangeAvatar(path string, w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\te := u.ChangeAvatar(path)\n\tif e != nil {\n\t\treturn InternalError(e, w, r)\n\t}\n\n\t// Clean up the old avatar data, so we don't end up with too many dead files in /uploads/\n\tif len(u.RawAvatar) > 2 {\n\t\tif u.RawAvatar[0] == '.' && u.RawAvatar[1] == '.' {\n\t\t\te := os.Remove(\"./uploads/avatar_\" + strconv.Itoa(u.ID) + \"_tmp\" + u.RawAvatar[1:])\n\t\t\tif e != nil && !os.IsNotExist(e) {\n\t\t\t\tLogWarning(e)\n\t\t\t\treturn LocalError(\"Something went wrong\", w, r, u)\n\t\t\t}\n\t\t\te = os.Remove(\"./uploads/avatar_\" + strconv.Itoa(u.ID) + \"_w48\" + u.RawAvatar[1:])\n\t\t\tif e != nil && !os.IsNotExist(e) {\n\t\t\t\tLogWarning(e)\n\t\t\t\treturn LocalError(\"Something went wrong\", w, r, u)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// SuperAdminOnly makes sure that only super admin can access certain critical panel routes\nfunc SuperAdminOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif !u.IsSuperAdmin {\n\t\treturn NoPermissions(w, r, u)\n\t}\n\treturn nil\n}\n\n// AdminOnly makes sure that only admins can access certain panel routes\nfunc AdminOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif !u.IsAdmin {\n\t\treturn NoPermissions(w, r, u)\n\t}\n\treturn nil\n}\n\n// SuperModeOnly makes sure that only super mods or higher can access the panel routes\nfunc SuperModOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif !u.IsSuperMod {\n\t\treturn NoPermissions(w, r, u)\n\t}\n\treturn nil\n}\n\n// MemberOnly makes sure that only logged in users can access this route\nfunc MemberOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif !u.Loggedin {\n\t\treturn LoginRequired(w, r, u)\n\t}\n\treturn nil\n}\n\n// NoBanned stops any banned users from accessing this route\nfunc NoBanned(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif u.IsBanned {\n\t\treturn Banned(w, r, u)\n\t}\n\treturn nil\n}\n\nfunc ParseForm(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif e := r.ParseForm(); e != nil {\n\t\treturn LocalError(\"Bad Form\", w, r, u)\n\t}\n\treturn nil\n}\n\nfunc NoSessionMismatch(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif e := r.ParseForm(); e != nil {\n\t\treturn LocalError(\"Bad Form\", w, r, u)\n\t}\n\tif len(u.Session) == 0 {\n\t\treturn SecurityError(w, r, u)\n\t}\n\t// TODO: Try to eliminate some of these allocations\n\tsess := []byte(u.Session)\n\tif subtle.ConstantTimeCompare([]byte(r.FormValue(\"session\")), sess) != 1 && subtle.ConstantTimeCompare([]byte(r.FormValue(\"s\")), sess) != 1 {\n\t\treturn SecurityError(w, r, u)\n\t}\n\treturn nil\n}\n\nfunc ReqIsJson(r *http.Request) bool {\n\treturn r.Header.Get(\"Content-type\") == \"application/json\"\n}\n\nfunc HandleUploadRoute(w http.ResponseWriter, r *http.Request, u *User, maxFileSize int) RouteError {\n\t// TODO: Reuse this code more\n\tif r.ContentLength > int64(maxFileSize) {\n\t\tsize, unit := ConvertByteUnit(float64(maxFileSize))\n\t\treturn CustomError(\"Your upload is too big. Your files need to be smaller than \"+strconv.Itoa(int(size))+unit+\".\", http.StatusExpectationFailed, \"Error\", w, r, nil, u)\n\t}\n\tr.Body = http.MaxBytesReader(w, r.Body, r.ContentLength)\n\n\te := r.ParseMultipartForm(int64(Megabyte))\n\tif e != nil {\n\t\treturn LocalError(\"Bad Form\", w, r, u)\n\t}\n\treturn nil\n}\n\nfunc NoUploadSessionMismatch(w http.ResponseWriter, r *http.Request, u *User) RouteError {\n\tif len(u.Session) == 0 {\n\t\treturn SecurityError(w, r, u)\n\t}\n\t// TODO: Try to eliminate some of these allocations\n\tsess := []byte(u.Session)\n\tif subtle.ConstantTimeCompare([]byte(r.FormValue(\"session\")), sess) != 1 && subtle.ConstantTimeCompare([]byte(r.FormValue(\"s\")), sess) != 1 {\n\t\treturn SecurityError(w, r, u)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/search.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar RepliesSearch Searcher\n\ntype Searcher interface {\n\tQuery(q string, zones []int) ([]int, error)\n}\n\n// TODO: Implement this\n// Note: This is slow compared to something like ElasticSearch and very limited\ntype SQLSearcher struct {\n\tqueryReplies *sql.Stmt\n\tqueryTopics  *sql.Stmt\n\tqueryRepliesZone *sql.Stmt\n\tqueryTopicsZone *sql.Stmt\n\t//queryZone    *sql.Stmt\n\tfuzzyZone *sql.Stmt\n}\n\n// TODO: Support things other than MySQL\n// TODO: Use LIMIT?\nfunc NewSQLSearcher(acc *qgen.Accumulator) (*SQLSearcher, error) {\n\tif acc.GetAdapter().GetName() != \"mysql\" {\n\t\treturn nil, errors.New(\"SQLSearcher only supports MySQL at this time\")\n\t}\n\treturn &SQLSearcher{\n\t\tqueryReplies: acc.RawPrepare(\"SELECT tid FROM replies WHERE MATCH(content) AGAINST (? IN BOOLEAN MODE)\"),\n\t\tqueryTopics:  acc.RawPrepare(\"SELECT tid FROM topics WHERE MATCH(title) AGAINST (? IN BOOLEAN MODE) OR MATCH(content) AGAINST (? IN BOOLEAN MODE)\"),\n\t\tqueryRepliesZone: acc.RawPrepare(\"SELECT tid FROM replies WHERE MATCH(content) AGAINST (? IN BOOLEAN MODE) AND tid=?\"),\n\t\tqueryTopicsZone:  acc.RawPrepare(\"SELECT tid FROM topics WHERE (MATCH(title) AGAINST (? IN BOOLEAN MODE) OR MATCH(content) AGAINST (? IN BOOLEAN MODE)) AND parentID=?\"),\n\t\t//queryZone:    acc.RawPrepare(\"SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (topics.title=? OR (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE) OR MATCH(replies.content) AGAINST (? IN BOOLEAN MODE)) OR topics.content=? OR replies.content=?) AND topics.parentID=?\"),\n\t\tfuzzyZone:    acc.RawPrepare(\"SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (topics.title LIKE ? OR topics.content LIKE ? OR replies.content LIKE ?) AND topics.parentID=?\"),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLSearcher) queryAll(q string) ([]int, error) {\n\tvar ids []int\n\trun := func(stmt *sql.Stmt, q ...interface{}) error {\n\t\trows, e := stmt.Query(q...)\n\t\tif e == sql.ErrNoRows {\n\t\t\treturn nil\n\t\t} else if e != nil {\n\t\t\treturn e\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar id int\n\t\t\tif e := rows.Scan(&id); e != nil {\n\t\t\t\treturn e\n\t\t\t}\n\t\t\tids = append(ids, id)\n\t\t}\n\t\treturn rows.Err()\n\t}\n\n\terr := run(s.queryReplies, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = run(s.queryTopics, q, q)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(ids) == 0 {\n\t\terr = sql.ErrNoRows\n\t}\n\treturn ids, err\n}\n\nfunc (s *SQLSearcher) Query(q string, zones []int) (ids []int, err error) {\n\tif len(zones) == 0 {\n\t\treturn nil, nil\n\t}\n\trun := func(rows *sql.Rows, e error) error {\n\t\t/*if e == sql.ErrNoRows {\n\t\t\treturn nil\n\t\t} else */if e != nil {\n\t\t\treturn e\n\t\t}\n\t\tdefer rows.Close()\n\n\t\tfor rows.Next() {\n\t\t\tvar id int\n\t\t\tif e := rows.Scan(&id); e != nil {\n\t\t\t\treturn e\n\t\t\t}\n\t\t\tids = append(ids, id)\n\t\t}\n\t\treturn rows.Err()\n\t}\n\n\tif len(zones) == 1 {\n\t\t//err = run(s.queryZone.Query(q, q, q, q, q,q, zones[0]))\n\t\terr = run(s.queryRepliesZone.Query(q, zones[0]))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\terr = run(s.queryTopicsZone.Query(q, q,zones[0]))\n\t} else {\n\t\tvar zList string\n\t\tfor _, zone := range zones {\n\t\t\tzList += strconv.Itoa(zone) + \",\"\n\t\t}\n\t\tzList = zList[:len(zList)-1]\n\n\t\tacc := qgen.NewAcc()\n\t\t/*stmt := acc.RawPrepare(\"SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE) OR MATCH(replies.content) AGAINST (? IN BOOLEAN MODE) OR topics.title=? OR topics.content=? OR replies.content=?) AND topics.parentID IN(\" + zList + \")\")\n\t\tif err = acc.FirstError(); err != nil {\n\t\t\treturn nil, err\n\t\t}*/\n\t\t// TODO: Cache common IN counts\n\t\tstmt := acc.RawPrepare(\"SELECT tid FROM topics WHERE (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE)) AND parentID IN(\" + zList + \")\")\n\t\tif err = acc.FirstError(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\terr = run(stmt.Query(q, q))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstmt = acc.RawPrepare(\"SELECT tid FROM replies WHERE MATCH(replies.content) AGAINST (? IN BOOLEAN MODE) AND tid IN(\" + zList + \")\")\n\t\tif err = acc.FirstError(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\terr = run(stmt.Query(q))\n\t\t//err = run(stmt.Query(q, q, q, q, q, q))\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(ids) == 0 {\n\t\terr = sql.ErrNoRows\n\t}\n\treturn ids, err\n}\n\n// TODO: Implement this\ntype ElasticSearchSearcher struct {\n}\n\nfunc NewElasticSearchSearcher() (*ElasticSearchSearcher, error) {\n\treturn &ElasticSearchSearcher{}, nil\n}\n\nfunc (s *ElasticSearchSearcher) Query(q string, zones []int) ([]int, error) {\n\treturn nil, nil\n}\n"
  },
  {
    "path": "common/settings.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar SettingBox atomic.Value // An atomic value pointing to a SettingBox\n\n// SettingMap is a map type specifically for holding the various settings admins set to toggle features on and off or to otherwise alter Gosora's behaviour from the Control Panel\ntype SettingMap map[string]interface{}\n\ntype SettingStore interface {\n\tParseSetting(name, content, typ, constraint string) string\n\tBypassGet(name string) (*Setting, error)\n\tBypassGetAll(name string) ([]*Setting, error)\n}\n\ntype OptionLabel struct {\n\tLabel    string\n\tValue    int\n\tSelected bool\n}\n\ntype Setting struct {\n\tName       string\n\tContent    string\n\tType       string\n\tConstraint string\n}\n\ntype SettingStmts struct {\n\tgetAll *sql.Stmt\n\tget    *sql.Stmt\n\tupdate *sql.Stmt\n}\n\nvar settingStmts SettingStmts\n\nfunc init() {\n\tSettingBox.Store(SettingMap(make(map[string]interface{})))\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\ts := \"settings\"\n\t\tsettingStmts = SettingStmts{\n\t\t\tgetAll: acc.Select(s).Columns(\"name,content,type,constraints\").Prepare(),\n\t\t\tget:    acc.Select(s).Columns(\"content,type,constraints\").Where(\"name=?\").Prepare(),\n\t\t\tupdate: acc.Update(s).Set(\"content=?\").Where(\"name=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc (s *Setting) Copy() (o *Setting) {\n\to = &Setting{Name: \"\"}\n\t*o = *s\n\treturn o\n}\n\nfunc LoadSettings() error {\n\tsBox := SettingMap(make(map[string]interface{}))\n\tsettings, err := sBox.BypassGetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, s := range settings {\n\t\terr = sBox.ParseSetting(s.Name, s.Content, s.Type, s.Constraint)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tSettingBox.Store(sBox)\n\treturn nil\n}\n\n// TODO: Add better support for HTML attributes (html-attribute). E.g. Meta descriptions.\nfunc (sBox SettingMap) ParseSetting(name, content, typ, constraint string) (err error) {\n\tssBox := map[string]interface{}(sBox)\n\tswitch typ {\n\tcase \"bool\":\n\t\tssBox[name] = (content == \"1\")\n\tcase \"int\":\n\t\tssBox[name], err = strconv.Atoi(content)\n\t\tif err != nil {\n\t\t\treturn errors.New(\"You were supposed to enter an integer x.x\")\n\t\t}\n\tcase \"int64\":\n\t\tssBox[name], err = strconv.ParseInt(content, 10, 64)\n\t\tif err != nil {\n\t\t\treturn errors.New(\"You were supposed to enter an integer x.x\")\n\t\t}\n\tcase \"list\":\n\t\tcons := strings.Split(constraint, \"-\")\n\t\tif len(cons) < 2 {\n\t\t\treturn errors.New(\"Invalid constraint! The second field wasn't set!\")\n\t\t}\n\n\t\tcon1, err := strconv.Atoi(cons[0])\n\t\tcon2, err2 := strconv.Atoi(cons[1])\n\t\tif err != nil || err2 != nil {\n\t\t\treturn errors.New(\"Invalid contraint! The constraint field wasn't an integer!\")\n\t\t}\n\n\t\tval, err := strconv.Atoi(content)\n\t\tif err != nil {\n\t\t\treturn errors.New(\"Only integers are allowed in this setting x.x\")\n\t\t}\n\n\t\tif val < con1 || val > con2 {\n\t\t\treturn errors.New(\"Only integers between a certain range are allowed in this setting\")\n\t\t}\n\t\tssBox[name] = val\n\tdefault:\n\t\tssBox[name] = content\n\t}\n\treturn nil\n}\n\nfunc (sBox SettingMap) BypassGet(name string) (*Setting, error) {\n\ts := &Setting{Name: name}\n\terr := settingStmts.get.QueryRow(name).Scan(&s.Content, &s.Type, &s.Constraint)\n\treturn s, err\n}\n\nfunc (sBox SettingMap) BypassGetAll() (settingList []*Setting, err error) {\n\trows, err := settingStmts.getAll.Query()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\ts := &Setting{Name: \"\"}\n\t\terr := rows.Scan(&s.Name, &s.Content, &s.Type, &s.Constraint)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsettingList = append(settingList, s)\n\t}\n\treturn settingList, rows.Err()\n}\n\nfunc (sBox SettingMap) Update(name, content string) RouteError {\n\ts, err := sBox.BypassGet(name)\n\tif err == ErrNoRows {\n\t\treturn FromError(err)\n\t} else if err != nil {\n\t\treturn SysError(err.Error())\n\t}\n\n\t// TODO: Why is this here and not in a common function?\n\tif s.Type == \"bool\" {\n\t\tif content == \"on\" || content == \"1\" {\n\t\t\tcontent = \"1\"\n\t\t} else {\n\t\t\tcontent = \"0\"\n\t\t}\n\t}\n\n\terr = sBox.ParseSetting(name, content, s.Type, s.Constraint)\n\tif err != nil {\n\t\treturn FromError(err)\n\t}\n\n\t// TODO: Make this a method or function?\n\t_, err = settingStmts.update.Exec(content, name)\n\tif err != nil {\n\t\treturn SysError(err.Error())\n\t}\n\n\terr = LoadSettings()\n\tif err != nil {\n\t\treturn SysError(err.Error())\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/site.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/pkg/errors\"\n)\n\n// Site holds the basic settings which should be tweaked when setting up a site, we might move them to the settings table at some point\nvar Site = &site{Name: \"Magical Fairy Land\", Language: \"english\"}\n\n// DbConfig holds the database configuration\nvar DbConfig = &dbConfig{Host: \"localhost\"}\n\n// Config holds the more technical settings\nvar Config = new(config)\n\n// Dev holds build flags and other things which should only be modified during developers or to gather additional test data\nvar Dev = new(devConfig)\n\nvar PluginConfig = map[string]string{}\n\ntype site struct {\n\tShortName    string\n\tName         string\n\tEmail        string\n\tURL          string\n\tHost         string\n\tLocalHost    bool // Used internally, do not modify as it will be overwritten\n\tPort         string\n\tPortInt      int // Alias for efficiency, do not modify, will be overwritten\n\tEnableSsl    bool\n\tEnableEmails bool\n\tHasProxy     bool\n\tLanguage     string\n\n\tMaxRequestSize int // Alias, do not modify, will be overwritten\n}\n\ntype dbConfig struct {\n\t// Production database\n\tAdapter  string\n\tHost     string\n\tUsername string\n\tPassword string\n\tDbname   string\n\tPort     string\n\n\t// Test database. Split this into a separate variable?\n\tTestAdapter  string\n\tTestHost     string\n\tTestUsername string\n\tTestPassword string\n\tTestDbname   string\n\tTestPort     string\n}\n\ntype config struct {\n\tSslPrivkey   string\n\tSslFullchain string\n\tHashAlgo     string // Defaults to bcrypt, and in the future, possibly something stronger\n\tConvoKey     string\n\n\tMaxRequestSizeStr  string\n\tMaxRequestSize     int\n\tUserCache          string\n\tUserCacheCapacity  int\n\tTopicCache         string\n\tTopicCacheCapacity int\n\tReplyCache         string\n\tReplyCacheCapacity int\n\n\tSMTPServer    string\n\tSMTPUsername  string\n\tSMTPPassword  string\n\tSMTPPort      string\n\tSMTPEnableTLS bool\n\n\tSearch string\n\n\tDefaultPath     string\n\tDefaultGroup    int    // Should be a setting in the database\n\tActivationGroup int    // Should be a setting in the database\n\tStaffCSS        string // ? - Move this into the settings table? Might be better to implement this as Group CSS\n\tDefaultForum    int    // The forum posts go in by default, this used to be covered by the Uncategorised Forum, but we want to replace it with a more robust solution. Make this a setting?\n\tMinifyTemplates bool\n\tBuildSlugs      bool // TODO: Make this a setting?\n\n\tPrimaryServer  bool\n\tServerCount    int\n\tLastIPCutoff   int // Currently just -1, non--1, but will accept the number of months a user's last IP should be retained for in the future before being purged. Please note that the other two cutoffs below operate off the numbers of days instead.\n\tPostIPCutoff   int\n\tPollIPCutoff   int\n\tLogPruneCutoff int\n\t//SelfDeleteTruncCutoff int // Personal data is stripped from the mod action rows only leaving the TID and the action for later investigation.\n\n\tDisableIP       bool\n\tDisableLastIP   bool\n\tDisablePostIP   bool\n\tDisablePollIP   bool\n\tDisableRegLog   bool\n\tDisableLoginLog bool\n\t//DisableSelfDeleteLog bool\n\n\tDisableLiveTopicList bool\n\tDisableJSAntispam    bool\n\t//LooseCSP             bool\n\tLooseHost              bool\n\tLoosePort              bool\n\tSslSchema              bool // Pretend we're using SSL, might be useful if a reverse-proxy terminates SSL in-front of Gosora\n\tDisableServerPush      bool\n\tEnableCDNPush          bool\n\tDisableNoavatarRange   bool\n\tDisableDefaultNoavatar bool\n\tDisableAnalytics       bool\n\n\tRefNoTrack bool\n\tRefNoRef   bool\n\tNoEmbed    bool\n\n\tExtraCSPOrigins string\n\tStaticResBase   string // /s/\n\t//DynStaticResBase string\n\tAvatarResBase string // /uploads/\n\n\tNoavatar            string // ? - Move this into the settings table?\n\tItemsPerPage        int    // ? - Move this into the settings table?\n\tMaxTopicTitleLength int\n\tMaxUsernameLength   int\n\n\tReadTimeout  int\n\tWriteTimeout int\n\tIdleTimeout  int\n\n\tLogDir             string\n\tDisableSuspLog     bool\n\tDisableBadRouteLog bool\n\tDisableStdout      bool\n\tDisableStderr      bool\n}\n\ntype devConfig struct {\n\tDebugMode     bool\n\tSuperDebug    bool\n\tTemplateDebug bool\n\tProfiling     bool\n\tTestDB        bool\n\n\tNoFsnotify bool // Super Experimental!\n\tFullReqLog bool\n\tExtraTmpls string // Experimental flag for adding compiled templates, we'll likely replace this with a better mechanism\n\n\t//QuicPort int // Experimental!\n\n\t//ExpFix1 bool // unlisted setting, experimental fix for http/1.1 conn hangs\n\tLogLongTick     bool // unlisted setting\n\tLogNewLongRoute bool // unlisted setting\n\tLog4thLongRoute bool // unlisted setting\n\n\tHourDBTimeout bool // unlisted setting\n}\n\n// configHolder is purely for having a big struct to unmarshal data into\ntype configHolder struct {\n\tSite     *site\n\tConfig   *config\n\tDatabase *dbConfig\n\tDev      *devConfig\n\tPlugin   map[string]string\n}\n\nfunc LoadConfig() error {\n\tdata, err := ioutil.ReadFile(\"./config/config.json\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar config configHolder\n\terr = json.Unmarshal(data, &config)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tSite = config.Site\n\tConfig = config.Config\n\tDbConfig = config.Database\n\tDev = config.Dev\n\tPluginConfig = config.Plugin\n\n\treturn nil\n}\n\nvar noavatarCache200 []string\nvar noavatarCache48 []string\n\n/*var noavatarCache200Jpg []string\nvar noavatarCache48Jpg []string\nvar noavatarCache200Avif []string\nvar noavatarCache48Avif []string*/\n\nfunc ProcessConfig() (err error) {\n\t// Strip these unnecessary bits, if we find them.\n\tSite.URL = strings.TrimPrefix(Site.URL, \"http://\")\n\tSite.URL = strings.TrimPrefix(Site.URL, \"https://\")\n\tSite.Host = Site.URL\n\tSite.LocalHost = Site.Host == \"localhost\" || Site.Host == \"127.0.0.1\" || Site.Host == \"::1\"\n\tSite.PortInt, err = strconv.Atoi(Site.Port)\n\tif err != nil {\n\t\treturn errors.New(\"The port must be a valid integer\")\n\t}\n\tif Site.PortInt != 80 && Site.PortInt != 443 {\n\t\tSite.URL = strings.TrimSuffix(Site.URL, \"/\")\n\t\tSite.URL = strings.TrimSuffix(Site.URL, \"\\\\\")\n\t\tSite.URL = strings.TrimSuffix(Site.URL, \":\")\n\t\tSite.URL = Site.URL + \":\" + Site.Port\n\t}\n\tuurl, err := url.Parse(Site.URL)\n\tif err != nil {\n\t\treturn errors.Wrap(err, \"failed to parse Site.URL: \")\n\t}\n\tif Site.EnableSsl {\n\t\tConfig.SslSchema = Site.EnableSsl\n\t}\n\tif Config.DefaultPath == \"\" {\n\t\tConfig.DefaultPath = \"/topics/\"\n\t}\n\n\t// TODO: Bump the size of max request size up, if it's too low\n\tConfig.MaxRequestSize, err = strconv.Atoi(Config.MaxRequestSizeStr)\n\tif err != nil {\n\t\treqSizeStr := Config.MaxRequestSizeStr\n\t\tif len(reqSizeStr) < 3 {\n\t\t\treturn errors.New(\"Invalid unit for MaxRequestSizeStr\")\n\t\t}\n\n\t\tquantity, err := strconv.Atoi(reqSizeStr[:len(reqSizeStr)-2])\n\t\tif err != nil {\n\t\t\treturn errors.New(\"Unable to convert quantity to integer in MaxRequestSizeStr, found \" + reqSizeStr[:len(reqSizeStr)-2])\n\t\t}\n\t\tunit := reqSizeStr[len(reqSizeStr)-2:]\n\n\t\t// TODO: Make it a named error just in case new errors are added in here in the future\n\t\tConfig.MaxRequestSize, err = FriendlyUnitToBytes(quantity, unit)\n\t\tif err != nil {\n\t\t\treturn errors.New(\"Unable to recognise unit for MaxRequestSizeStr, found \" + unit)\n\t\t}\n\t}\n\tif Dev.DebugMode {\n\t\tlog.Print(\"Set MaxRequestSize to \", Config.MaxRequestSize)\n\t}\n\tif Config.MaxRequestSize <= 0 {\n\t\tlog.Fatal(\"MaxRequestSize should not be zero or below\")\n\t}\n\tSite.MaxRequestSize = Config.MaxRequestSize\n\n\tlocal := func(h string) bool {\n\t\treturn h == \"localhost\" || h == \"127.0.0.1\" || h == \"::1\" || h == Site.URL\n\t}\n\tuurl, err = url.Parse(Config.StaticResBase)\n\tif err != nil {\n\t\treturn errors.Wrap(err, \"failed to parse Config.StaticResBase: \")\n\t}\n\thost := uurl.Hostname()\n\tif !local(host) {\n\t\tConfig.ExtraCSPOrigins += \" \" + host\n\t\tConfig.RefNoRef = true // Avoid leaking origin data to the CDN\n\t}\n\tif Config.StaticResBase != \"\" {\n\t\tStaticFiles.Prefix = Config.StaticResBase\n\t}\n\n\tuurl, err = url.Parse(Config.AvatarResBase)\n\tif err != nil {\n\t\treturn errors.Wrap(err, \"failed to parse Config.AvatarResBase: \")\n\t}\n\thost2 := uurl.Hostname()\n\tif host != host2 && !local(host) {\n\t\tConfig.ExtraCSPOrigins += \" \" + host\n\t\tConfig.RefNoRef = true // Avoid leaking origin data to the CDN\n\t}\n\tif Config.AvatarResBase == \"\" {\n\t\tConfig.AvatarResBase = \"/uploads/\"\n\t}\n\n\tif !Config.DisableDefaultNoavatar {\n\t\tcap := 11\n\t\tnoavatarCache200 = make([]string, cap)\n\t\tnoavatarCache48 = make([]string, cap)\n\t\t/*noavatarCache200Jpg = make([]string, cap)\n\t\tnoavatarCache48Jpg = make([]string, cap)\n\t\tnoavatarCache200Avif = make([]string, cap)\n\t\tnoavatarCache48Avif = make([]string, cap)*/\n\t\tfor i := 0; i < cap; i++ {\n\t\t\tnoavatarCache200[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(200) + \".png?i=0\"\n\t\t\tnoavatarCache48[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(48) + \".png?i=0\"\n\n\t\t\t/*noavatarCache200Jpg[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(200) + \".jpg?i=0\"\n\t\t\tnoavatarCache48Jpg[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(48) + \".jpg?i=0\"\n\n\t\t\tnoavatarCache200Avif[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(200) + \".avif?i=0\"\n\t\t\tnoavatarCache48Avif[i] = StaticFiles.Prefix + \"n\" + strconv.Itoa(i) + \"-\" + strconv.Itoa(48) + \".avif?i=0\"*/\n\t\t}\n\t}\n\tConfig.Noavatar = strings.Replace(Config.Noavatar, \"{site_url}\", Site.URL, -1)\n\tguestAvatar = GuestAvatar{buildNoavatar(0, 200), buildNoavatar(0, 48)}\n\n\tif Config.DisableIP {\n\t\tConfig.DisableLastIP = true\n\t\tConfig.DisablePostIP = true\n\t\tConfig.DisablePollIP = true\n\t}\n\n\tif Config.PostIPCutoff == 0 {\n\t\tConfig.PostIPCutoff = 90 // Default cutoff\n\t}\n\tif Config.LogPruneCutoff == 0 {\n\t\tConfig.LogPruneCutoff = 180 // Default cutoff\n\t}\n\tif Config.LastIPCutoff == 0 {\n\t\tConfig.LastIPCutoff = 3 // Default cutoff\n\t}\n\tif Config.LastIPCutoff > 12 {\n\t\tConfig.LastIPCutoff = 12\n\t}\n\tif Config.PollIPCutoff == 0 {\n\t\tConfig.PollIPCutoff = 90 // Default cutoff\n\t}\n\tif Config.NoEmbed {\n\t\tDefaultParseSettings.NoEmbed = true\n\t}\n\n\t// ? Find a way of making these unlimited if zero? It might rule out some optimisations, waste memory, and break layouts\n\tif Config.MaxTopicTitleLength == 0 {\n\t\tConfig.MaxTopicTitleLength = 100\n\t}\n\tif Config.MaxUsernameLength == 0 {\n\t\tConfig.MaxUsernameLength = 100\n\t}\n\tGuestUser.Avatar, GuestUser.MicroAvatar = BuildAvatar(0, \"\")\n\n\tif Config.HashAlgo != \"\" {\n\t\t// TODO: Set the alternate hash algo, e.g. argon2\n\t}\n\n\tif Config.LogDir == \"\" {\n\t\tConfig.LogDir = \"./logs/\"\n\t}\n\n\t// We need this in here rather than verifyConfig as switchToTestDB() currently overwrites the values it verifies\n\tif DbConfig.TestDbname == DbConfig.Dbname {\n\t\treturn errors.New(\"Your test database can't have the same name as your production database\")\n\t}\n\tif Dev.TestDB {\n\t\tSwitchToTestDB()\n\t}\n\treturn nil\n}\n\nfunc VerifyConfig() (err error) {\n\tswitch {\n\tcase !Forums.Exists(Config.DefaultForum):\n\t\terr = errors.New(\"Invalid default forum\")\n\tcase Config.ServerCount < 1:\n\t\terr = errors.New(\"You can't have less than one server\")\n\tcase Config.MaxTopicTitleLength > 100:\n\t\terr = errors.New(\"The max topic title length cannot be over 100 as that's unable to fit in the database row\")\n\tcase Config.MaxUsernameLength > 100:\n\t\terr = errors.New(\"The max username length cannot be over 100 as that's unable to fit in the database row\")\n\t}\n\treturn err\n}\n\nfunc SwitchToTestDB() {\n\tDbConfig.Host = DbConfig.TestHost\n\tDbConfig.Username = DbConfig.TestUsername\n\tDbConfig.Password = DbConfig.TestPassword\n\tDbConfig.Dbname = DbConfig.TestDbname\n\tDbConfig.Port = DbConfig.TestPort\n}\n"
  },
  {
    "path": "common/statistics.go",
    "content": "package common\n\n// EXPERIMENTAL\nimport (\n\t\"errors\"\n)\n\nvar StatStore StatStoreInt\n\ntype StatStoreInt interface {\n\tLookupInt(name string, duration int, unit string) (int, error)\n}\n\ntype DefaultStatStore struct {\n}\n\nfunc NewDefaultStatStore() *DefaultStatStore {\n\treturn &DefaultStatStore{}\n}\n\nfunc (s *DefaultStatStore) LookupInt(name string, duration int, unit string) (int, error) {\n\tswitch name {\n\tcase \"postCount\":\n\t\treturn s.countTable(\"replies\", duration, unit)\n\t}\n\treturn 0, errors.New(\"The requested stat doesn't exist\")\n}\n\nfunc (s *DefaultStatStore) countTable(table string, duration int, unit string) (stat int, err error) {\n\t/*counter := qgen.NewAcc().Count(\"replies\").DateCutoff(\"createdAt\", 1, \"day\").Prepare()\n\tif acc.FirstError() != nil {\n\t\treturn 0, acc.FirstError()\n\t}\n\terr := counter.QueryRow().Scan(&stat)*/\n\treturn stat, err\n}\n\n//stmts.todaysPostCount, err = db.Prepare(\"select count(*) from replies where createdAt BETWEEN (utc_timestamp() - interval 1 day) and utc_timestamp()\")\n"
  },
  {
    "path": "common/subscription.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Subscriptions SubscriptionStore\n\n// ? Should we have a subscription store for each zone? topic, forum, etc?\ntype SubscriptionStore interface {\n\tAdd(uid, elementID int, elementType string) error\n\tDelete(uid, targetID int, targetType string) error\n\tDeleteResource(targetID int, targetType string) error\n}\n\ntype DefaultSubscriptionStore struct {\n\tadd            *sql.Stmt\n\tdelete         *sql.Stmt\n\tdeleteResource *sql.Stmt\n}\n\nfunc NewDefaultSubscriptionStore() (*DefaultSubscriptionStore, error) {\n\tacc := qgen.NewAcc()\n\tast := \"activity_subscriptions\"\n\treturn &DefaultSubscriptionStore{\n\t\tadd:            acc.Insert(ast).Columns(\"user,targetID,targetType,level\").Fields(\"?,?,?,2\").Prepare(),\n\t\tdelete:         acc.Delete(ast).Where(\"user=? AND targetID=? AND targetType=?\").Prepare(),\n\t\tdeleteResource: acc.Delete(ast).Where(\"targetID=? AND targetType=?\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultSubscriptionStore) Add(uid, elementID int, elementType string) error {\n\t_, err := s.add.Exec(uid, elementID, elementType)\n\treturn err\n}\n\n// TODO: Add a primary key to the activity subscriptions table\nfunc (s *DefaultSubscriptionStore) Delete(uid, targetID int, targetType string) error {\n\t_, err := s.delete.Exec(uid, targetID, targetType)\n\treturn err\n}\n\nfunc (s *DefaultSubscriptionStore) DeleteResource(targetID int, targetType string) error {\n\t_, err := s.deleteResource.Exec(targetID, targetType)\n\treturn err\n}\n"
  },
  {
    "path": "common/tasks.go",
    "content": "/*\n*\n*\tGosora Task System\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype TaskStmts struct {\n\tgetExpiredScheduledGroups *sql.Stmt\n\tgetSync                   *sql.Stmt\n}\n\nvar Tasks *ScheduledTasks\n\ntype TaskSet interface {\n\tAdd(func() error)\n\tGetList() []func() error\n\tRun() error\n\tCount() int\n}\n\ntype DefaultTaskSet struct {\n\tTasks []func() error\n}\n\nfunc (s *DefaultTaskSet) Add(task func() error) {\n\ts.Tasks = append(s.Tasks, task)\n}\n\nfunc (s *DefaultTaskSet) GetList() []func() error {\n\treturn s.Tasks\n}\n\nfunc (s *DefaultTaskSet) Run() error {\n\tfor _, task := range s.Tasks {\n\t\tif e := task(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *DefaultTaskSet) Count() int {\n\treturn len(s.Tasks)\n}\n\ntype ScheduledTasks struct {\n\tHalfSec    TaskSet\n\tSec        TaskSet\n\tFifteenMin TaskSet\n\tHour       TaskSet\n\tDay        TaskSet\n\tShutdown   TaskSet\n}\n\nfunc NewScheduledTasks() *ScheduledTasks {\n\treturn &ScheduledTasks{\n\t\tHalfSec:    &DefaultTaskSet{},\n\t\tSec:        &DefaultTaskSet{},\n\t\tFifteenMin: &DefaultTaskSet{},\n\t\tHour:       &DefaultTaskSet{},\n\t\tDay:        &DefaultTaskSet{},\n\t\tShutdown:   &DefaultTaskSet{},\n\t}\n}\n\n/*var ScheduledHalfSecondTasks []func() error\nvar ScheduledSecondTasks []func() error\nvar ScheduledFifteenMinuteTasks []func() error\nvar ScheduledHourTasks []func() error\nvar ScheduledDayTasks []func() error\nvar ShutdownTasks []func() error*/\nvar taskStmts TaskStmts\nvar lastSync time.Time\n\n// TODO: Add a TaskInits.Add\nfunc init() {\n\tlastSync = time.Now()\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\ttaskStmts = TaskStmts{\n\t\t\tgetExpiredScheduledGroups: acc.Select(\"users_groups_scheduler\").Columns(\"uid\").Where(\"UTC_TIMESTAMP() > revert_at AND temporary = 1\").Prepare(),\n\t\t\tgetSync:                   acc.Select(\"sync\").Columns(\"last_update\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// AddScheduledHalfSecondTask is not concurrency safe\n/*func AddScheduledHalfSecondTask(task func() error) {\n\tScheduledHalfSecondTasks = append(ScheduledHalfSecondTasks, task)\n}\n\n// AddScheduledSecondTask is not concurrency safe\nfunc AddScheduledSecondTask(task func() error) {\n\tScheduledSecondTasks = append(ScheduledSecondTasks, task)\n}\n\n// AddScheduledFifteenMinuteTask is not concurrency safe\nfunc AddScheduledFifteenMinuteTask(task func() error) {\n\tScheduledFifteenMinuteTasks = append(ScheduledFifteenMinuteTasks, task)\n}\n\n// AddScheduledHourTask is not concurrency safe\nfunc AddScheduledHourTask(task func() error) {\n\tScheduledHourTasks = append(ScheduledHourTasks, task)\n}\n\n// AddScheduledDayTask is not concurrency safe\nfunc AddScheduledDayTask(task func() error) {\n\tScheduledDayTasks = append(ScheduledDayTasks, task)\n}\n\n// AddShutdownTask is not concurrency safe\nfunc AddShutdownTask(task func() error) {\n\tShutdownTasks = append(ShutdownTasks, task)\n}\n\n// ScheduledHalfSecondTaskCount is not concurrency safe\nfunc ScheduledHalfSecondTaskCount() int {\n\treturn len(ScheduledHalfSecondTasks)\n}\n\n// ScheduledSecondTaskCount is not concurrency safe\nfunc ScheduledSecondTaskCount() int {\n\treturn len(ScheduledSecondTasks)\n}\n\n// ScheduledFifteenMinuteTaskCount is not concurrency safe\nfunc ScheduledFifteenMinuteTaskCount() int {\n\treturn len(ScheduledFifteenMinuteTasks)\n}\n\n// ScheduledHourTaskCount is not concurrency safe\nfunc ScheduledHourTaskCount() int {\n\treturn len(ScheduledHourTasks)\n}\n\n// ScheduledDayTaskCount is not concurrency safe\nfunc ScheduledDayTaskCount() int {\n\treturn len(ScheduledDayTasks)\n}\n\n// ShutdownTaskCount is not concurrency safe\nfunc ShutdownTaskCount() int {\n\treturn len(ShutdownTasks)\n}*/\n\n// TODO: Use AddScheduledSecondTask\nfunc HandleExpiredScheduledGroups() error {\n\trows, e := taskStmts.getExpiredScheduledGroups.Query()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\n\tvar uid int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&uid); e != nil {\n\t\t\treturn e\n\t\t}\n\t\t// Sneaky way of initialising a *User, please use the methods on the UserStore instead\n\t\tuser := BlankUser()\n\t\tuser.ID = uid\n\t\tif e = user.RevertGroupUpdate(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\n// TODO: Use AddScheduledSecondTask\n// TODO: Be a little more granular with the synchronisation\n// TODO: Synchronise more things\n// TODO: Does this even work?\nfunc HandleServerSync() error {\n\t// We don't want to run any unnecessary queries when there is nothing to synchronise\n\tif Config.ServerCount == 1 {\n\t\treturn nil\n\t}\n\n\tvar lastUpdate time.Time\n\te := taskStmts.getSync.QueryRow().Scan(&lastUpdate)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\tif lastUpdate.After(lastSync) {\n\t\tif e = Forums.LoadForums(); e != nil {\n\t\t\tlog.Print(\"Unable to reload the forums\")\n\t\t\treturn e\n\t\t}\n\t\t// TODO: Resync the groups\n\t\t// TODO: Resync the permissions\n\t\tif e = LoadSettings(); e != nil {\n\t\t\tlog.Print(\"Unable to reload the settings\")\n\t\t\treturn e\n\t\t}\n\t\tif e = WordFilters.ReloadAll(); e != nil {\n\t\t\tlog.Print(\"Unable to reload the word filters\")\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/template_init.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/Azareal/Gosora/common/alerts\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\ttmpl \"github.com/Azareal/Gosora/common/templates\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/Gosora/uutils\"\n)\n\nvar Ctemplates []string // TODO: Use this to filter out top level templates we don't need\nvar DefaultTemplates = template.New(\"\")\nvar DefaultTemplateFuncMap map[string]interface{}\n\n//var Templates = template.New(\"\")\nvar PrebuildTmplList []func(User, *Header) CTmpl\n\nfunc skipCTmpl(key string) bool {\n\tfor _, tmpl := range Ctemplates {\n\t\tif strings.HasSuffix(key, \"/\"+tmpl+\".html\") {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\ntype CTmpl struct {\n\tName       string\n\tFilename   string\n\tPath       string\n\tStructName string\n\tData       interface{}\n\tImports    []string\n}\n\nfunc genIntTmpl(name string) func(pi interface{}, w io.Writer) error {\n\treturn func(pi interface{}, w io.Writer) error {\n\t\ttheme := Themes[DefaultThemeBox.Load().(string)]\n\t\tmapping, ok := theme.TemplatesMap[name]\n\t\tif !ok {\n\t\t\tmapping = name\n\t\t}\n\t\treturn DefaultTemplates.ExecuteTemplate(w, mapping+\".html\", pi)\n\t}\n}\n\n// TODO: Refactor the template trees to not need these\n// nolint\nvar Template_topic_handle = genIntTmpl(\"topic\")\nvar Template_topic_guest_handle = Template_topic_handle\nvar Template_topic_member_handle = Template_topic_handle\nvar Template_topic_alt_handle = genIntTmpl(\"topic\")\nvar Template_topic_alt_guest_handle = Template_topic_alt_handle\nvar Template_topic_alt_member_handle = Template_topic_alt_handle\n\n// nolint\nvar Template_topics_handle = genIntTmpl(\"topics\")\nvar Template_topics_guest_handle = Template_topics_handle\nvar Template_topics_member_handle = Template_topics_handle\n\n// nolint\nvar Template_forum_handle = genIntTmpl(\"forum\")\nvar Template_forum_guest_handle = Template_forum_handle\nvar Template_forum_member_handle = Template_forum_handle\n\n// nolint\nvar Template_forums_handle = genIntTmpl(\"forums\")\nvar Template_forums_guest_handle = Template_forums_handle\nvar Template_forums_member_handle = Template_forums_handle\n\n// nolint\nvar Template_profile_handle = genIntTmpl(\"profile\")\nvar Template_profile_guest_handle = Template_profile_handle\nvar Template_profile_member_handle = Template_profile_handle\n\n// nolint\nvar Template_create_topic_handle = genIntTmpl(\"create_topic\")\nvar Template_login_handle = genIntTmpl(\"login\")\nvar Template_register_handle = genIntTmpl(\"register\")\nvar Template_error_handle = genIntTmpl(\"error\")\nvar Template_ip_search_handle = genIntTmpl(\"ip_search\")\nvar Template_account_handle = genIntTmpl(\"account\")\n\nfunc tmplInitUsers() (*User, *User, *User) {\n\tavatar, microAvatar := BuildAvatar(62, \"\")\n\tu := User{62, BuildProfileURL(\"fake-user\", 62), \"Fake User\", \"compiler@localhost\", 0, false, false, false, false, false, false, GuestPerms, make(map[string]bool), \"\", false, \"\", avatar, microAvatar, \"\", \"\", 0, 0, 0, 0, StartTime, \"0.0.0.0.0\", 0, 0, nil, UserPrivacy{}}\n\n\t// TODO: Do a more accurate level calculation for this?\n\tavatar, microAvatar = BuildAvatar(1, \"\")\n\tu2 := User{1, BuildProfileURL(\"admin-alice\", 1), \"Admin Alice\", \"alice@localhost\", 1, true, true, true, true, false, false, AllPerms, make(map[string]bool), \"\", true, \"\", avatar, microAvatar, \"\", \"\", 58, 1000, 0, 1000, StartTime, \"127.0.0.1\", 0, 0, nil, UserPrivacy{}}\n\n\tavatar, microAvatar = BuildAvatar(2, \"\")\n\tu3 := User{2, BuildProfileURL(\"admin-fred\", 62), \"Admin Fred\", \"fred@localhost\", 1, true, true, true, true, false, false, AllPerms, make(map[string]bool), \"\", true, \"\", avatar, microAvatar, \"\", \"\", 42, 900, 0, 900, StartTime, \"::1\", 0, 0, nil, UserPrivacy{}}\n\treturn &u, &u2, &u3\n}\n\nfunc tmplInitHeaders(u, u2, u3 *User) (*Header, *Header, *Header) {\n\theader := &Header{\n\t\tSite:     Site,\n\t\tSettings: SettingBox.Load().(SettingMap),\n\t\t//Themes:          Themes,\n\t\tThemesSlice:     ThemesSlice,\n\t\tTheme:           Themes[DefaultThemeBox.Load().(string)],\n\t\tCurrentUser:     u,\n\t\tNoticeList:      []string{\"test\"},\n\t\tStylesheets:     []HScript{{\"panel.css\", \"\"}},\n\t\tScripts:         []HScript{{\"whatever.js\", \"\"}},\n\t\tPreScriptsAsync: []HScript{{\"whatever.js\", \"\"}},\n\t\tScriptsAsync:    []HScript{{\"whatever.js\", \"\"}},\n\t\tWidgets: PageWidgets{\n\t\t\tLeftSidebar: template.HTML(\"lalala\"),\n\t\t},\n\t}\n\n\tbuildHeader := func(u *User) *Header {\n\t\thead := &Header{Site: Site}\n\t\t*head = *header\n\t\thead.CurrentUser = u\n\t\treturn head\n\t}\n\n\treturn header, buildHeader(u2), buildHeader(u3)\n}\n\ntype TmplLoggedin struct {\n\tStub   string\n\tGuest  string\n\tMember string\n}\n\ntype nobreak interface{}\n\ntype TItem struct {\n\tExpects    string\n\tExpectsInt interface{}\n\tLoggedIn   bool\n}\n\ntype TItemHold map[string]TItem\n\nfunc (h TItemHold) Add(name, expects string, expectsInt interface{}) {\n\th[name] = TItem{expects, expectsInt, true}\n}\n\nfunc (h TItemHold) AddStd(name, expects string, expectsInt interface{}) {\n\th[name] = TItem{expects, expectsInt, false}\n}\n\n// ? - Add template hooks?\nfunc CompileTemplates() error {\n\tlog.Print(\"Compiling the templates\")\n\t// TODO: Implement per-theme template overrides here too\n\toverriden := make(map[string]map[string]bool)\n\tfor _, th := range Themes {\n\t\toverriden[th.Name] = make(map[string]bool)\n\t\tlog.Printf(\"th.OverridenTemplates: %+v\\n\", th.OverridenTemplates)\n\t\tfor _, override := range th.OverridenTemplates {\n\t\t\toverriden[th.Name][override] = true\n\t\t}\n\t}\n\tlog.Printf(\"overriden: %+v\\n\", overriden)\n\n\tconfig := tmpl.CTemplateConfig{\n\t\tMinify:     Config.MinifyTemplates,\n\t\tDebug:      Dev.DebugMode,\n\t\tSuperDebug: Dev.TemplateDebug,\n\t\tDockToID:   DockToID,\n\t}\n\tc := tmpl.NewCTemplateSet(\"normal\", \"./logs/\")\n\tc.SetConfig(config)\n\tc.SetBaseImportMap(map[string]string{\n\t\t\"io\":                               \"io\",\n\t\t\"github.com/Azareal/Gosora/common\": \"c github.com/Azareal/Gosora/common\",\n\t})\n\tc.SetBuildTags(\"!no_templategen\")\n\tc.SetOverrideTrack(overriden)\n\tc.SetPerThemeTmpls(make(map[string]bool))\n\n\tlog.Print(\"Compiling the default templates\")\n\tvar wg sync.WaitGroup\n\tif err := compileTemplates(&wg, c, \"\"); err != nil {\n\t\treturn err\n\t}\n\toroots := c.GetOverridenRoots()\n\tlog.Printf(\"oroots: %+v\\n\", oroots)\n\n\tlog.Print(\"Compiling the per-theme templates\")\n\tfor th, tmpls := range oroots {\n\t\tc.ResetLogs(\"normal-\" + th)\n\t\tc.SetThemeName(th)\n\t\tc.SetPerThemeTmpls(tmpls)\n\t\tlog.Print(\"th: \", th)\n\t\tlog.Printf(\"perThemeTmpls: %+v\\n\", tmpls)\n\t\terr := compileTemplates(&wg, c, th)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\twriteTemplateList(c, &wg, \"./\")\n\treturn nil\n}\n\nfunc compileCommons(c *tmpl.CTemplateSet, head, head2 *Header, forumList []Forum, o TItemHold) error {\n\t// TODO: Add support for interface{}s\n\t_, user2, user3 := tmplInitUsers()\n\tnow := time.Now()\n\n\t// Convienience function to save a line here and there\n\thtitle := func(name string) *Header {\n\t\thead.Title = name\n\t\treturn head\n\t}\n\t/*htitle2 := func(name string) *Header {\n\t\thead2.Title = name\n\t\treturn head2\n\t}*/\n\n\tvar topicsList []TopicsRowMut\n\ttopic := Topic{1, \"/topic/topic-title.1\", \"Topic Title\", \"The topic content.\", 1, false, false, now, now, user3.ID, 1, 1, \"\", \"::1\", 1, 0, 1, 1, 1, \"classname\", 0, \"\", nil}\n\ttopicsList = append(topicsList, TopicsRowMut{&TopicsRow{topic, 1, user2, \"\", 0, user3, \"General\", \"/forum/general.2\"}, false})\n\ttopicListPage := TopicListPage{htitle(\"Topic List\"), topicsList, forumList, Config.DefaultForum, TopicListSort{\"lastupdated\", false}, []int{1}, QuickTools{false, false, false}, Paginator{[]int{1}, 1, 1}}\n\to.Add(\"topics\", \"c.TopicListPage\", topicListPage)\n\to.Add(\"topics_mini\", \"c.TopicListPage\", topicListPage)\n\n\tforumItem := BlankForum(1, \"general-forum.1\", \"General Forum\", \"Where the general stuff happens\", true, \"all\", 0, \"\", 0)\n\tforumPage := ForumPage{htitle(\"General Forum\"), topicsList, forumItem, false, false, Paginator{[]int{1}, 1, 1}}\n\to.Add(\"forum\", \"c.ForumPage\", forumPage)\n\to.Add(\"forums\", \"c.ForumsPage\", ForumsPage{htitle(\"Forum List\"), forumList})\n\n\tpoll := Poll{ID: 1, Type: 0, Options: map[int]string{0: \"Nothing\", 1: \"Something\"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{\n\t\t{0, \"Nothing\"},\n\t\t{1, \"Something\"},\n\t}, VoteCount: 7}\n\tavatar, microAvatar := BuildAvatar(62, \"\")\n\tminiAttach := []*MiniAttachment{{Path: \"/\"}}\n\ttu := TopicUser{1, \"blah\", \"Blah\", \"Hey there!\", 0, false, false, now, now, 1, 1, 0, \"\", \"127.0.0.1\", 1, 0, 1, 0, \"classname\", poll.ID, \"weird-data\", BuildProfileURL(\"fake-user\", 62), \"Fake User\", Config.DefaultGroup, avatar, microAvatar, 0, \"\", \"\", \"\", 58, false, miniAttach, nil, false}\n\n\tvar replyList []*ReplyUser\n\treply := Reply{1, 1, \"Yo!\", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, \"::1\", true, 1, 1, \"\"}\n\tru := &ReplyUser{ClassName: \"\", Reply: reply, CreatedByName: \"Alice\", Avatar: avatar, Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach}\n\t_, err := ru.Init(user2)\n\tif err != nil {\n\t\treturn err\n\t}\n\treplyList = append(replyList, ru)\n\ttpage := TopicPage{htitle(\"Topic Name\"), replyList, tu, &Forum{ID: 1, Name: \"Hahaha\"}, &poll, Paginator{[]int{1}, 1, 1}}\n\ttpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID)\n\to.Add(\"topic\", \"c.TopicPage\", tpage)\n\to.Add(\"topic_mini\", \"c.TopicPage\", tpage)\n\to.Add(\"topic_alt\", \"c.TopicPage\", tpage)\n\to.Add(\"topic_alt_mini\", \"c.TopicPage\", tpage)\n\treturn nil\n}\n\nfunc compileTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName string) error {\n\t// Schemas to train the template compiler on what to expect\n\t// TODO: Add support for interface{}s\n\tuser, user2, user3 := tmplInitUsers()\n\theader, header2, _ := tmplInitHeaders(user, user2, user3)\n\tnow := time.Now()\n\n\t/*poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: \"Nothing\", 1: \"Something\"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{\n\t\tPollOption{0, \"Nothing\"},\n\t\tPollOption{1, \"Something\"},\n\t}, VoteCount: 7}*/\n\t//avatar, microAvatar := BuildAvatar(62, \"\")\n\tminiAttach := []*MiniAttachment{{Path: \"/\"}}\n\tvar replyList []*ReplyUser\n\t//topic := TopicUser{1, \"blah\", \"Blah\", \"Hey there!\", 0, false, false, now, now, 1, 1, 0, \"\", \"127.0.0.1\", 1, 0, 1, 0, \"classname\", poll.ID, \"weird-data\", BuildProfileURL(\"fake-user\", 62), \"Fake User\", Config.DefaultGroup, avatar, microAvatar, 0, \"\", \"\", \"\", \"\", \"\", 58, false, miniAttach, nil}\n\t// TODO: Do we want the UID on this to be 0?\n\t//avatar, microAvatar = BuildAvatar(0, \"\")\n\treply := Reply{1, 1, \"Yo!\", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, \"::1\", true, 1, 1, \"\"}\n\tru := &ReplyUser{ClassName: \"\", Reply: reply, CreatedByName: \"Alice\", Avatar: \"\", Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach}\n\t_, err := ru.Init(user)\n\tif err != nil {\n\t\treturn err\n\t}\n\treplyList = append(replyList, ru)\n\n\tforum := BlankForum(1, \"/forum/d.1\", \"d\", \"d desc\", true, \"\", 0, \"\", 1)\n\tforum.LastTopic = BlankTopic()\n\tforum.LastReplyer = BlankUser()\n\tforumList := []Forum{*forum}\n\n\t// Convienience function to save a line here and there\n\thtitle := func(name string) *Header {\n\t\theader.Title = name\n\t\treturn header\n\t}\n\tt := TItemHold(make(map[string]TItem))\n\terr = compileCommons(c, header, header2, forumList, t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tppage := ProfilePage{htitle(\"User 526\"), replyList, *user, 0, 0, false, false, false, false} // TODO: Use the score from user to generate the currentScore and nextScore\n\tt.Add(\"profile\", \"c.ProfilePage\", ppage)\n\n\tvar topicsList []TopicsRowMut\n\ttopic := Topic{1, \"topic-title\", \"Topic Title\", \"The topic content.\", 1, false, false, now, now, user3.ID, 1, 1, \"\", \"::1\", 1, 0, 1, 1, 1, \"classname\", 0, \"\", nil}\n\ttopicsList = append(topicsList, TopicsRowMut{&TopicsRow{topic, 0, user2, \"\", 0, user3, \"General\", \"/forum/general.2\"}, false})\n\ttopicListPage := TopicListPage{htitle(\"Topic List\"), topicsList, forumList, Config.DefaultForum, TopicListSort{\"lastupdated\", false}, []int{1}, QuickTools{false, false, false}, Paginator{[]int{1}, 1, 1}}\n\n\tforumItem := BlankForum(1, \"general-forum.1\", \"General Forum\", \"Where the general stuff happens\", true, \"all\", 0, \"\", 0)\n\tforumPage := ForumPage{htitle(\"General Forum\"), topicsList, forumItem, false, false, Paginator{[]int{1}, 1, 1}}\n\n\t// Experimental!\n\tfor _, tmpl := range strings.Split(Dev.ExtraTmpls, \",\") {\n\t\tsp := strings.Split(tmpl, \":\")\n\t\tif len(sp) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttyp := \"0\"\n\t\tif len(sp) == 3 {\n\t\t\ttyp = sp[2]\n\t\t}\n\n\t\tvar pi interface{}\n\t\tswitch sp[1] {\n\t\tcase \"c.TopicListPage\":\n\t\t\tpi = topicListPage\n\t\tcase \"c.ForumPage\":\n\t\t\tpi = forumPage\n\t\tcase \"c.ProfilePage\":\n\t\t\tpi = ppage\n\t\tcase \"c.Page\":\n\t\t\tpi = Page{htitle(\"Something\"), tList, nil}\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif typ == \"1\" {\n\t\t\tt.Add(sp[0], sp[1], pi)\n\t\t} else {\n\t\t\tt.AddStd(sp[0], sp[1], pi)\n\t\t}\n\t}\n\n\tt.AddStd(\"login\", \"c.Page\", Page{htitle(\"Login Page\"), tList, nil})\n\tt.AddStd(\"register\", \"c.RegisterPage\", RegisterPage{htitle(\"Registration Page\"), false, \"\", []RegisterVerify{{true, &RegisterVerifyImageGrid{\"What?\", []RegisterVerifyImageGridImage{{\"something.png\"}}}}}})\n\tt.AddStd(\"error\", \"c.ErrorPage\", ErrorPage{htitle(\"Error\"), \"A problem has occurred in the system.\"})\n\n\tipSearchPage := IPSearchPage{htitle(\"IP Search\"), map[int]*User{1: user2}, \"::1\"}\n\tt.AddStd(\"ip_search\", \"c.IPSearchPage\", ipSearchPage)\n\n\tvar inter nobreak\n\taccountPage := Account{header, \"dashboard\", \"account_own_edit\", inter}\n\tt.AddStd(\"account\", \"c.Account\", accountPage)\n\n\tparti := []*User{user}\n\tconvo := &Conversation{1, BuildConvoURL(1), user.ID, time.Now(), 0, time.Now()}\n\tconvoItems := []ConvoViewRow{{&ConversationPost{1, 1, \"hey\", \"\", user.ID}, user, \"\", 4, true}}\n\tconvoPage := ConvoViewPage{header, convo, convoItems, parti, true, Paginator{[]int{1}, 1, 1}}\n\tt.AddStd(\"convo\", \"c.ConvoViewPage\", convoPage)\n\n\tconvos := []*ConversationExtra{{&Conversation{}, []*User{user}}}\n\tvar cRows []ConvoListRow\n\tfor _, convo := range convos {\n\t\tcRows = append(cRows, ConvoListRow{convo, convo.Users, false})\n\t}\n\tconvoListPage := ConvoListPage{header, cRows, Paginator{[]int{1}, 1, 1}}\n\tt.AddStd(\"convos\", \"c.ConvoListPage\", convoListPage)\n\n\tbasePage := &BasePanelPage{header, PanelStats{}, \"dashboard\", ReportForumID, true}\n\tt.AddStd(\"panel\", \"c.Panel\", Panel{basePage, \"panel_dashboard_right\", \"\", \"panel_dashboard\", inter})\n\tges := []GridElement{{\"\", \"\", \"\", 1, \"grid_istat\", \"\", \"\", \"\"}}\n\tt.AddStd(\"panel_dashboard\", \"c.DashGrids\", DashGrids{ges, ges})\n\n\tgoVersion := runtime.Version()\n\tdbVersion := qgen.Builder.DbVersion()\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\tdebugTasks := DebugPageTasks{0, 0, 0, 0, 0, 0}\n\tdebugCache := DebugPageCache{1, 1, 1, 2, 2, 2, true}\n\tdebugDatabase := DebugPageDatabase{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}\n\tdebugDisk := DebugPageDisk{1, 1, 1, 1, 1, 1}\n\tdpage := PanelDebugPage{basePage, goVersion, dbVersion, \"0s\", 1, qgen.Builder.GetAdapter().GetName(), 1, 1, 1, debugTasks, memStats, debugCache, debugDatabase, debugDisk}\n\tt.AddStd(\"panel_debug\", \"c.PanelDebugPage\", dpage)\n\t//t.AddStd(\"panel_analytics\", \"c.PanelAnalytics\", Panel{basePage, \"panel_dashboard_right\",\"panel_dashboard\", inter})\n\n\twriteTemplate := func(name string, content interface{}) {\n\t\tlog.Print(\"Writing template '\" + name + \"'\")\n\t\twriteTmpl := func(name, content string) {\n\t\t\tif content == \"\" {\n\t\t\t\treturn //log.Fatal(\"No content body for \" + name)\n\t\t\t}\n\t\t\te := writeFile(\"./tmpl_\"+name+\".go\", content)\n\t\t\tif e != nil {\n\t\t\t\tlog.Fatal(e)\n\t\t\t}\n\t\t}\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer EatPanics()\n\t\t\ttname := themeName\n\t\t\tif tname != \"\" {\n\t\t\t\ttname = \"_\" + tname\n\t\t\t}\n\t\t\tswitch content := content.(type) {\n\t\t\tcase string:\n\t\t\t\twriteTmpl(name+tname, content)\n\t\t\tcase TmplLoggedin:\n\t\t\t\twriteTmpl(name+tname, content.Stub)\n\t\t\t\twriteTmpl(name+tname+\"_guest\", content.Guest)\n\t\t\t\twriteTmpl(name+tname+\"_member\", content.Member)\n\t\t\t}\n\t\t\twg.Done()\n\t\t}()\n\t}\n\n\t// Let plugins register their own templates\n\tDebugLog(\"Registering the templates for the plugins\")\n\tconfig := c.GetConfig()\n\tconfig.SkipHandles = true\n\tc.SetConfig(config)\n\tfor _, tmplfunc := range PrebuildTmplList {\n\t\ttmplItem := tmplfunc(*user, header)\n\t\tvarList := make(map[string]tmpl.VarItem)\n\t\tcompiledTmpl, err := c.Compile(tmplItem.Filename, tmplItem.Path, tmplItem.StructName, tmplItem.Data, varList, tmplItem.Imports...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\twriteTemplate(tmplItem.Name, compiledTmpl)\n\t}\n\n\tlog.Print(\"Writing the templates\")\n\tfor name, titem := range t {\n\t\tlog.Print(\"Writing \" + name)\n\t\tvarList := make(map[string]tmpl.VarItem)\n\t\tif titem.LoggedIn {\n\t\t\tstub, guest, member, err := c.CompileByLoggedin(name+\".html\", \"templates/\", titem.Expects, titem.ExpectsInt, varList)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\twriteTemplate(name, TmplLoggedin{stub, guest, member})\n\t\t} else {\n\t\t\ttmpl, err := c.Compile(name+\".html\", \"templates/\", titem.Expects, titem.ExpectsInt, varList)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\twriteTemplate(name, tmpl)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ? - Add template hooks?\nfunc CompileJSTemplates() error {\n\tlog.Print(\"Compiling the JS templates\")\n\t// TODO: Implement per-theme template overrides here too\n\toverriden := make(map[string]map[string]bool)\n\tfor _, theme := range Themes {\n\t\toverriden[theme.Name] = make(map[string]bool)\n\t\tlog.Printf(\"theme.OverridenTemplates: %+v\\n\", theme.OverridenTemplates)\n\t\tfor _, override := range theme.OverridenTemplates {\n\t\t\toverriden[theme.Name][override] = true\n\t\t}\n\t}\n\tlog.Printf(\"overriden: %+v\\n\", overriden)\n\n\tconfig := tmpl.CTemplateConfig{\n\t\tMinify:         Config.MinifyTemplates,\n\t\tDebug:          Dev.DebugMode,\n\t\tSuperDebug:     Dev.TemplateDebug,\n\t\tSkipHandles:    true,\n\t\tSkipTmplPtrMap: true,\n\t\tSkipInitBlock:  false,\n\t\tPackageName:    \"tmpl\",\n\t\tDockToID:       DockToID,\n\t}\n\tc := tmpl.NewCTemplateSet(\"js\", \"./logs/\")\n\tc.SetConfig(config)\n\tc.SetBuildTags(\"!no_templategen\")\n\tc.SetOverrideTrack(overriden)\n\tc.SetPerThemeTmpls(make(map[string]bool))\n\n\tlog.Print(\"Compiling the default templates\")\n\tvar wg sync.WaitGroup\n\terr := compileJSTemplates(&wg, c, \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\toroots := c.GetOverridenRoots()\n\tlog.Printf(\"oroots: %+v\\n\", oroots)\n\n\tlog.Print(\"Compiling the per-theme templates\")\n\tfor theme, tmpls := range oroots {\n\t\tc.SetThemeName(theme)\n\t\tc.SetPerThemeTmpls(tmpls)\n\t\tlog.Print(\"theme: \", theme)\n\t\tlog.Printf(\"perThemeTmpls: %+v\\n\", tmpls)\n\t\terr = compileJSTemplates(&wg, c, theme)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tdirPrefix := \"./tmpl_client/\"\n\twriteTemplateList(c, &wg, dirPrefix)\n\treturn nil\n}\n\nfunc compileJSTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName string) error {\n\tuser, user2, user3 := tmplInitUsers()\n\theader, _, _ := tmplInitHeaders(user, user2, user3)\n\tnow := time.Now()\n\tvarList := make(map[string]tmpl.VarItem)\n\n\tc.SetBaseImportMap(map[string]string{\n\t\t\"io\": \"io\",\n\t\t\"github.com/Azareal/Gosora/common/alerts\": \"github.com/Azareal/Gosora/common/alerts\",\n\t})\n\n\t// TODO: Check what sort of path is sent exactly and use it here\n\talertItem := alerts.AlertItem{Avatar: \"\", ASID: 1, Path: \"/\", Message: \"uh oh, something happened\"}\n\talertTmpl, err := c.Compile(\"alert.html\", \"templates/\", \"alerts.AlertItem\", alertItem, varList)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.SetBaseImportMap(map[string]string{\n\t\t\"io\":                               \"io\",\n\t\t\"github.com/Azareal/Gosora/common\": \"c github.com/Azareal/Gosora/common\",\n\t})\n\t// TODO: Fix the import loop so we don't have to use this hack anymore\n\tc.SetBuildTags(\"!no_templategen,tmplgentopic\")\n\n\tt := TItemHold(make(map[string]TItem))\n\n\ttopic := Topic{1, \"topic-title\", \"Topic Title\", \"The topic content.\", 1, false, false, now, now, user3.ID, 1, 1, \"\", \"::1\", 1, 0, 1, 0, 1, \"classname\", 1, \"\", nil}\n\ttopicsRow := TopicsRowMut{&TopicsRow{topic, 0, user2, \"\", 0, user3, \"General\", \"/forum/general.2\"}, false}\n\tt.AddStd(\"topics_topic\", \"c.TopicsRowMut\", topicsRow)\n\n\tpoll := Poll{ID: 1, Type: 0, Options: map[int]string{0: \"Nothing\", 1: \"Something\"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{\n\t\t{0, \"Nothing\"},\n\t\t{1, \"Something\"},\n\t}, VoteCount: 7}\n\tavatar, microAvatar := BuildAvatar(62, \"\")\n\tminiAttach := []*MiniAttachment{{Path: \"/\"}}\n\ttu := TopicUser{1, \"blah\", \"Blah\", \"Hey there!\", 62, false, false, now, now, 1, 1, 0, \"\", \"::1\", 1, 0, 1, 0, \"classname\", poll.ID, \"weird-data\", BuildProfileURL(\"fake-user\", 62), \"Fake User\", Config.DefaultGroup, avatar, microAvatar, 0, \"\", \"\", \"\", 58, false, miniAttach, nil, false}\n\tvar replyList []*ReplyUser\n\t// TODO: Do we really want the UID here to be zero?\n\tavatar, microAvatar = BuildAvatar(0, \"\")\n\treply := Reply{1, 1, \"Yo!\", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, \"::1\", true, 1, 1, \"\"}\n\tru := &ReplyUser{ClassName: \"\", Reply: reply, CreatedByName: \"Alice\", Avatar: avatar, Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach}\n\t_, err = ru.Init(user)\n\tif err != nil {\n\t\treturn err\n\t}\n\treplyList = append(replyList, ru)\n\n\tvarList = make(map[string]tmpl.VarItem)\n\theader.Title = \"Topic Name\"\n\ttpage := TopicPage{header, replyList, tu, &Forum{ID: 1, Name: \"Hahaha\"}, &poll, Paginator{[]int{1}, 1, 1}}\n\ttpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID)\n\tt.AddStd(\"topic_posts\", \"c.TopicPage\", tpage)\n\tt.AddStd(\"topic_alt_posts\", \"c.TopicPage\", tpage)\n\n\titemsPerPage := 25\n\t_, page, lastPage := PageOffset(20, 1, itemsPerPage)\n\tpageList := Paginate(page, lastPage, 5)\n\tt.AddStd(\"paginator\", \"c.Paginator\", Paginator{pageList, page, lastPage})\n\n\tt.AddStd(\"topic_c_edit_post\", \"c.TopicCEditPost\", TopicCEditPost{ID: 0, Source: \"\", Ref: \"\"})\n\tt.AddStd(\"topic_c_attach_item\", \"c.TopicCAttachItem\", TopicCAttachItem{ID: 1, ImgSrc: \"\", Path: \"\", FullPath: \"\"})\n\tt.AddStd(\"topic_c_poll_input\", \"c.TopicCPollInput\", TopicCPollInput{Index: 0})\n\n\tparti := []*User{user}\n\tconvo := &Conversation{1, BuildConvoURL(1), user.ID, time.Now(), 0, time.Now()}\n\tconvoItems := []ConvoViewRow{{&ConversationPost{1, 1, \"hey\", \"\", user.ID}, user, \"\", 4, true}}\n\tconvoPage := ConvoViewPage{header, convo, convoItems, parti, true, Paginator{[]int{1}, 1, 1}}\n\tt.AddStd(\"convo\", \"c.ConvoViewPage\", convoPage)\n\n\tt.AddStd(\"notice\", \"string\", \"nonono\")\n\n\tdirPrefix := \"./tmpl_client/\"\n\twriteTemplate := func(name, content string) {\n\t\tlog.Print(\"Writing template '\" + name + \"'\")\n\t\tif content == \"\" {\n\t\t\treturn //log.Fatal(\"No content body\")\n\t\t}\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer EatPanics()\n\t\t\ttname := themeName\n\t\t\tif tname != \"\" {\n\t\t\t\ttname = \"_\" + tname\n\t\t\t}\n\t\t\te := writeFile(dirPrefix+\"tmpl_\"+name+tname+\".jgo\", content)\n\t\t\tif e != nil {\n\t\t\t\tlog.Fatal(e)\n\t\t\t}\n\t\t\twg.Done()\n\t\t}()\n\t}\n\n\tlog.Print(\"Writing the templates\")\n\tfor name, titem := range t {\n\t\tlog.Print(\"Writing \" + name)\n\t\tvarList := make(map[string]tmpl.VarItem)\n\t\ttmpl, err := c.Compile(name+\".html\", \"templates/\", titem.Expects, titem.ExpectsInt, varList)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\twriteTemplate(name, tmpl)\n\t}\n\twriteTemplate(\"alert\", alertTmpl)\n\t/*//writeTemplate(\"forum\", forumTmpl)\n\twriteTemplate(\"topic_posts\", topicPostsTmpl)\n\twriteTemplate(\"topic_alt_posts\", topicAltPostsTmpl)\n\twriteTemplateList(c, &wg, dirPrefix)*/\n\treturn nil\n}\n\nvar poutlen = len(\"\\n// nolint\\nfunc init() {\\n\")\nvar poutlooplen = len(\"__frags[0]=a_0[:]\\n\")\n\nfunc getTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) string {\n\tDebugLog(\"in getTemplateList\")\n\t//pout := \"\\n// nolint\\nfunc init() {\\n\"\n\ttFragCount := make(map[string]int)\n\tbodyMap := make(map[string]string) //map[body]fragmentPrefix\n\t//tmplMap := make(map[string]map[string]string) // map[tmpl]map[body]fragmentPrefix\n\ttmpCount := 0\n\tvar bsb strings.Builder\n\tvar poutsb strings.Builder\n\tpoutsb.Grow(poutlen + (poutlooplen * len(c.FragOut)))\n\tpoutsb.WriteString(\"\\n// nolint\\nfunc init() {\\n\")\n\tfor _, frag := range c.FragOut {\n\t\tfront := frag.TmplName + \"_frags[\" + strconv.Itoa(frag.Index) + \"]\"\n\t\tDebugLog(\"front: \", front)\n\t\tDebugLog(\"frag.Body: \", frag.Body)\n\t\t/*bodyMap, tok := tmplMap[frag.TmplName]\n\t\tif !tok {\n\t\t\ttmplMap[frag.TmplName] = make(map[string]string)\n\t\t\tbodyMap = tmplMap[frag.TmplName]\n\t\t}*/\n\t\tfp, ok := bodyMap[frag.Body]\n\t\tif !ok {\n\t\t\tbodyMap[frag.Body] = front\n\t\t\t//var bits string\n\t\t\tbsb.Reset()\n\t\t\tDebugLog(\"encoding f.Body\")\n\t\t\tfor _, char := range []byte(frag.Body) {\n\t\t\t\tif char == '\\'' {\n\t\t\t\t\t//bits += \"'\\\\\" + string(char) + \"',\"\n\t\t\t\t\tbsb.WriteString(\"'\\\\'',\")\n\t\t\t\t} else if char < 32 {\n\t\t\t\t\t//bits += strconv.Itoa(int(char)) + \",\"\n\t\t\t\t\tbsb.WriteString(strconv.Itoa(int(char)))\n\t\t\t\t\tbsb.WriteByte(',')\n\t\t\t\t} else {\n\t\t\t\t\t//bits += \"'\" + string(char) + \"',\"\n\t\t\t\t\tbsb.WriteByte('\\'')\n\t\t\t\t\tbsb.WriteString(string(char))\n\t\t\t\t\tbsb.WriteString(\"',\")\n\t\t\t\t}\n\t\t\t}\n\t\t\ttmpStr := strconv.Itoa(tmpCount)\n\t\t\t//\"a_\" + tmpStr + \":=[...]byte{\" + /*bits*/ bsb.String() + \"}\\n\"\n\t\t\tpoutsb.WriteString(\"a_\")\n\t\t\tpoutsb.WriteString(tmpStr)\n\t\t\tpoutsb.WriteString(\":=[...]byte{\")\n\t\t\tpoutsb.WriteString(bsb.String())\n\t\t\tpoutsb.WriteString(\"}\\n\")\n\n\t\t\t//front + \"=a_\" + tmpStr + \"[:]\\n\"\n\t\t\tpoutsb.WriteString(front)\n\t\t\tpoutsb.WriteString(\"=a_\")\n\t\t\tpoutsb.WriteString(tmpStr)\n\t\t\tpoutsb.WriteString(\"[:]\\n\")\n\t\t\ttmpCount++\n\t\t\t//pout += front + \"=[]byte(`\" + frag.Body + \"`)\\n\"\n\t\t} else {\n\t\t\tDebugLog(\"encoding cached index \" + fp)\n\t\t\tpoutsb.WriteString(front + \"=\" + fp + \"\\n\")\n\t\t}\n\n\t\t_, ok = tFragCount[frag.TmplName]\n\t\tif !ok {\n\t\t\ttFragCount[frag.TmplName] = 0\n\t\t}\n\t\ttFragCount[frag.TmplName]++\n\t}\n\n\t//out := \"package \" + c.GetConfig().PackageName + \"\\n\\n\"\n\tbsb.Reset()\n\tsb := bsb\n\tpkgName := c.GetConfig().PackageName\n\tsb.Grow(tllenhint + ((looplenhint + 2) + (looplenhint2+2)*len(tFragCount)) + len(pkgName))\n\tsb.WriteString(\"package \")\n\tsb.WriteString(pkgName)\n\tsb.WriteString(\"\\n\\n\")\n\tfor templateName, count := range tFragCount {\n\t\t//out += \"var \" + templateName + \"_frags = make([][]byte,\" + strconv.Itoa(count) + \")\\n\"\n\t\t//out += \"var \" + templateName + \"_frags [\" + strconv.Itoa(count) + \"][]byte\\n\"\n\t\tsb.WriteString(\"var \")\n\t\tsb.WriteString(templateName)\n\t\tsb.WriteString(\"_frags [\")\n\t\tsb.WriteString(strconv.Itoa(count))\n\t\tsb.WriteString(\"][]byte\\n\")\n\t}\n\tsb.WriteString(poutsb.String())\n\tsb.WriteString(\"\\n\\n// nolint\\nGetFrag = func(name string) [][]byte {\\nswitch(name) {\\n\")\n\t//getterstr := \"\\n// nolint\\nGetFrag = func(name string) [][]byte {\\nswitch(name) {\\n\"\n\tfor templateName, _ := range tFragCount {\n\t\t//getterstr += \"\\tcase \\\"\" + templateName + \"\\\":\\n\"\n\t\t///getterstr += \"\\treturn \" + templateName + \"_frags\\n\"\n\t\t//getterstr += \"\\treturn \" + templateName + \"_frags[:]\\n\"\n\t\tsb.WriteString(\"\\tcase \\\"\")\n\t\tsb.WriteString(templateName)\n\t\tsb.WriteString(\"\\\":\\n\\treturn \")\n\t\tsb.WriteString(templateName)\n\t\tsb.WriteString(\"_frags[:]\\n\")\n\t}\n\tsb.WriteString(\"}\\nreturn nil\\n}\\n}\\n\")\n\t//getterstr += \"}\\nreturn nil\\n}\\n\"\n\t//out += pout + \"\\n\" + getterstr + \"}\\n\"\n\n\treturn sb.String()\n}\n\nvar looplenhint = len(\"var _frags [][]byte\\n\")\nvar looplenhint2 = len(\"\\tcase \\\"\\\":\\n\\treturn _frags[:]\\n\")\nvar tllenhint = len(\"package \\n\\n\\n// nolint\\nGetFrag = func(name string) [][]byte {\\nswitch(name) {\\nvar _frags [][]byte\\n\\tcase \\\"\\\":\\n\\treturn _frags[:]\\n}\\nreturn nil\\n}\\n\\n}\\n\")\n\nfunc writeTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) {\n\tlog.Print(\"Writing template list\")\n\twg.Add(1)\n\tgo func() {\n\t\tdefer EatPanics()\n\t\te := writeFile(prefix+\"tmpl_list.go\", getTemplateList(c, wg, prefix))\n\t\tif e != nil {\n\t\t\tlog.Fatal(e)\n\t\t}\n\t\twg.Done()\n\t}()\n\twg.Wait()\n}\n\nfunc arithToInt64(in interface{}) (o int64) {\n\tswitch in := in.(type) {\n\tcase int64:\n\t\to = in\n\tcase int32:\n\t\to = int64(in)\n\tcase int:\n\t\to = int64(in)\n\tcase uint32:\n\t\to = int64(in)\n\tcase uint16:\n\t\to = int64(in)\n\tcase uint8:\n\t\to = int64(in)\n\tcase uint:\n\t\to = int64(in)\n\t}\n\treturn o\n}\n\nfunc arithDuoToInt64(left, right interface{}) (leftInt, rightInt int64) {\n\treturn arithToInt64(left), arithToInt64(right)\n}\n\nfunc initDefaultTmplFuncMap() {\n\t// TODO: Add support for floats\n\tfmap := make(map[string]interface{})\n\tfmap[\"add\"] = func(left, right interface{}) interface{} {\n\t\tleftInt, rightInt := arithDuoToInt64(left, right)\n\t\treturn leftInt + rightInt\n\t}\n\n\tfmap[\"subtract\"] = func(left, right interface{}) interface{} {\n\t\tleftInt, rightInt := arithDuoToInt64(left, right)\n\t\treturn leftInt - rightInt\n\t}\n\n\tfmap[\"multiply\"] = func(left, right interface{}) interface{} {\n\t\tleftInt, rightInt := arithDuoToInt64(left, right)\n\t\treturn leftInt * rightInt\n\t}\n\n\tfmap[\"divide\"] = func(left, right interface{}) interface{} {\n\t\tleftInt, rightInt := arithDuoToInt64(left, right)\n\t\tif leftInt == 0 || rightInt == 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn leftInt / rightInt\n\t}\n\n\tfmap[\"dock\"] = func(dock, headerInt interface{}) interface{} {\n\t\treturn template.HTML(BuildWidget(dock.(string), headerInt.(*Header)))\n\t}\n\n\tfmap[\"hasWidgets\"] = func(dock, headerInt interface{}) interface{} {\n\t\treturn HasWidgets(dock.(string), headerInt.(*Header))\n\t}\n\n\tfmap[\"elapsed\"] = func(startedAtInt interface{}) interface{} {\n\t\t//return time.Since(startedAtInt.(time.Time)).String()\n\t\treturn time.Duration(uutils.Nanotime() - startedAtInt.(int64)).String()\n\t}\n\n\tfmap[\"lang\"] = func(phraseNameInt interface{}) interface{} {\n\t\tphraseName, ok := phraseNameInt.(string)\n\t\tif !ok {\n\t\t\tpanic(\"phraseNameInt is not a string\")\n\t\t}\n\t\t// TODO: Log non-existent phrases?\n\t\treturn template.HTML(p.GetTmplPhrase(phraseName))\n\t}\n\n\t// TODO: Implement this in the template generator too\n\tfmap[\"langf\"] = func(phraseNameInt interface{}, args ...interface{}) interface{} {\n\t\tphraseName, ok := phraseNameInt.(string)\n\t\tif !ok {\n\t\t\tpanic(\"phraseNameInt is not a string\")\n\t\t}\n\t\t// TODO: Log non-existent phrases?\n\t\t// TODO: Optimise TmplPhrasef so we don't use slow Sprintf there\n\t\treturn template.HTML(p.GetTmplPhrasef(phraseName, args...))\n\t}\n\n\tfmap[\"level\"] = func(levelInt interface{}) interface{} {\n\t\tlevel, ok := levelInt.(int)\n\t\tif !ok {\n\t\t\tpanic(\"levelInt is not an integer\")\n\t\t}\n\t\treturn template.HTML(p.GetLevelPhrase(level))\n\t}\n\n\tfmap[\"bunit\"] = func(byteInt interface{}) interface{} {\n\t\tvar byteFloat float64\n\t\tvar unit string\n\t\tswitch bytes := byteInt.(type) {\n\t\tcase int:\n\t\t\tbyteFloat, unit = ConvertByteUnit(float64(bytes))\n\t\tcase int64:\n\t\t\tbyteFloat, unit = ConvertByteUnit(float64(bytes))\n\t\tcase uint64:\n\t\t\tbyteFloat, unit = ConvertByteUnit(float64(bytes))\n\t\tcase float64:\n\t\t\tbyteFloat, unit = ConvertByteUnit(bytes)\n\t\tdefault:\n\t\t\tpanic(\"bytes is not an int, int64 or uint64\")\n\t\t}\n\t\treturn fmt.Sprintf(\"%.1f\", byteFloat) + unit\n\t}\n\n\tfmap[\"abstime\"] = func(timeInt interface{}) interface{} {\n\t\ttime, ok := timeInt.(time.Time)\n\t\tif !ok {\n\t\t\tpanic(\"timeInt is not a time.Time\")\n\t\t}\n\t\treturn time.Format(\"2006-01-02 15:04:05\")\n\t}\n\n\tfmap[\"reltime\"] = func(timeInt interface{}) interface{} {\n\t\ttime, ok := timeInt.(time.Time)\n\t\tif !ok {\n\t\t\tpanic(\"timeInt is not a time.Time\")\n\t\t}\n\t\treturn RelativeTime(time)\n\t}\n\n\tfmap[\"scope\"] = func(name interface{}) interface{} {\n\t\treturn \"\"\n\t}\n\n\tfmap[\"dyntmpl\"] = func(nameInt, pageInt, headerInt interface{}) interface{} {\n\t\theader := headerInt.(*Header)\n\t\terr := header.Theme.RunTmpl(nameInt.(string), pageInt, header.Writer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn \"\"\n\t}\n\n\tfmap[\"ptmpl\"] = func(nameInt, pageInt, headerInt interface{}) interface{} {\n\t\theader := headerInt.(*Header)\n\t\terr := header.Theme.RunTmpl(nameInt.(string), pageInt, header.Writer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn \"\"\n\t}\n\n\tfmap[\"js\"] = func() interface{} {\n\t\treturn false\n\t}\n\n\tfmap[\"flush\"] = func() interface{} {\n\t\treturn nil\n\t}\n\n\tfmap[\"res\"] = func(nameInt interface{}) interface{} {\n\t\tn := nameInt.(string)\n\t\tif n[0] == '/' && n[1] == '/' {\n\t\t} else {\n\t\t\tif f, ok := StaticFiles.GetShort(n); ok {\n\t\t\t\tn = f.OName\n\t\t\t}\n\t\t}\n\t\treturn n\n\t}\n\n\tDefaultTemplateFuncMap = fmap\n}\n\nfunc loadTemplates(t *template.Template, themeName string) error {\n\tt.Funcs(DefaultTemplateFuncMap)\n\ttFiles, err := filepath.Glob(\"templates/*.html\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttFileMap := make(map[string]int)\n\tfor index, path := range tFiles {\n\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\tlog.Print(\"templateFile: \", path)\n\t\tif skipCTmpl(path) {\n\t\t\tlog.Print(\"skipping\")\n\t\t\tcontinue\n\t\t}\n\t\ttFileMap[path] = index\n\t}\n\n\toverrideFiles, err := filepath.Glob(\"templates/overrides/*.html\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, path := range overrideFiles {\n\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\tlog.Print(\"overrideFile: \", path)\n\t\tif skipCTmpl(path) {\n\t\t\tlog.Print(\"skipping\")\n\t\t\tcontinue\n\t\t}\n\t\tindex, ok := tFileMap[\"templates/\"+strings.TrimPrefix(path, \"templates/overrides/\")]\n\t\tif !ok {\n\t\t\tlog.Print(\"not ok: templates/\" + strings.TrimPrefix(path, \"templates/overrides/\"))\n\t\t\ttFiles = append(tFiles, path)\n\t\t\tcontinue\n\t\t}\n\t\ttFiles[index] = path\n\t}\n\n\tif themeName != \"\" {\n\t\toverrideFiles, err := filepath.Glob(\"./themes/\" + themeName + \"/overrides/*.html\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, path := range overrideFiles {\n\t\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\t\tlog.Print(\"overrideFile: \", path)\n\t\t\tif skipCTmpl(path) {\n\t\t\t\tlog.Print(\"skipping\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindex, ok := tFileMap[\"templates/\"+strings.TrimPrefix(path, \"themes/\"+themeName+\"/overrides/\")]\n\t\t\tif !ok {\n\t\t\t\tlog.Print(\"not ok: templates/\" + strings.TrimPrefix(path, \"themes/\"+themeName+\"/overrides/\"))\n\t\t\t\ttFiles = append(tFiles, path)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttFiles[index] = path\n\t\t}\n\t}\n\n\t// TODO: Minify these\n\t/*err = t.ParseFiles(tFiles...)\n\tif err != nil {\n\t\treturn err\n\t}*/\n\tfor _, fname := range tFiles {\n\t\tb, err := ioutil.ReadFile(fname)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ts := tmpl.Minify(string(b))\n\t\tname := filepath.Base(fname)\n\t\tvar tmpl *template.Template\n\t\tif name == t.Name() {\n\t\t\ttmpl = t\n\t\t} else {\n\t\t\ttmpl = t.New(name)\n\t\t}\n\t\t_, err = tmpl.Parse(s)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t_, err = t.ParseGlob(\"pages/*\")\n\treturn err\n}\n\nfunc InitTemplates() error {\n\tDebugLog(\"Initialising the template system\")\n\tinitDefaultTmplFuncMap()\n\n\t// The interpreted templates...\n\tDebugLog(\"Loading the template files...\")\n\treturn loadTemplates(DefaultTemplates, \"\")\n}\n"
  },
  {
    "path": "common/templates/context.go",
    "content": "package tmpl\n\nimport (\n\t\"reflect\"\n)\n\n// For use in generated code\ntype FragLite struct {\n\tBody string\n}\n\ntype Fragment struct {\n\tBody         string\n\tTemplateName string\n\tIndex        int\n\tSeen         bool\n}\n\ntype OutBufferFrame struct {\n\tBody         string\n\tType         string\n\tTemplateName string\n\tExtra        interface{}\n\tExtra2       interface{}\n}\n\ntype CContext struct {\n\tRootHolder       string\n\tVarHolder        string\n\tHoldReflect      reflect.Value\n\tRootTemplateName string\n\tTemplateName     string\n\tLoopDepth        int\n\tOutBuf           *[]OutBufferFrame\n}\n\nfunc (con *CContext) Push(nType string, body string) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, nType, con.TemplateName, nil, nil})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) PushText(body string, fragIndex int, fragOutIndex int) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, \"text\", con.TemplateName, fragIndex, fragOutIndex})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) PushPhrase(langIndex int) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{\"\", \"lang\", con.TemplateName, langIndex, nil})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) PushPhrasef(langIndex int, args string) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{args, \"langf\", con.TemplateName, langIndex, nil})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) StartIf(body string) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, \"startif\", con.TemplateName, false, nil})\n\treturn con.LastBufIndex()\n}\nfunc (con *CContext) StartIfPtr(body string) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, \"startif\", con.TemplateName, true, nil})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) EndIf(startIndex int, body string) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, \"endif\", con.TemplateName, startIndex, nil})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) StartLoop(body string) (index int) {\n\tcon.LoopDepth++\n\treturn con.Push(\"startloop\", body)\n}\n\nfunc (con *CContext) EndLoop(body string) (index int) {\n\treturn con.Push(\"endloop\", body)\n}\n\nfunc (con *CContext) StartTemplate(body string) (index int) {\n\treturn con.addFrame(body, \"starttemplate\", nil, nil)\n}\n\nfunc (con *CContext) EndTemplate(body string) (index int) {\n\treturn con.Push(\"endtemplate\", body)\n}\n\nfunc (con *CContext) AttachVars(vars string, index int) {\n\toutBuf := *con.OutBuf\n\tn := outBuf[index]\n\tif n.Type != \"starttemplate\" && n.Type != \"startloop\" && n.Type != \"startif\" {\n\t\tpanic(\"not a starttemplate, startloop or startif node\")\n\t}\n\tn.Body += vars\n\toutBuf[index] = n\n}\n\nfunc (con *CContext) addFrame(body, ftype string, extra1 interface{}, extra2 interface{}) (index int) {\n\t*con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, ftype, con.TemplateName, extra1, extra2})\n\treturn con.LastBufIndex()\n}\n\nfunc (con *CContext) LastBufIndex() int {\n\treturn len(*con.OutBuf) - 1\n}\n\nfunc (con *CContext) DiscardAndAfter(index int) {\n\toutBuf := *con.OutBuf\n\tif len(outBuf) <= index {\n\t\treturn\n\t}\n\tif index == 0 {\n\t\toutBuf = nil\n\t} else {\n\t\toutBuf = outBuf[:index]\n\t}\n\t*con.OutBuf = outBuf\n}\n"
  },
  {
    "path": "common/templates/minifiers.go",
    "content": "package tmpl\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n)\n\n// TODO: Write unit tests for this\nfunc Minify(data string) string {\n\tdata = strings.Replace(data, \"\\t\", \"\", -1)\n\tdata = strings.Replace(data, \"\\v\", \"\", -1)\n\tdata = strings.Replace(data, \"\\n\", \"\", -1)\n\tdata = strings.Replace(data, \"\\r\", \"\", -1)\n\tdata = strings.Replace(data, \"  \", \" \", -1)\n\treturn data\n}\n\n// TODO: Strip comments\n// TODO: Handle CSS nested in <style> tags?\n// TODO: Write unit tests for this\nfunc minifyHTML(data string) string {\n\treturn Minify(data)\n}\n\n// TODO: Have static files use this\n// TODO: Strip comments\n// TODO: Convert the rgb()s to hex codes?\n// TODO: Write unit tests for this\nfunc minifyCSS(data string) string {\n\treturn Minify(data)\n}\n\n// TODO: Convert this to three character hex strings whenever possible?\n// TODO: Write unit tests for this\n// nolint\nfunc rgbToHexstr(red, green, blue int) string {\n\treturn strconv.FormatInt(int64(red), 16) + strconv.FormatInt(int64(green), 16) + strconv.FormatInt(int64(blue), 16)\n}\n\n/*\n// TODO: Write unit tests for this\nfunc hexstrToRgb(hexstr string) (red, blue, green int, err error) {\n\t// Strip the # at the start\n\tif hexstr[0] == '#' {\n\t\thexstr = strings.TrimPrefix(hexstr,\"#\")\n\t}\n\tif len(hexstr) != 3 && len(hexstr) != 6 {\n\t\treturn 0, 0, 0, errors.New(\"Hex colour codes may only be three or six characters long\")\n\t}\n\n\tif len(hexstr) == 3 {\n\t\thexstr = hexstr[0] + hexstr[0] + hexstr[1] + hexstr[1] + hexstr[2] + hexstr[2]\n\t}\n}*/\n"
  },
  {
    "path": "common/templates/templates.go",
    "content": "package tmpl\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\t\"text/template/parse\"\n\t\"time\"\n\t\"unicode\"\n)\n\n// TODO: Turn this file into a library\nvar textOverlapList = make(map[string]int)\n\n// TODO: Stop hard-coding this here\nvar langPkg = \"github.com/Azareal/Gosora/common/phrases\"\n\ntype VarItem struct {\n\tName        string\n\tDestination string\n\tType        string\n}\n\ntype VarItemReflect struct {\n\tName        string\n\tDestination string\n\tValue       reflect.Value\n}\n\ntype CTemplateConfig struct {\n\tMinify         bool\n\tDebug          bool\n\tSuperDebug     bool\n\tSkipHandles    bool\n\tSkipTmplPtrMap bool\n\tSkipInitBlock  bool\n\tPackageName    string\n\tDockToID       map[string]int\n}\n\n// nolint\ntype CTemplateSet struct {\n\ttemplateList map[string]*parse.Tree\n\tfileDir      string\n\tlogDir       string\n\tfuncMap      map[string]interface{}\n\timportMap    map[string]string\n\t//templateFragmentCount map[string]int\n\tfragOnce             map[string]bool\n\tfragmentCursor       map[string]int\n\tFragOut              []OutFrag\n\tfragBuf              []Fragment\n\tvarList              map[string]VarItem\n\tlocalVars            map[string]map[string]VarItemReflect\n\thasDispInt           bool\n\tlocalDispStructIndex int\n\tlangIndexToName      []string\n\tguestOnly            bool\n\tmemberOnly           bool\n\tstats                map[string]int\n\t//tempVars map[string]string\n\tconfig        CTemplateConfig\n\tbaseImportMap map[string]string\n\tbuildTags     string\n\n\toverridenTrack map[string]map[string]bool\n\toverridenRoots map[string]map[string]bool\n\tthemeName      string\n\tperThemeTmpls  map[string]bool\n\n\tlogger  *log.Logger\n\tloggerf *os.File\n\tlang    string\n\n\tfsb strings.Builder\n}\n\nfunc NewCTemplateSet(in string, logDir ...string) *CTemplateSet {\n\tvar llogDir string\n\tif len(logDir) > 0 {\n\t\tllogDir = logDir[0]\n\t}\n\tf, err := os.OpenFile(llogDir+\"tmpls-\"+in+\"-\"+strconv.FormatInt(time.Now().Unix(), 10)+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn &CTemplateSet{\n\t\tconfig: CTemplateConfig{\n\t\t\tPackageName: \"main\",\n\t\t},\n\t\tlogDir:         llogDir,\n\t\tbaseImportMap:  map[string]string{},\n\t\toverridenRoots: map[string]map[string]bool{},\n\t\tfuncMap: map[string]interface{}{\n\t\t\t\"and\":        \"&&\",\n\t\t\t\"not\":        \"!\",\n\t\t\t\"or\":         \"||\",\n\t\t\t\"eq\":         \"==\",\n\t\t\t\"ge\":         \">=\",\n\t\t\t\"gt\":         \">\",\n\t\t\t\"le\":         \"<=\",\n\t\t\t\"lt\":         \"<\",\n\t\t\t\"ne\":         \"!=\",\n\t\t\t\"add\":        \"+\",\n\t\t\t\"subtract\":   \"-\",\n\t\t\t\"multiply\":   \"*\",\n\t\t\t\"divide\":     \"/\",\n\t\t\t\"dock\":       true,\n\t\t\t\"hasWidgets\": true,\n\t\t\t\"elapsed\":    true,\n\t\t\t\"lang\":       true,\n\t\t\t\"langf\":      true,\n\t\t\t\"level\":      true,\n\t\t\t\"bunit\":      true,\n\t\t\t\"abstime\":    true,\n\t\t\t\"reltime\":    true,\n\t\t\t\"scope\":      true,\n\t\t\t\"dyntmpl\":    true,\n\t\t\t\"ptmpl\":      true,\n\t\t\t\"js\":         true,\n\t\t\t\"index\":      true,\n\t\t\t\"flush\":      true,\n\t\t\t\"res\":        true,\n\t\t},\n\t\tlogger:  log.New(f, \"\", log.LstdFlags),\n\t\tloggerf: f,\n\t\tlang:    in,\n\t}\n}\n\nfunc (c *CTemplateSet) SetConfig(config CTemplateConfig) {\n\tif config.PackageName == \"\" {\n\t\tconfig.PackageName = \"main\"\n\t}\n\tc.config = config\n}\n\nfunc (c *CTemplateSet) GetConfig() CTemplateConfig {\n\treturn c.config\n}\n\nfunc (c *CTemplateSet) SetBaseImportMap(importMap map[string]string) {\n\tc.baseImportMap = importMap\n}\n\nfunc (c *CTemplateSet) SetBuildTags(tags string) {\n\tc.buildTags = tags\n}\n\nfunc (c *CTemplateSet) SetOverrideTrack(overriden map[string]map[string]bool) {\n\tc.overridenTrack = overriden\n}\n\nfunc (c *CTemplateSet) GetOverridenRoots() map[string]map[string]bool {\n\treturn c.overridenRoots\n}\n\nfunc (c *CTemplateSet) SetThemeName(name string) {\n\tc.themeName = name\n}\n\nfunc (c *CTemplateSet) SetPerThemeTmpls(perThemeTmpls map[string]bool) {\n\tc.perThemeTmpls = perThemeTmpls\n}\n\nfunc (c *CTemplateSet) ResetLogs(in string) {\n\tf, err := os.OpenFile(c.logDir+\"tmpls-\"+in+\"-\"+strconv.FormatInt(time.Now().Unix(), 10)+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tc.logger = log.New(f, \"\", log.LstdFlags)\n\tc.loggerf = f\n}\n\ntype SkipBlock struct {\n\tFrags           map[int]int\n\tLastCount       int\n\tClosestFragSkip int\n}\ntype Skipper struct {\n\tCount int\n\tIndex int\n}\n\ntype OutFrag struct {\n\tTmplName string\n\tIndex    int\n\tBody     string\n}\n\nfunc (c *CTemplateSet) buildImportList() (importList string) {\n\tif len(c.importMap) == 0 {\n\t\treturn \"\"\n\t}\n\tvar ilsb strings.Builder\n\tilsb.Grow(10 + (len(c.importMap) * 3))\n\tilsb.WriteString(\"import (\")\n\tfor _, item := range c.importMap {\n\t\tispl := strings.Split(item, \" \")\n\t\tif len(ispl) > 1 {\n\t\t\t//importList += ispl[0] + \" \\\"\" + ispl[1] + \"\\\"\\n\"\n\t\t\tilsb.WriteString(ispl[0])\n\t\t\tilsb.WriteString(\" \\\"\")\n\t\t\tilsb.WriteString(ispl[1])\n\t\t\tilsb.WriteString(\"\\\"\\n\")\n\t\t} else {\n\t\t\t//importList += \"\\\"\" + item + \"\\\"\\n\"\n\t\t\tilsb.WriteString(\"\\\"\")\n\t\t\tilsb.WriteString(item)\n\t\t\tilsb.WriteString(\"\\\"\\n\")\n\t\t}\n\t}\n\t//importList += \")\\n\"\n\tilsb.WriteString(\")\\n\")\n\treturn ilsb.String()\n}\n\nfunc (c *CTemplateSet) CompileByLoggedin(name, fileDir, expects string, expectsInt interface{}, varList map[string]VarItem, imports ...string) (stub, gout, mout string, e error) {\n\tc.importMap = map[string]string{}\n\tfor index, item := range c.baseImportMap {\n\t\tc.importMap[index] = item\n\t}\n\tfor _, importItem := range imports {\n\t\tc.importMap[importItem] = importItem\n\t}\n\tc.importMap[\"errors\"] = \"errors\"\n\timportList := c.buildImportList()\n\n\tfname := strings.TrimSuffix(name, filepath.Ext(name))\n\tif c.themeName != \"\" {\n\t\t_, ok := c.perThemeTmpls[fname]\n\t\tif !ok {\n\t\t\treturn \"\", \"\", \"\", nil\n\t\t}\n\t\tfname += \"_\" + c.themeName\n\t}\n\tc.importMap[\"github.com/Azareal/Gosora/common\"] = \"c github.com/Azareal/Gosora/common\"\n\n\tc.fsb.Reset()\n\tstub = `package ` + c.config.PackageName + \"\\n\" + importList + \"\\n\"\n\n\tif !c.config.SkipInitBlock {\n\t\t//stub += \"// nolint\\nfunc init() {\\n\"\n\t\tc.fsb.WriteString(\"// nolint\\nfunc init() {\\n\")\n\t\tif !c.config.SkipHandles && c.themeName == \"\" {\n\t\t\t//stub += \"\\tc.Tmpl_\" + fname + \"_handle = Tmpl_\" + fname + \"\\n\"\n\t\t\tc.fsb.WriteString(\"\\tc.Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"_handle = Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\t//stub += \"\\tc.Ctemplates = append(c.Ctemplates,\\\"\" + fname + \"\\\")\\n\"\n\t\t\tc.fsb.WriteString(\"\\n\\tc.Ctemplates = append(c.Ctemplates,\\\"\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\")\\n\")\n\t\t}\n\t\tif !c.config.SkipTmplPtrMap {\n\t\t\t//stub += \"tmpl := Tmpl_\" + fname + \"\\n\"\n\t\t\tc.fsb.WriteString(\"tmpl := Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\t//stub += \"\\tc.TmplPtrMap[\\\"\" + fname + \"\\\"] = &tmpl\\n\"\n\t\t\tc.fsb.WriteString(\"\\n\\tc.TmplPtrMap[\\\"\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"] = &tmpl\\n\")\n\t\t\t//stub += \"\\tc.TmplPtrMap[\\\"o_\" + fname + \"\\\"] = tmpl\\n\"\n\t\t\tc.fsb.WriteString(\"\\tc.TmplPtrMap[\\\"o_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"] = tmpl\\n\")\n\t\t}\n\t\t//stub += \"}\\n\\n\"\n\t\tc.fsb.WriteString(\"}\\n\\n\")\n\t}\n\tstub += c.fsb.String()\n\n\t// TODO: Try to remove this redundant interface cast\n\tstub += `\n// nolint\nfunc Tmpl_` + fname + `(tmpl_i interface{}, w io.Writer) error {\n\ttmpl_vars, ok := tmpl_i.(` + expects + `)\n\tif !ok {\n\t\treturn errors.New(\"invalid page struct value\")\n\t}\n\tif tmpl_vars.CurrentUser.Loggedin {\n\t\treturn Tmpl_` + fname + `_member(tmpl_i, w)\n\t}\n\treturn Tmpl_` + fname + `_guest(tmpl_i, w)\n}`\n\n\tc.fileDir = fileDir\n\tcontent, e := c.loadTemplate(c.fileDir, name)\n\tif e != nil {\n\t\tc.detail(\"bailing out:\", e)\n\t\treturn \"\", \"\", \"\", e\n\t}\n\n\tc.guestOnly = true\n\tgout, e = c.compile(name, content, expects, expectsInt, varList, imports...)\n\tif e != nil {\n\t\treturn \"\", \"\", \"\", e\n\t}\n\tc.guestOnly = false\n\n\tc.memberOnly = true\n\tmout, e = c.compile(name, content, expects, expectsInt, varList, imports...)\n\tc.memberOnly = false\n\n\treturn stub, gout, mout, e\n}\n\nfunc (c *CTemplateSet) Compile(name, fileDir, expects string, expectsInt interface{}, varList map[string]VarItem, imports ...string) (out string, e error) {\n\tif c.config.Debug {\n\t\tc.logger.Println(\"Compiling template '\" + name + \"'\")\n\t}\n\tc.fileDir = fileDir\n\tcontent, e := c.loadTemplate(c.fileDir, name)\n\tif e != nil {\n\t\tc.detail(\"bailing out:\", e)\n\t\treturn \"\", e\n\t}\n\n\treturn c.compile(name, content, expects, expectsInt, varList, imports...)\n}\n\nfunc (c *CTemplateSet) compile(name, content, expects string, expectsInt interface{}, varList map[string]VarItem, imports ...string) (out string, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t\tif err := c.loggerf.Sync(); err != nil {\n\t\t\t\tfmt.Println(err)\n\t\t\t}\n\t\t\tlog.Fatal(\"\")\n\t\t\treturn\n\t\t}\n\t}()\n\t//c.dumpCall(\"compile\", name, content, expects, expectsInt, varList, imports)\n\t//c.detailf(\"c: %+v\\n\", c)\n\tc.importMap = map[string]string{}\n\tfor index, item := range c.baseImportMap {\n\t\tc.importMap[index] = item\n\t}\n\tc.importMap[\"errors\"] = \"errors\"\n\tfor _, importItem := range imports {\n\t\tc.importMap[importItem] = importItem\n\t}\n\n\tc.varList = varList\n\tc.hasDispInt = false\n\tc.localDispStructIndex = 0\n\tc.stats = make(map[string]int)\n\n\t//tree := parse.New(name, c.funcMap)\n\t//treeSet := make(map[string]*parse.Tree)\n\ttreeSet, err := parse.Parse(name, content, \"{{\", \"}}\", c.funcMap)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tc.detail(name)\n\tc.detailf(\"treeSet: %+v\\n\", treeSet)\n\n\tfname := strings.TrimSuffix(name, filepath.Ext(name))\n\tif c.themeName != \"\" {\n\t\t_, ok := c.perThemeTmpls[fname]\n\t\tif !ok {\n\t\t\tc.detail(\"fname not in c.perThemeTmpls\")\n\t\t\tc.detail(\"c.perThemeTmpls\", c.perThemeTmpls)\n\t\t\treturn \"\", nil\n\t\t}\n\t\tfname += \"_\" + c.themeName\n\t}\n\tif c.guestOnly {\n\t\tfname += \"_guest\"\n\t} else if c.memberOnly {\n\t\tfname += \"_member\"\n\t}\n\n\tc.detail(\"root overridenTrack loop\")\n\tc.detail(\"fname:\", fname)\n\tfor themeName, track := range c.overridenTrack {\n\t\tc.detail(\"themeName:\", themeName)\n\t\tc.detailf(\"track: %+v\\n\", track)\n\t\tcroot, ok := c.overridenRoots[themeName]\n\t\tif !ok {\n\t\t\tcroot = make(map[string]bool)\n\t\t\tc.overridenRoots[themeName] = croot\n\t\t}\n\t\tc.detailf(\"croot: %+v\\n\", croot)\n\t\tfor tmplName, _ := range track {\n\t\t\tcname := tmplName\n\t\t\tif c.guestOnly {\n\t\t\t\tcname += \"_guest\"\n\t\t\t} else if c.memberOnly {\n\t\t\t\tcname += \"_member\"\n\t\t\t}\n\t\t\tc.detail(\"cname:\", cname)\n\t\t\tif fname == cname {\n\t\t\t\tc.detail(\"match\")\n\t\t\t\tcroot[strings.TrimSuffix(strings.TrimSuffix(fname, \"_guest\"), \"_member\")] = true\n\t\t\t} else {\n\t\t\t\tc.detail(\"no match\")\n\t\t\t}\n\t\t}\n\t}\n\tc.detailf(\"c.overridenRoots: %+v\\n\", c.overridenRoots)\n\n\tvar outBuf []OutBufferFrame\n\trootHold := \"tmpl_\" + fname + \"_vars\"\n\t//rootHold := \"tmpl_vars\"\n\tcon := CContext{\n\t\tRootHolder:       rootHold,\n\t\tVarHolder:        rootHold,\n\t\tHoldReflect:      reflect.ValueOf(expectsInt),\n\t\tRootTemplateName: fname,\n\t\tTemplateName:     fname,\n\t\tOutBuf:           &outBuf,\n\t}\n\n\tc.templateList = map[string]*parse.Tree{}\n\tfor nname, tree := range treeSet {\n\t\tif name == nname {\n\t\t\tc.templateList[fname] = tree\n\t\t} else {\n\t\t\tif strings.HasPrefix(nname, \".html\") {\n\t\t\t\tnname = strings.TrimSuffix(nname, \".html\")\n\t\t\t}\n\t\t\tc.templateList[nname] = tree\n\t\t}\n\t}\n\tc.detailf(\"c.templateList: %+v\\n\", c.templateList)\n\n\tc.localVars = make(map[string]map[string]VarItemReflect)\n\tc.localVars[fname] = make(map[string]VarItemReflect)\n\tc.localVars[fname][\".\"] = VarItemReflect{\".\", con.VarHolder, con.HoldReflect}\n\tif c.fragOnce == nil {\n\t\tc.fragOnce = make(map[string]bool)\n\t}\n\tc.fragmentCursor = map[string]int{fname: 0}\n\tc.fragBuf = nil\n\tc.langIndexToName = nil\n\n\t// TODO: Is this the first template loaded in? We really should have some sort of constructor for CTemplateSet\n\t//if c.templateFragmentCount == nil {\n\t//\tc.templateFragmentCount = make(map[string]int)\n\t//}\n\t//c.detailf(\"c: %+v\\n\", c)\n\n\tc.detailf(\"name: %+v\\n\", name)\n\tc.detailf(\"fname: %+v\\n\", fname)\n\tstartIndex := con.StartTemplate(\"\")\n\tttree := c.templateList[fname]\n\tif ttree == nil {\n\t\tpanic(\"ttree is nil\")\n\t}\n\tc.rootIterate(ttree, con)\n\tcon.EndTemplate(\"\")\n\tc.afterTemplate(con, startIndex)\n\t//c.templateFragmentCount[fname] = c.fragmentCursor[fname] + 1\n\n\t_, ok := c.fragOnce[fname]\n\tif !ok {\n\t\tc.fragOnce[fname] = true\n\t}\n\tif len(c.langIndexToName) > 0 {\n\t\tc.importMap[langPkg] = langPkg\n\t}\n\t// TODO: Simplify this logic by doing some reordering?\n\tif c.lang == \"normal\" {\n\t\tc.importMap[\"net/http\"] = \"net/http\"\n\t}\n\timportList := c.buildImportList()\n\n\tc.fsb.Reset()\n\t//var fout string\n\tif c.buildTags != \"\" {\n\t\t//fout += \"// +build \" + c.buildTags + \"\\n\\n\"\n\t\tc.fsb.WriteString(\"// +build \")\n\t\tc.fsb.WriteString(c.buildTags)\n\t\tc.fsb.WriteString(\"\\n\\n\")\n\t}\n\t//fout += \"// Code generated by Gosora. More below:\\n/* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */\\n\"\n\tc.fsb.WriteString(\"// Code generated by Gosora. More below:\\n/* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */\\n\")\n\t//fout += \"package \" + c.config.PackageName + \"\\n\" + importList + \"\\n\"\n\tc.fsb.WriteString(\"package \")\n\tc.fsb.WriteString(c.config.PackageName)\n\tc.fsb.WriteString(\"\\n\")\n\tc.fsb.WriteString(importList)\n\tc.fsb.WriteString(\"\\n\")\n\n\tif c.lang == \"js\" {\n\t\t//var l string\n\t\tif len(c.langIndexToName) > 0 {\n\t\t\t/*var lsb strings.Builder\n\t\t\tlsb.Grow(len(c.langIndexToName) * (1 + 2))\n\t\t\tfor i, name := range c.langIndexToName {\n\t\t\t\t//l += `\"` + name + `\"` + \",\\n\"\n\t\t\t\tif i == 0 {\n\t\t\t\t\t//l += `\"` + name + `\"`\n\t\t\t\t\tlsb.WriteRune('\"')\n\t\t\t\t} else {\n\t\t\t\t\t//l += `,\"` + name + `\"`\n\t\t\t\t\tlsb.WriteString(`,\"`)\n\t\t\t\t}\n\t\t\t\tlsb.WriteString(name)\n\t\t\t\tlsb.WriteRune('\"')\n\t\t\t}*/\n\t\t\t//fout += \"if(tmplInits===undefined) var tmplInits={}\\n\"\n\t\t\tc.fsb.WriteString(\"if(tmplInits===undefined) var tmplInits={}\\n\")\n\t\t\t//fout += \"tmplInits[\\\"tmpl_\" + fname + \"\\\"]=[\" + lsb.String() + \"]\"\n\t\t\tc.fsb.WriteString(\"tmplInits[\\\"tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"]=[\")\n\n\t\t\tc.fsb.Grow(len(c.langIndexToName) * (1 + 2))\n\t\t\tfor i, name := range c.langIndexToName {\n\t\t\t\t//l += `\"` + name + `\"` + \",\\n\"\n\t\t\t\tif i == 0 {\n\t\t\t\t\t//l += `\"` + name + `\"`\n\t\t\t\t\tc.fsb.WriteRune('\"')\n\t\t\t\t} else {\n\t\t\t\t\t//l += `,\"` + name + `\"`\n\t\t\t\t\tc.fsb.WriteString(`,\"`)\n\t\t\t\t}\n\t\t\t\tc.fsb.WriteString(name)\n\t\t\t\tc.fsb.WriteRune('\"')\n\t\t\t}\n\n\t\t\tc.fsb.WriteString(\"]\")\n\t\t} else {\n\t\t\t//fout += \"if(tmplInits===undefined) var tmplInits={}\\n\"\n\t\t\tc.fsb.WriteString(\"if(tmplInits===undefined) var tmplInits={}\\n\")\n\t\t\t//fout += \"tmplInits[\\\"tmpl_\" + fname + \"\\\"]=[]\"\n\t\t\tc.fsb.WriteString(\"tmplInits[\\\"tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"]=[]\")\n\t\t}\n\t\t/*if len(l) > 0 {\n\t\t\tl = \"\\n\" + l\n\t\t}*/\n\t} else if !c.config.SkipInitBlock {\n\t\tif len(c.langIndexToName) > 0 {\n\t\t\t//fout += \"var \" + fname + \"_tmpl_phrase_id int\\n\\n\"\n\t\t\tc.fsb.WriteString(\"var \")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"_tmpl_phrase_id int\\n\\n\")\n\t\t\tc.fsb.WriteString(\"var \")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tif len(c.langIndexToName) > 1 {\n\t\t\t\t//fout += \"var \" + fname + \"_phrase_arr [\" + strconv.Itoa(len(c.langIndexToName)) + \"][]byte\\n\\n\"\n\t\t\t\tc.fsb.WriteString(\"_phrase_arr [\")\n\t\t\t\tc.fsb.WriteString(strconv.Itoa(len(c.langIndexToName)))\n\t\t\t\tc.fsb.WriteString(\"][]byte\\n\\n\")\n\t\t\t} else {\n\t\t\t\t//fout += \"var \" + fname + \"_phrase []byte\\n\\n\"\n\t\t\t\tc.fsb.WriteString(\"_phrase []byte\\n\\n\")\n\t\t\t}\n\t\t}\n\t\t//fout += \"// nolint\\nfunc init() {\\n\"\n\t\tc.fsb.WriteString(\"// nolint\\nfunc init() {\\n\")\n\n\t\tif !c.config.SkipHandles && c.themeName == \"\" {\n\t\t\t//fout += \"\\tc.Tmpl_\" + fname + \"_handle = Tmpl_\" + fname\n\t\t\tc.fsb.WriteString(\"\\tc.Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"_handle = Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\t//fout += \"\\n\\tc.Ctemplates = append(c.Ctemplates,\\\"\" + fname + \"\\\")\\n\"\n\t\t\tc.fsb.WriteString(\"\\n\\tc.Ctemplates = append(c.Ctemplates,\\\"\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\")\\n\")\n\t\t}\n\n\t\tif !c.config.SkipTmplPtrMap {\n\t\t\t//fout += \"tmpl := Tmpl_\" + fname + \"\\n\"\n\t\t\tc.fsb.WriteString(\"tmpl := Tmpl_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\t//fout += \"\\tc.TmplPtrMap[\\\"\" + fname + \"\\\"] = &tmpl\\n\"\n\t\t\tc.fsb.WriteString(\"\\n\\tc.TmplPtrMap[\\\"\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"] = &tmpl\\n\")\n\t\t\t//fout += \"\\tc.TmplPtrMap[\\\"o_\" + fname + \"\\\"] = tmpl\\n\"\n\t\t\tc.fsb.WriteString(\"\\tc.TmplPtrMap[\\\"o_\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"\\\"] = tmpl\\n\")\n\t\t}\n\t\tif len(c.langIndexToName) > 0 {\n\t\t\t//fout += \"\\t\" + fname + \"_tmpl_phrase_id = phrases.RegisterTmplPhraseNames([]string{\\n\"\n\t\t\tc.fsb.WriteString(\"\\t\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tc.fsb.WriteString(\"_tmpl_phrase_id = phrases.RegisterTmplPhraseNames([]string{\\n\")\n\t\t\tfor _, name := range c.langIndexToName {\n\t\t\t\t//fout += \"\\t\\t\" + `\"` + name + `\"` + \",\\n\"\n\t\t\t\tc.fsb.WriteString(\"\\t\\t\\\"\")\n\t\t\t\tc.fsb.WriteString(name)\n\t\t\t\tc.fsb.WriteString(\"\\\",\\n\")\n\t\t\t}\n\t\t\t//fout += \"\\t})\\n\"\n\t\t\tc.fsb.WriteString(\"\\t})\\n\")\n\n\t\t\tif len(c.langIndexToName) > 1 {\n\t\t\t\t/*fout += `\tphrases.AddTmplIndexCallback(func(phraseSet [][]byte) {\n\t\t\t\t\t\tcopy(` + fname + `_phrase_arr[:], phraseSet)\n\t\t\t\t\t})\n\t\t\t\t`*/\n\t\t\t\tc.fsb.WriteString(`\tphrases.AddTmplIndexCallback(func(phraseSet [][]byte) {\n\t\tcopy(`)\n\t\t\t\tc.fsb.WriteString(fname)\n\t\t\t\tc.fsb.WriteString(`_phrase_arr[:], phraseSet)\n\t})\n`)\n\t\t\t} else {\n\t\t\t\t/*fout += `\tphrases.AddTmplIndexCallback(func(phraseSet [][]byte) {\n\t\t\t\t\t\t` + fname + `_phrase = phraseSet[0]\n\t\t\t\t})\n\t\t\t\t`*/\n\t\t\t\tc.fsb.WriteString(`\tphrases.AddTmplIndexCallback(func(phraseSet [][]byte) {\n`)\n\t\t\t\tc.fsb.WriteString(fname)\n\t\t\t\tc.fsb.WriteString(`_phrase = phraseSet[0]\n\t})\n`)\n\t\t\t}\n\t\t}\n\t\t//fout += \"}\\n\\n\"\n\t\tc.fsb.WriteString(\"}\\n\\n\")\n\t}\n\n\tc.fsb.WriteString(\"// nolint\\nfunc Tmpl_\")\n\tc.fsb.WriteString(fname)\n\tif c.lang == \"normal\" {\n\t\t/*fout += \"// nolint\\nfunc Tmpl_\" + fname + \"(tmpl_i interface{}, w io.Writer) error {\\n\"\n\t\t\t\tfout += `tmpl_` + fname + `_vars, ok := tmpl_i.(` + expects + `)\n\t\tif !ok {\n\t\t\treturn errors.New(\"invalid page struct value\")\n\t\t}\n\t\t`*/\n\t\tc.fsb.WriteString(\"(tmpl_i interface{}, w io.Writer) error {\\n\")\n\n\t\tc.fsb.WriteString(`tmpl_`)\n\t\tc.fsb.WriteString(fname)\n\t\tc.fsb.WriteString(`_vars, ok := tmpl_i.(`)\n\t\tc.fsb.WriteString(expects)\n\t\tc.fsb.WriteString(`)\n\tif !ok {\n\t\treturn errors.New(\"invalid page struct value\")\n\t}\n\tvar iw http.ResponseWriter\n\tif gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\tiw = gzw.ResponseWriter\n\t\tw = gzw.Writer\n\t}\n\t_ = iw\n\tvar tmp []byte\n\t_ = tmp\n`)\n\t} else {\n\t\t//fout += \"// nolint\\nfunc Tmpl_\" + fname + \"(tmpl_\" + fname + \"_vars interface{}, w io.Writer) error {\\n\"\n\t\tc.fsb.WriteString(\"(tmpl_\")\n\t\tc.fsb.WriteString(fname)\n\t\tc.fsb.WriteString(\"_vars interface{}, w io.Writer) error {\\n\")\n\t\t//fout += \"// nolint\\nfunc Tmpl_\" + fname + \"(tmpl_vars interface{}, w io.Writer) error {\\n\"\n\t}\n\n\t//var fsb strings.Builder\n\tif len(c.langIndexToName) > 0 {\n\t\t//fout += \"//var plist = phrases.GetTmplPhrasesBytes(\" + fname + \"_tmpl_phrase_id)\\n\"\n\t\tc.fsb.WriteString(\"//var plist = phrases.GetTmplPhrasesBytes(\")\n\t\tc.fsb.WriteString(fname)\n\t\tc.fsb.WriteString(\"_tmpl_phrase_id)\\n\")\n\n\t\t//fout += \"if len(plist) > 0 {\\n_ = plist[len(plist)-1]\\n}\\n\"\n\t\t//fout += \"var plist = \" + fname + \"_phrase_arr\\n\"\n\t}\n\n\t//var varString string\n\t//var vssb strings.Builder\n\tc.fsb.Grow(10 + 3)\n\tfor _, varItem := range c.varList {\n\t\t//varString += \"var \" + varItem.Name + \" \" + varItem.Type + \" = \" + varItem.Destination + \"\\n\"\n\t\tc.fsb.WriteString(\"var \")\n\t\tc.fsb.WriteString(varItem.Name)\n\t\tc.fsb.WriteRune(' ')\n\t\tc.fsb.WriteString(varItem.Type)\n\t\tc.fsb.WriteString(\" = \")\n\t\tc.fsb.WriteString(varItem.Destination)\n\t\tc.fsb.WriteString(\"\\n\")\n\t}\n\n\t//c.fsb.WriteString(varString)\n\t//fout += varString\n\tskipped := make(map[string]*SkipBlock) // map[templateName]*SkipBlock{map[atIndexAndAfter]skipThisMuch,lastCount}\n\n\twriteTextFrame := func(tmplName string, index int) {\n\t\tout := \"w.Write(\" + tmplName + \"_frags[\" + strconv.Itoa(index) + \"]\" + \")\\n\"\n\t\tc.detail(\"writing \", out)\n\t\t//fout += out\n\t\tc.fsb.WriteString(out)\n\t}\n\n\tfor fid := 0; len(outBuf) > fid; fid++ {\n\t\tfr := outBuf[fid]\n\t\tc.detail(fr.Type + \" frame\")\n\t\tswitch {\n\t\tcase fr.Type == \"text\":\n\t\t\tc.detail(fr)\n\t\t\toid := fid\n\t\t\tc.detail(\"oid:\", oid)\n\t\t\tskipBlock, ok := skipped[fr.TemplateName]\n\t\t\tif !ok {\n\t\t\t\tskipBlock = &SkipBlock{make(map[int]int), 0, 0}\n\t\t\t\tskipped[fr.TemplateName] = skipBlock\n\t\t\t}\n\t\t\tskip := skipBlock.LastCount\n\t\t\tc.detailf(\"skipblock %+v\\n\", skipBlock)\n\t\t\t//var count int\n\t\t\tfor len(outBuf) > fid+1 && outBuf[fid+1].Type == \"text\" && outBuf[fid+1].TemplateName == fr.TemplateName {\n\t\t\t\tc.detail(\"pre fid:\", fid)\n\t\t\t\t//count++\n\t\t\t\tnext := outBuf[fid+1]\n\t\t\t\tc.detail(\"next frame:\", next)\n\t\t\t\tc.detail(\"frame frag:\", c.fragBuf[fr.Extra2.(int)])\n\t\t\t\tc.detail(\"next frag:\", c.fragBuf[next.Extra2.(int)])\n\t\t\t\tc.fragBuf[fr.Extra2.(int)].Body += c.fragBuf[next.Extra2.(int)].Body\n\t\t\t\tc.fragBuf[next.Extra2.(int)].Seen = true\n\t\t\t\tfid++\n\t\t\t\tskipBlock.LastCount++\n\t\t\t\tskipBlock.Frags[fr.Extra.(int)] = skipBlock.LastCount\n\t\t\t\tc.detail(\"post fid:\", fid)\n\t\t\t}\n\t\t\twriteTextFrame(fr.TemplateName, fr.Extra.(int)-skip)\n\t\tcase fr.Type == \"varsub\" || fr.Type == \"cvarsub\":\n\t\t\t//fout += \"w.Write(\" + fr.Body + \")\\n\"\n\t\t\tc.fsb.WriteString(\"w.Write(\")\n\t\t\tc.fsb.WriteString(fr.Body)\n\t\t\tc.fsb.WriteString(\")\\n\")\n\t\tcase fr.Type == \"lang\":\n\t\t\t//fout += \"w.Write(plist[\" + strconv.Itoa(fr.Extra.(int)) + \"])\\n\"\n\t\t\tc.fsb.WriteString(\"w.Write(\")\n\t\t\tc.fsb.WriteString(fname)\n\t\t\tif len(c.langIndexToName) == 1 {\n\t\t\t\t//fout += \"w.Write(\" + fname + \"_phrase)\\n\"\n\t\t\t\tc.fsb.WriteString(\"_phrase)\\n\")\n\t\t\t} else {\n\t\t\t\t//fout += \"w.Write(\" + fname + \"_phrase_arr[\" + strconv.Itoa(fr.Extra.(int)) + \"])\\n\"\n\t\t\t\tc.fsb.WriteString(\"_phrase_arr[\")\n\t\t\t\tc.fsb.WriteString(strconv.Itoa(fr.Extra.(int)))\n\t\t\t\tc.fsb.WriteString(\"])\\n\")\n\t\t\t}\n\t\t//case fr.Type == \"identifier\":\n\t\tdefault:\n\t\t\t//fout += fr.Body\n\t\t\tc.fsb.WriteString(fr.Body)\n\t\t}\n\t}\n\t//fout += \"return nil\\n}\\n\"\n\tc.fsb.WriteString(\"return nil\\n}\\n\")\n\t//fout += c.fsb.String()\n\n\twriteFrag := func(tmplName string, index int, body string) {\n\t\t//c.detail(\"writing \", fragmentPrefix)\n\t\tc.FragOut = append(c.FragOut, OutFrag{tmplName, index, body})\n\t}\n\n\tfor _, frag := range c.fragBuf {\n\t\tc.detail(\"frag:\", frag)\n\t\tif frag.Seen {\n\t\t\tc.detail(\"invisible\")\n\t\t\tcontinue\n\t\t}\n\t\t// TODO: What if the same template is invoked in multiple spots in a template?\n\t\tskipBlock := skipped[frag.TemplateName]\n\t\tskip := skipBlock.Frags[skipBlock.ClosestFragSkip]\n\t\t_, ok := skipBlock.Frags[frag.Index]\n\t\tif ok {\n\t\t\tskipBlock.ClosestFragSkip = frag.Index\n\t\t}\n\t\tc.detailf(\"skipblock %+v\\n\", skipBlock)\n\t\tc.detail(\"skipping \", skip)\n\t\tindex := frag.Index - skip\n\t\tif index < 0 {\n\t\t\tindex = 0\n\t\t}\n\t\twriteFrag(frag.TemplateName, index, frag.Body)\n\t}\n\n\tfout := strings.Replace(c.fsb.String(), `))\nw.Write([]byte(`, \" + \", -1)\n\tfout = strings.Replace(fout, \"` + `\", \"\", -1)\n\n\tif c.config.Debug {\n\t\tfor index, count := range c.stats {\n\t\t\tc.logger.Println(index+\": \", strconv.Itoa(count))\n\t\t}\n\t\tc.logger.Println(\" \")\n\t}\n\tc.detail(\"Output!\")\n\tc.detail(fout)\n\treturn fout, nil\n}\n\nfunc (c *CTemplateSet) rootIterate(tree *parse.Tree, con CContext) {\n\tc.dumpCall(\"rootIterate\", tree, con)\n\tif tree.Root == nil {\n\t\tc.detailf(\"tree: %+v\\n\", tree)\n\t\tpanic(\"tree root node is empty\")\n\t}\n\tc.detail(tree.Root)\n\tfor _, node := range tree.Root.Nodes {\n\t\tc.detail(\"Node:\", node.String())\n\t\tc.compileSwitch(con, node)\n\t}\n\tc.retCall(\"rootIterate\")\n}\n\nfunc inSlice(haystack []string, expr string) bool {\n\tfor _, needle := range haystack {\n\t\tif needle == expr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (c *CTemplateSet) compileSwitch(con CContext, node parse.Node) {\n\tc.dumpCall(\"compileSwitch\", con, node)\n\tdefer c.retCall(\"compileSwitch\")\n\tswitch node := node.(type) {\n\tcase *parse.ActionNode:\n\t\tc.detail(\"Action Node\")\n\t\tif node.Pipe == nil {\n\t\t\tbreak\n\t\t}\n\t\tfor _, cmd := range node.Pipe.Cmds {\n\t\t\tc.compileSubSwitch(con, cmd)\n\t\t}\n\tcase *parse.IfNode:\n\t\tc.detail(\"If Node:\")\n\t\tc.detail(\"node.Pipe\", node.Pipe)\n\t\tvar expr string\n\t\tfor _, cmd := range node.Pipe.Cmds {\n\t\t\tc.detail(\"If Node Bit:\", cmd)\n\t\t\tc.detail(\"Bit Type:\", reflect.ValueOf(cmd).Type().Name())\n\t\t\texprStep := c.compileExprSwitch(con, cmd)\n\t\t\texpr += exprStep\n\t\t\tc.detail(\"Expression Step:\", exprStep)\n\t\t}\n\n\t\tc.detail(\"Expression:\", expr)\n\t\t// Simple member / guest optimisation for now\n\t\t// TODO: Expand upon this\n\t\tuserExprs, negUserExprs := buildUserExprs(con.RootHolder)\n\t\tif c.guestOnly {\n\t\t\tc.detail(\"optimising away member branch\")\n\t\t\tif inSlice(userExprs, expr) {\n\t\t\t\tc.detail(\"positive conditional:\", expr)\n\t\t\t\tif node.ElseList != nil {\n\t\t\t\t\tc.compileSwitch(con, node.ElseList)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t} else if inSlice(negUserExprs, expr) {\n\t\t\t\tc.detail(\"negative conditional:\", expr)\n\t\t\t\tc.compileSwitch(con, node.List)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else if c.memberOnly {\n\t\t\tc.detail(\"optimising away guest branch\")\n\t\t\tif (con.RootHolder + \".CurrentUser.Loggedin\") == expr {\n\t\t\t\tc.detail(\"positive conditional:\", expr)\n\t\t\t\tc.compileSwitch(con, node.List)\n\t\t\t\treturn\n\t\t\t} else if (\"!\" + con.RootHolder + \".CurrentUser.Loggedin\") == expr {\n\t\t\t\tc.detail(\"negative conditional:\", expr)\n\t\t\t\tif node.ElseList != nil {\n\t\t\t\t\tc.compileSwitch(con, node.ElseList)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// simple constant folding\n\t\tif expr == \"true\" {\n\t\t\tc.compileSwitch(con, node.List)\n\t\t\treturn\n\t\t} else if expr == \"false\" {\n\t\t\tc.compileSwitch(con, node.ElseList)\n\t\t\treturn\n\t\t}\n\n\t\tvar startIf int\n\t\tvar nilIf = strings.HasPrefix(expr, con.RootHolder) && strings.HasSuffix(expr, \"!=nil\")\n\t\tif nilIf {\n\t\t\tstartIf = con.StartIfPtr(\"if \" + expr + \" {\\n\")\n\t\t} else {\n\t\t\tstartIf = con.StartIf(\"if \" + expr + \" {\\n\")\n\t\t}\n\t\tc.compileSwitch(con, node.List)\n\t\tif node.ElseList == nil {\n\t\t\tc.detail(\"Selected Branch 1\")\n\t\t\tcon.EndIf(startIf, \"}\\n\")\n\t\t\tif nilIf {\n\t\t\t\tc.afterTemplate(con, startIf)\n\t\t\t}\n\t\t} else {\n\t\t\tc.detail(\"Selected Branch 2\")\n\t\t\tcon.EndIf(startIf, \"}\")\n\t\t\tif nilIf {\n\t\t\t\tc.afterTemplate(con, startIf)\n\t\t\t}\n\t\t\tcon.Push(\"startelse\", \" else {\\n\")\n\t\t\tc.compileSwitch(con, node.ElseList)\n\t\t\tcon.Push(\"endelse\", \"}\\n\")\n\t\t}\n\tcase *parse.ListNode:\n\t\tc.detailf(\"List Node: %+v\\n\", node)\n\t\tfor _, subnode := range node.Nodes {\n\t\t\tc.compileSwitch(con, subnode)\n\t\t}\n\tcase *parse.RangeNode:\n\t\tc.compileRangeNode(con, node)\n\tcase *parse.TemplateNode:\n\t\tc.compileSubTemplate(con, node)\n\tcase *parse.TextNode:\n\t\tc.addText(con, node.Text)\n\tdefault:\n\t\tc.unknownNode(node)\n\t}\n}\n\nfunc (c *CTemplateSet) addText(con CContext, text []byte) {\n\tc.dumpCall(\"addText\", con, text)\n\ttmpText := bytes.TrimSpace(text)\n\tif len(tmpText) == 0 {\n\t\treturn\n\t}\n\tnodeText := string(text)\n\tc.detail(\"con.TemplateName:\", con.TemplateName)\n\tfragIndex := c.fragmentCursor[con.TemplateName]\n\t_, ok := c.fragOnce[con.TemplateName]\n\tc.fragBuf = append(c.fragBuf, Fragment{nodeText, con.TemplateName, fragIndex, ok})\n\tcon.PushText(strconv.Itoa(fragIndex), fragIndex, len(c.fragBuf)-1)\n\tc.fragmentCursor[con.TemplateName] = fragIndex + 1\n}\n\nfunc (c *CTemplateSet) compileRangeNode(con CContext, node *parse.RangeNode) {\n\tc.dumpCall(\"compileRangeNode\", con, node)\n\tdefer c.retCall(\"compileRangeNode\")\n\tc.detail(\"node.Pipe:\", node.Pipe)\n\tvar expr string\n\tvar outVal reflect.Value\n\tfor _, cmd := range node.Pipe.Cmds {\n\t\tc.detail(\"Range Bit:\", cmd)\n\t\t// ! This bit is slightly suspect, hm.\n\t\texpr, outVal = c.compileReflectSwitch(con, cmd)\n\t}\n\tc.detail(\"Expr:\", expr)\n\tc.detail(\"Range Kind Switch!\")\n\n\tstartIf := func(item reflect.Value, useCopy bool) {\n\t\tsIndex := con.StartIf(\"if len(\" + expr + \")!=0 {\\n\")\n\t\tstartIndex := con.StartLoop(\"for _, item := range \" + expr + \" {\\n\")\n\t\tccon := con\n\t\tvar depth string\n\t\tif ccon.VarHolder == \"item\" {\n\t\t\tdepth = strings.TrimPrefix(ccon.VarHolder, \"item\")\n\t\t\tif depth != \"\" {\n\t\t\t\tidepth, err := strconv.Atoi(depth)\n\t\t\t\tif err != nil {\n\t\t\t\t\tpanic(err)\n\t\t\t\t}\n\t\t\t\tdepth = strconv.Itoa(idepth + 1)\n\t\t\t}\n\t\t}\n\t\tccon.VarHolder = \"item\" + depth\n\t\tccon.HoldReflect = item\n\t\tc.compileSwitch(ccon, node.List)\n\t\tif con.LastBufIndex() == startIndex {\n\t\t\tcon.DiscardAndAfter(startIndex - 1)\n\t\t\treturn\n\t\t}\n\t\tcon.EndLoop(\"}\\n\")\n\t\tc.afterTemplate(con, startIndex)\n\t\tif node.ElseList != nil {\n\t\t\tcon.EndIf(sIndex, \"}\")\n\t\t\tcon.Push(\"startelse\", \" else {\\n\")\n\t\t\tif !useCopy {\n\t\t\t\tccon = con\n\t\t\t}\n\t\t\tc.compileSwitch(ccon, node.ElseList)\n\t\t\tcon.Push(\"endelse\", \"}\\n\")\n\t\t} else {\n\t\t\tcon.EndIf(sIndex, \"}\\n\")\n\t\t}\n\t}\n\n\tswitch outVal.Kind() {\n\tcase reflect.Map:\n\t\tvar item reflect.Value\n\t\tfor _, key := range outVal.MapKeys() {\n\t\t\titem = outVal.MapIndex(key)\n\t\t}\n\t\tc.detail(\"Range item:\", item)\n\t\tif !item.IsValid() {\n\t\t\tc.critical(\"expr:\", expr)\n\t\t\tc.critical(\"con.VarHolder\", con.VarHolder)\n\t\t\tpanic(\"item\" + \"^\\n\" + \"Invalid map. Maybe, it doesn't have any entries for the template engine to analyse?\")\n\t\t}\n\t\tstartIf(item, true)\n\tcase reflect.Slice:\n\t\tif outVal.Len() == 0 {\n\t\t\tc.critical(\"expr:\", expr)\n\t\t\tc.critical(\"con.VarHolder\", con.VarHolder)\n\t\t\tpanic(\"The sample data needs at-least one or more elements for the slices. We're looking into removing this requirement at some point!\")\n\t\t}\n\t\tstartIf(outVal.Index(0), false)\n\tcase reflect.Invalid:\n\t\treturn\n\t}\n}\n\n// ! Temporary, we probably want something that is good with non-struct pointers too\n// For compileSubSwitch and compileSubTemplate\nfunc (c *CTemplateSet) skipStructPointers(cur reflect.Value, id string) reflect.Value {\n\tif cur.Kind() == reflect.Ptr {\n\t\tc.detail(\"Looping over pointer\")\n\t\tfor cur.Kind() == reflect.Ptr {\n\t\t\tcur = cur.Elem()\n\t\t}\n\t\tc.detail(\"Data Kind:\", cur.Kind().String())\n\t\tc.detail(\"Field Bit:\", id)\n\t}\n\treturn cur\n}\n\n// For compileSubSwitch and compileSubTemplate\nfunc (c *CTemplateSet) checkIfValid(cur reflect.Value, varHolder string, holdReflect reflect.Value, varBit string, multiline bool) {\n\tif !cur.IsValid() {\n\t\tc.critical(\"Debug Data:\")\n\t\tc.critical(\"Holdreflect:\", holdReflect)\n\t\tc.critical(\"Holdreflect.Kind():\", holdReflect.Kind())\n\t\tif !c.config.SuperDebug {\n\t\t\tc.critical(\"cur.Kind():\", cur.Kind().String())\n\t\t}\n\t\tc.critical(\"\")\n\t\tif !multiline {\n\t\t\tpanic(varHolder + varBit + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t\t}\n\t\tpanic(varBit + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t}\n}\n\nfunc (c *CTemplateSet) compileSubSwitch(con CContext, node *parse.CommandNode) {\n\tc.dumpCall(\"compileSubSwitch\", con, node)\n\tswitch n := node.Args[0].(type) {\n\tcase *parse.FieldNode:\n\t\tc.detail(\"Field Node:\", n.Ident)\n\t\t/* Use reflect to determine if the field is for a method, otherwise assume it's a variable. Variable declarations are coming soon! */\n\t\tcur := con.HoldReflect\n\n\t\tvar varBit string\n\t\tif cur.Kind() == reflect.Interface {\n\t\t\tcur = cur.Elem()\n\t\t\tvarBit += \".(\" + cur.Type().Name() + \")\"\n\t\t}\n\n\t\tvar assLines string\n\t\tmultiline := false\n\t\tfor _, id := range n.Ident {\n\t\t\tc.detail(\"Data Kind:\", cur.Kind().String())\n\t\t\tc.detail(\"Field Bit:\", id)\n\t\t\tcur = c.skipStructPointers(cur, id)\n\t\t\tc.checkIfValid(cur, con.VarHolder, con.HoldReflect, varBit, multiline)\n\n\t\t\tc.detail(\"in-loop varBit:\" + varBit)\n\t\t\tif cur.Kind() == reflect.Map {\n\t\t\t\tcur = cur.MapIndex(reflect.ValueOf(id))\n\t\t\t\tvarBit += \"[\\\"\" + id + \"\\\"]\"\n\t\t\t\tcur = c.skipStructPointers(cur, id)\n\n\t\t\t\tif cur.Kind() == reflect.Struct || cur.Kind() == reflect.Interface {\n\t\t\t\t\t// TODO: Move the newVarByte declaration to the top level or to the if level, if a dispInt is only used in a particular if statement\n\t\t\t\t\tvar dispStr, newVarByte string\n\t\t\t\t\tif cur.Kind() == reflect.Interface {\n\t\t\t\t\t\tdispStr = \"Int\"\n\t\t\t\t\t\tif !c.hasDispInt {\n\t\t\t\t\t\t\tnewVarByte = \":\"\n\t\t\t\t\t\t\tc.hasDispInt = true\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t// TODO: De-dupe identical struct types rather than allocating a variable for each one\n\t\t\t\t\tif cur.Kind() == reflect.Struct {\n\t\t\t\t\t\tdispStr = \"Struct\" + strconv.Itoa(c.localDispStructIndex)\n\t\t\t\t\t\tnewVarByte = \":\"\n\t\t\t\t\t\tc.localDispStructIndex++\n\t\t\t\t\t}\n\t\t\t\t\tcon.VarHolder = \"disp\" + dispStr\n\t\t\t\t\tvarBit = con.VarHolder + \" \" + newVarByte + \"= \" + con.VarHolder + varBit + \"\\n\"\n\t\t\t\t\tmultiline = true\n\t\t\t\t} else {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tif cur.Kind() != reflect.Interface {\n\t\t\t\tcur = cur.FieldByName(id)\n\t\t\t\tvarBit += \".\" + id\n\t\t\t}\n\n\t\t\t// TODO: Handle deeply nested pointers mixed with interfaces mixed with pointers better\n\t\t\tif cur.Kind() == reflect.Interface {\n\t\t\t\tcur = cur.Elem()\n\t\t\t\tvarBit += \".(\"\n\t\t\t\t// TODO: Surely, there's a better way of doing this?\n\t\t\t\tif cur.Type().PkgPath() != \"main\" && cur.Type().PkgPath() != \"\" {\n\t\t\t\t\tc.importMap[\"html/template\"] = \"html/template\"\n\t\t\t\t\tvarBit += strings.TrimPrefix(cur.Type().PkgPath(), \"html/\") + \".\"\n\t\t\t\t}\n\t\t\t\tvarBit += cur.Type().Name() + \")\"\n\t\t\t}\n\t\t\tc.detail(\"End Cycle:\", varBit)\n\t\t}\n\n\t\tif multiline {\n\t\t\tassSplit := strings.Split(varBit, \"\\n\")\n\t\t\tvarBit = assSplit[len(assSplit)-1]\n\t\t\tassSplit = assSplit[:len(assSplit)-1]\n\t\t\tassLines = strings.Join(assSplit, \"\\n\") + \"\\n\"\n\t\t}\n\t\tc.compileVarSub(con, con.VarHolder+varBit, cur, assLines, func(in string) string {\n\t\t\tfor _, varItem := range c.varList {\n\t\t\t\tif strings.HasPrefix(in, varItem.Destination) {\n\t\t\t\t\tin = strings.Replace(in, varItem.Destination, varItem.Name, 1)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn in\n\t\t})\n\tcase *parse.DotNode:\n\t\tc.detail(\"Dot Node:\", node.String())\n\t\tc.compileVarSub(con, con.VarHolder, con.HoldReflect, \"\", nil)\n\tcase *parse.NilNode:\n\t\tpanic(\"Nil is not a command x.x\")\n\tcase *parse.VariableNode:\n\t\tc.detail(\"Variable Node:\", n.String())\n\t\tc.detail(n.Ident)\n\t\tvarname, reflectVal := c.compileIfVarSub(con, n.String())\n\t\tc.compileVarSub(con, varname, reflectVal, \"\", nil)\n\tcase *parse.StringNode:\n\t\tcon.Push(\"stringnode\", n.Quoted)\n\tcase *parse.IdentifierNode:\n\t\tc.detail(\"Identifier Node:\", node)\n\t\tc.detail(\"Identifier Node Args:\", node.Args)\n\t\tout, outval, lit, noident := c.compileIdentSwitch(con, node)\n\t\tif noident {\n\t\t\treturn\n\t\t} else if lit {\n\t\t\tcon.Push(\"identifier\", out)\n\t\t\treturn\n\t\t}\n\t\tc.compileVarSub(con, out, outval, \"\", nil)\n\tdefault:\n\t\tc.unknownNode(node)\n\t}\n}\n\nfunc (c *CTemplateSet) compileExprSwitch(con CContext, node *parse.CommandNode) (out string) {\n\tc.dumpCall(\"compileExprSwitch\", con, node)\n\tfirstWord := node.Args[0]\n\tswitch n := firstWord.(type) {\n\tcase *parse.FieldNode:\n\t\tif c.config.SuperDebug {\n\t\t\tc.logger.Println(\"Field Node:\", n.Ident)\n\t\t\tfor _, id := range n.Ident {\n\t\t\t\tc.logger.Println(\"Field Bit:\", id)\n\t\t\t}\n\t\t}\n\t\t/* Use reflect to determine if the field is for a method, otherwise assume it's a variable. Coming Soon. */\n\t\tout = c.compileBoolSub(con, n.String())\n\tcase *parse.ChainNode:\n\t\tc.detail(\"Chain Node:\", n.Node)\n\t\tc.detail(\"Node Args:\", node.Args)\n\tcase *parse.IdentifierNode:\n\t\tc.detail(\"Identifier Node:\", node)\n\t\tc.detail(\"Node Args:\", node.Args)\n\t\tout = c.compileIdentSwitchN(con, node)\n\tcase *parse.DotNode:\n\t\tout = con.VarHolder\n\tcase *parse.VariableNode:\n\t\tc.detail(\"Variable Node:\", n.String())\n\t\tc.detail(\"Node Identifier:\", n.Ident)\n\t\tout, _ = c.compileIfVarSub(con, n.String())\n\tcase *parse.NilNode:\n\t\tpanic(\"Nil is not a command x.x\")\n\tcase *parse.PipeNode:\n\t\tc.detail(\"Pipe Node:\", n)\n\t\tc.detail(\"Node Args:\", node.Args)\n\t\tout += c.compileIdentSwitchN(con, node)\n\tdefault:\n\t\tc.unknownNode(firstWord)\n\t}\n\tc.retCall(\"compileExprSwitch\", out)\n\treturn out\n}\n\nfunc (c *CTemplateSet) unknownNode(n parse.Node) {\n\tel := reflect.ValueOf(n).Elem()\n\tc.logger.Println(\"Unknown Kind:\", el.Kind())\n\tc.logger.Println(\"Unknown Type:\", el.Type().Name())\n\tpanic(\"I don't know what node this is! Grr...\")\n}\n\nfunc (c *CTemplateSet) compileIdentSwitchN(con CContext, n *parse.CommandNode) (out string) {\n\tc.detail(\"in compileIdentSwitchN\")\n\tout, _, _, _ = c.compileIdentSwitch(con, n)\n\treturn out\n}\n\nfunc (c *CTemplateSet) dumpSymbol(pos int, n *parse.CommandNode, symbol string) {\n\tc.detail(\"symbol:\", symbol)\n\tc.detail(\"n.Args[pos+1]\", n.Args[pos+1])\n\tc.detail(\"n.Args[pos+2]\", n.Args[pos+2])\n}\n\nfunc (c *CTemplateSet) compareFunc(con CContext, pos int, n *parse.CommandNode, compare string) (out string) {\n\tc.dumpSymbol(pos, n, compare)\n\treturn c.compileIfVarSubN(con, n.Args[pos+1].String()) + \" \" + compare + \" \" + c.compileIfVarSubN(con, n.Args[pos+2].String())\n}\n\nfunc (c *CTemplateSet) simpleMath(con CContext, pos int, n *parse.CommandNode, symbol string) (out string, val reflect.Value) {\n\tleftParam, val2 := c.compileIfVarSub(con, n.Args[pos+1].String())\n\trightParam, val3 := c.compileIfVarSub(con, n.Args[pos+2].String())\n\tif val2.IsValid() {\n\t\tval = val2\n\t} else if val3.IsValid() {\n\t\tval = val3\n\t} else {\n\t\t// TODO: What does this do?\n\t\tnumSample := 1\n\t\tval = reflect.ValueOf(numSample)\n\t}\n\tc.dumpSymbol(pos, n, symbol)\n\treturn leftParam + \" \" + symbol + \" \" + rightParam, val\n}\n\nfunc (c *CTemplateSet) compareJoin(con CContext, pos int, node *parse.CommandNode, symbol string) (pos2 int, out string) {\n\tc.detailf(\"Building %s function\", symbol)\n\tif pos == 0 {\n\t\tc.logger.Println(\"pos:\", pos)\n\t\tpanic(symbol + \" is missing a left operand\")\n\t}\n\tif len(node.Args) <= pos {\n\t\tc.logger.Println(\"post pos:\", pos)\n\t\tc.logger.Println(\"len(node.Args):\", len(node.Args))\n\t\tpanic(symbol + \" is missing a right operand\")\n\t}\n\n\tleft := c.compileBoolSub(con, node.Args[pos-1].String())\n\t_, funcExists := c.funcMap[node.Args[pos+1].String()]\n\n\tvar right string\n\tif !funcExists {\n\t\tright = c.compileBoolSub(con, node.Args[pos+1].String())\n\t}\n\tout = left + \" \" + symbol + \" \" + right\n\n\tc.detail(\"Left op:\", node.Args[pos-1])\n\tc.detail(\"Right op:\", node.Args[pos+1])\n\tif !funcExists {\n\t\tpos++\n\t}\n\tc.detail(\"pos:\", pos)\n\tc.detail(\"len(node.Args):\", len(node.Args))\n\n\treturn pos, out\n}\n\nfunc (c *CTemplateSet) compileIdentSwitch(con CContext, node *parse.CommandNode) (out string, val reflect.Value, literal, notIdent bool) {\n\tc.dumpCall(\"compileIdentSwitch\", con, node)\n\tlitString := func(inner string, bytes bool) {\n\t\tif !bytes {\n\t\t\tinner = \"StringToBytes(\" + inner + \"/*,tmp*/)\"\n\t\t}\n\t\tout = \"w.Write(\" + inner + \")\\n\"\n\t\tliteral = true\n\t}\nArgLoop:\n\tfor pos := 0; pos < len(node.Args); pos++ {\n\t\tid := node.Args[pos]\n\t\tc.detail(\"pos:\", pos)\n\t\tc.detail(\"id:\", id)\n\t\tswitch id.String() {\n\t\tcase \"not\":\n\t\t\tout += \"!\"\n\t\tcase \"or\", \"and\":\n\t\t\tvar rout string\n\t\t\tpos, rout = c.compareJoin(con, pos, node, c.funcMap[id.String()].(string)) // TODO: Test this\n\t\t\tout += rout\n\t\tcase \"le\", \"lt\", \"gt\", \"ge\":\n\t\t\tout += c.compareFunc(con, pos, node, c.funcMap[id.String()].(string))\n\t\t\tbreak ArgLoop\n\t\tcase \"eq\", \"ne\":\n\t\t\to := c.compareFunc(con, pos, node, c.funcMap[id.String()].(string))\n\t\t\tif out == \"!\" {\n\t\t\t\to = \"(\" + o + \")\"\n\t\t\t}\n\t\t\tout += o\n\t\t\tbreak ArgLoop\n\t\tcase \"add\", \"subtract\", \"divide\", \"multiply\":\n\t\t\trout, rval := c.simpleMath(con, pos, node, c.funcMap[id.String()].(string))\n\t\t\tout += rout\n\t\t\tval = rval\n\t\t\tbreak ArgLoop\n\t\tcase \"elapsed\":\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\t// TODO: Refactor this\n\t\t\t// TODO: Validate that this is actually a time.Time\n\t\t\t//litString(\"time.Since(\"+leftParam+\").String()\", false)\n\t\t\tc.importMap[\"time\"] = \"time\"\n\t\t\tc.importMap[\"github.com/Azareal/Gosora/uutils\"] = \"github.com/Azareal/Gosora/uutils\"\n\t\t\tlitString(\"time.Duration(uutils.Nanotime() - \"+leftParam+\").String()\", false)\n\t\t\tbreak ArgLoop\n\t\tcase \"dock\":\n\t\t\t// TODO: Implement string literals properly\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\trightOp := node.Args[pos+2].String()\n\t\t\tif len(leftOp) == 0 || len(rightOp) == 0 {\n\t\t\t\tpanic(\"The left or right operand for function dock cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam := leftOp\n\t\t\tif leftOp[0] != '\"' {\n\t\t\t\tleftParam, _ = c.compileIfVarSub(con, leftParam)\n\t\t\t}\n\t\t\tif rightOp[0] == '\"' {\n\t\t\t\tpanic(\"The right operand for function dock cannot be a string\")\n\t\t\t}\n\t\t\trightParam, val3 := c.compileIfVarSub(con, rightOp)\n\t\t\tif !val3.IsValid() {\n\t\t\t\tpanic(\"val3 is invalid\")\n\t\t\t}\n\t\t\tval = val3\n\n\t\t\t// TODO: Refactor this\n\t\t\tif leftParam[0] == '\"' {\n\t\t\t\tleftParam = strings.TrimSuffix(strings.TrimPrefix(leftParam, \"\\\"\"), \"\\\"\")\n\t\t\t\tid, ok := c.config.DockToID[leftParam]\n\t\t\t\tif ok {\n\t\t\t\t\tout = \"c.BuildWidget3(\" + strconv.Itoa(id) + \",\" + rightParam + \")\\n\"\n\t\t\t\t\tliteral = true\n\t\t\t\t\tbreak ArgLoop\n\t\t\t\t}\n\t\t\t}\n\t\t\tlitString(\"c.BuildWidget(\"+leftParam+\",\"+rightParam+\")\", false)\n\t\t\tbreak ArgLoop\n\t\tcase \"hasWidgets\":\n\t\t\t// TODO: Implement string literals properly\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\trightOp := node.Args[pos+2].String()\n\t\t\tif len(leftOp) == 0 || len(rightOp) == 0 {\n\t\t\t\tpanic(\"The left or right operand for function dock cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam := leftOp\n\t\t\tif leftOp[0] != '\"' {\n\t\t\t\tleftParam, _ = c.compileIfVarSub(con, leftParam)\n\t\t\t}\n\t\t\tif rightOp[0] == '\"' {\n\t\t\t\tpanic(\"The right operand for function dock cannot be a string\")\n\t\t\t}\n\t\t\trightParam, val3 := c.compileIfVarSub(con, rightOp)\n\t\t\tif !val3.IsValid() {\n\t\t\t\tpanic(\"val3 is invalid\")\n\t\t\t}\n\t\t\tval = val3\n\n\t\t\t// TODO: Refactor this\n\t\t\tif leftParam[0] == '\"' {\n\t\t\t\tleftParam = strings.TrimSuffix(strings.TrimPrefix(leftParam, \"\\\"\"), \"\\\"\")\n\t\t\t\tid, ok := c.config.DockToID[leftParam]\n\t\t\t\tif ok {\n\t\t\t\t\tout = \"c.HasWidgets2(\" + strconv.Itoa(id) + \",\" + rightParam + \")\"\n\t\t\t\t\tliteral = true\n\t\t\t\t\tbreak ArgLoop\n\t\t\t\t}\n\t\t\t}\n\t\t\tout = \"c.HasWidgets(\" + leftParam + \",\" + rightParam + \")\"\n\t\t\tliteral = true\n\t\t\tbreak ArgLoop\n\t\tcase \"js\":\n\t\t\tif c.lang == \"js\" {\n\t\t\t\tout = \"true\"\n\t\t\t} else {\n\t\t\t\tout = \"false\"\n\t\t\t}\n\t\t\tliteral = true\n\t\t\tbreak ArgLoop\n\t\tcase \"lang\":\n\t\t\t// TODO: Implement string literals properly\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The left operand for the language string cannot be left blank\")\n\t\t\t}\n\t\t\tif leftOp[0] == '\"' {\n\t\t\t\t// ! Slightly crude but it does the job\n\t\t\t\tleftParam := strings.Replace(leftOp, \"\\\"\", \"\", -1)\n\t\t\t\tc.langIndexToName = append(c.langIndexToName, leftParam)\n\t\t\t\tnotIdent = true\n\t\t\t\tcon.PushPhrase(len(c.langIndexToName) - 1)\n\t\t\t} else {\n\t\t\t\tleftParam := leftOp\n\t\t\t\tif leftOp[0] != '\"' {\n\t\t\t\t\tleftParam, _ = c.compileIfVarSub(con, leftParam)\n\t\t\t\t}\n\t\t\t\t// TODO: Add an optimisation if it's a string literal passsed in from a parent template rather than a true dynamic\n\t\t\t\tlitString(\"phrases.GetTmplPhrasef(\"+leftParam+\")\", false)\n\t\t\t\tc.importMap[langPkg] = langPkg\n\t\t\t}\n\t\t\tbreak ArgLoop\n\t\tcase \"langf\":\n\t\t\t// TODO: Implement string literals properly\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The left operand for the language string cannot be left blank\")\n\t\t\t}\n\t\t\tif leftOp[0] != '\"' {\n\t\t\t\tpanic(\"Phrase names cannot be dynamic\")\n\t\t\t}\n\n\t\t\tvar olist []string\n\t\t\tfor i := pos + 2; i < len(node.Args); i++ {\n\t\t\t\top := node.Args[i].String()\n\t\t\t\tif op != \"\" {\n\t\t\t\t\tif /*op[0] == '.' || */ op[0] == '$' {\n\t\t\t\t\t\tpanic(\"langf args cannot be dynamic\")\n\t\t\t\t\t}\n\t\t\t\t\tif op[0] != '.' && op[0] != '\"' && !unicode.IsDigit(rune(op[0])) {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tolist = append(olist, op)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(olist) == 0 {\n\t\t\t\tpanic(\"You must provide parameters for langf\")\n\t\t\t}\n\n\t\t\tob := \",\"\n\t\t\tfor _, op := range olist {\n\t\t\t\tif op[0] == '.' {\n\t\t\t\t\tparam, val3 := c.compileIfVarSub(con, op)\n\t\t\t\t\tif !val3.IsValid() {\n\t\t\t\t\t\tpanic(\"val3 is invalid\")\n\t\t\t\t\t}\n\t\t\t\t\tob += param + \",\"\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tallNum := true\n\t\t\t\tfor _, o := range op {\n\t\t\t\t\tif !unicode.IsDigit(o) {\n\t\t\t\t\t\tallNum = false\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif allNum {\n\t\t\t\t\tob += strings.Replace(op, \"\\\"\", \"\\\\\\\"\", -1) + \",\"\n\t\t\t\t} else {\n\t\t\t\t\tob += ob + \",\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ob != \"\" {\n\t\t\t\tob = ob[:len(ob)-1]\n\t\t\t}\n\n\t\t\t// TODO: Implement string literals properly\n\t\t\t// ! Slightly crude but it does the job\n\t\t\tlitString(\"phrases.GetTmplPhrasef(\"+leftOp+ob+\")\", false)\n\t\t\tc.importMap[langPkg] = langPkg\n\t\t\tbreak ArgLoop\n\t\tcase \"level\":\n\t\t\t// TODO: Implement level literals\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The leftoperand for function level cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\t// TODO: Refactor this\n\t\t\tlitString(\"phrases.GetLevelPhrase(\"+leftParam+\")\", false)\n\t\t\tc.importMap[langPkg] = langPkg\n\t\t\tbreak ArgLoop\n\t\tcase \"bunit\":\n\t\t\t// TODO: Implement bunit literals\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The leftoperand for function buint cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\tout = \"{\\nbyteFloat, unit := c.ConvertByteUnit(float64(\" + leftParam + \"))\\n\"\n\t\t\tout += \"w.Write(StringToBytes(fmt.Sprintf(\\\"%.1f\\\", byteFloat)/*,tmp*/))\\nw.Write(StringToBytes(unit/*,tmp*/))\\n}\\n\"\n\t\t\tliteral = true\n\t\t\tc.importMap[\"fmt\"] = \"fmt\"\n\t\t\tbreak ArgLoop\n\t\tcase \"abstime\":\n\t\t\t// TODO: Implement level literals\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The leftoperand for function abstime cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\t// TODO: Refactor this\n\t\t\tlitString(leftParam+\".Format(\\\"2006-01-02 15:04:05\\\")\", false)\n\t\t\tbreak ArgLoop\n\t\tcase \"reltime\":\n\t\t\t// TODO: Implement level literals\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The leftoperand for function reltime cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\t// TODO: Refactor this\n\t\t\tlitString(\"c.RelativeTime(\"+leftParam+\")\", false)\n\t\t\tbreak ArgLoop\n\t\tcase \"scope\":\n\t\t\tliteral = true\n\t\t\tbreak ArgLoop\n\t\t// TODO: Optimise ptmpl\n\t\tcase \"dyntmpl\", \"ptmpl\":\n\t\t\tvar pageParam, headParam string\n\t\t\t// TODO: Implement string literals properly\n\t\t\t// TODO: Should we check to see if pos+3 is within the bounds of the slice?\n\t\t\tnameOp := node.Args[pos+1].String()\n\t\t\tpageOp := node.Args[pos+2].String()\n\t\t\theadOp := node.Args[pos+3].String()\n\t\t\tif len(nameOp) == 0 || len(pageOp) == 0 || len(headOp) == 0 {\n\t\t\t\tpanic(\"None of the three operands for function dyntmpl can be left blank\")\n\t\t\t}\n\t\t\tnameParam := nameOp\n\t\t\tif nameOp[0] != '\"' {\n\t\t\t\tnameParam, _ = c.compileIfVarSub(con, nameParam)\n\t\t\t}\n\t\t\tif pageOp[0] == '\"' {\n\t\t\t\tpanic(\"The page operand for function dyntmpl cannot be a string\")\n\t\t\t}\n\t\t\tif headOp[0] == '\"' {\n\t\t\t\tpanic(\"The head operand for function dyntmpl cannot be a string\")\n\t\t\t}\n\n\t\t\tpageParam, val3 := c.compileIfVarSub(con, pageOp)\n\t\t\tif !val3.IsValid() {\n\t\t\t\tpanic(\"val3 is invalid\")\n\t\t\t}\n\t\t\theadParam, val4 := c.compileIfVarSub(con, headOp)\n\t\t\tif !val4.IsValid() {\n\t\t\t\tpanic(\"val4 is invalid\")\n\t\t\t}\n\t\t\tval = val4\n\n\t\t\t// TODO: Refactor this\n\t\t\t// TODO: Call the template function directly rather than going through RunThemeTemplate to eliminate a round of indirection?\n\t\t\tout = \"{\\ne := \" + headParam + \".Theme.RunTmpl(\" + nameParam + \",\" + pageParam + \",w)\\n\"\n\t\t\tout += \"if e != nil {\\nreturn e\\n}\\n}\\n\"\n\t\t\tliteral = true\n\t\t\tbreak ArgLoop\n\t\tcase \"flush\":\n\t\t\tliteral = true\n\t\t\tbreak ArgLoop\n\t\t/*if c.lang == \"js\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = \"if fl, ok := iw.(http.Flusher); ok {\\nfl.Flush()\\n}\\n\"\n\t\tliteral = true\n\t\tc.importMap[\"net/http\"] = \"net/http\"\n\t\tbreak ArgLoop*/\n\t\t// TODO: Test this\n\t\tcase \"res\":\n\t\t\tleftOp := node.Args[pos+1].String()\n\t\t\tif len(leftOp) == 0 {\n\t\t\t\tpanic(\"The leftoperand for function res cannot be left blank\")\n\t\t\t}\n\t\t\tleftParam, _ := c.compileIfVarSub(con, leftOp)\n\t\t\tliteral = true\n\t\t\tif leftParam[0] == '\"' {\n\t\t\t\tif leftParam[1] == '/' && leftParam[2] == '/' {\n\t\t\t\t\tlitString(leftParam, false)\n\t\t\t\t\tbreak ArgLoop\n\t\t\t\t}\n\t\t\t\tout = \"{n := \" + leftParam + \"\\nif f, ok := c.StaticFiles.GetShort(n); ok {\\nw.Write(StringToBytes(f.OName))\\n} else {\\nw.Write(StringToBytes(n))\\n}}\\n\"\n\t\t\t\tbreak ArgLoop\n\t\t\t}\n\t\t\tout = \"{n := \" + leftParam + \"\\nif n[0] == '/' && n[1] == '/' {\\n} else {\\nif f, ok := c.StaticFiles.GetShort(n); ok {\\nn = f.OName\\n}\\nw.Write(StringToBytes(n))\\n}\\n\"\n\t\t\tbreak ArgLoop\n\t\tdefault:\n\t\t\tc.detail(\"Variable!\")\n\t\t\tif len(node.Args) > (pos + 1) {\n\t\t\t\tnextNode := node.Args[pos+1].String()\n\t\t\t\tif nextNode == \"or\" || nextNode == \"and\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tout += c.compileIfVarSubN(con, id.String())\n\t\t}\n\t}\n\tc.retCall(\"compileIdentSwitch\", out, val, literal)\n\treturn out, val, literal, notIdent\n}\n\nfunc (c *CTemplateSet) compileReflectSwitch(con CContext, node *parse.CommandNode) (out string, outVal reflect.Value) {\n\tc.dumpCall(\"compileReflectSwitch\", con, node)\n\tfirstWord := node.Args[0]\n\tswitch n := firstWord.(type) {\n\tcase *parse.FieldNode:\n\t\tif c.config.SuperDebug {\n\t\t\tc.logger.Println(\"Field Node:\", n.Ident)\n\t\t\tfor _, id := range n.Ident {\n\t\t\t\tc.logger.Println(\"Field Bit:\", id)\n\t\t\t}\n\t\t}\n\t\t/* Use reflect to determine if the field is for a method, otherwise assume it's a variable. Coming Soon. */\n\t\treturn c.compileIfVarSub(con, n.String())\n\tcase *parse.ChainNode:\n\t\tc.detail(\"Chain Node:\", n.Node)\n\t\tc.detail(\"node.Args:\", node.Args)\n\tcase *parse.DotNode:\n\t\treturn con.VarHolder, con.HoldReflect\n\tcase *parse.NilNode:\n\t\tpanic(\"Nil is not a command x.x\")\n\tdefault:\n\t\t//panic(\"I don't know what node this is\")\n\t}\n\treturn out, outVal\n}\n\nfunc (c *CTemplateSet) compileIfVarSubN(con CContext, varname string) (out string) {\n\tc.dumpCall(\"compileIfVarSubN\", con, varname)\n\tout, _ = c.compileIfVarSub(con, varname)\n\treturn out\n}\n\nfunc (c *CTemplateSet) compileIfVarSub(con CContext, varname string) (out string, val reflect.Value) {\n\tc.dumpCall(\"compileIfVarSub\", con, varname)\n\tcur := con.HoldReflect\n\tif varname[0] != '.' && varname[0] != '$' {\n\t\treturn varname, cur\n\t}\n\n\tstepInterface := func() {\n\t\tnobreak := (cur.Type().Name() == \"nobreak\")\n\t\tc.detailf(\"cur.Type().Name(): %+v\\n\", cur.Type().Name())\n\t\tif cur.Kind() == reflect.Interface && !nobreak {\n\t\t\tcur = cur.Elem()\n\t\t\tout += \".(\" + cur.Type().Name() + \")\"\n\t\t}\n\t}\n\n\tbits := strings.Split(varname, \".\")\n\tif varname[0] == '$' {\n\t\tvar res VarItemReflect\n\t\tif varname[1] == '.' {\n\t\t\tres = c.localVars[con.TemplateName][\".\"]\n\t\t} else {\n\t\t\tres = c.localVars[con.TemplateName][strings.TrimPrefix(bits[0], \"$\")]\n\t\t}\n\t\tout += res.Destination\n\t\tcur = res.Value\n\n\t\tif cur.Kind() == reflect.Interface {\n\t\t\tcur = cur.Elem()\n\t\t}\n\t} else {\n\t\tout += con.VarHolder\n\t\tstepInterface()\n\t}\n\tbits[0] = strings.TrimPrefix(bits[0], \"$\")\n\n\tdumpKind := func(pre string) {\n\t\tc.detail(pre+\" Kind:\", cur.Kind())\n\t\tc.detail(pre+\" Type:\", cur.Type().Name())\n\t}\n\tdumpKind(\"Cur\")\n\tfor _, bit := range bits {\n\t\tc.detail(\"Variable Field:\", bit)\n\t\tif bit == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// TODO: Fix this up so that it works for regular pointers and not just struct pointers. Ditto for the other cur.Kind() == reflect.Ptr we have in this file\n\t\tif cur.Kind() == reflect.Ptr {\n\t\t\tc.detail(\"Looping over pointer\")\n\t\t\tfor cur.Kind() == reflect.Ptr {\n\t\t\t\tcur = cur.Elem()\n\t\t\t}\n\t\t\tc.detail(\"Data Kind:\", cur.Kind().String())\n\t\t\tc.detail(\"Field Bit:\", bit)\n\t\t}\n\n\t\tcur = cur.FieldByName(bit)\n\t\tout += \".\" + bit\n\t\tif !cur.IsValid() {\n\t\t\tc.logger.Println(\"cur: \", cur)\n\t\t\tpanic(out + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t\t}\n\t\tstepInterface()\n\t\tif !cur.IsValid() {\n\t\t\tc.logger.Println(\"cur: \", cur)\n\t\t\tpanic(out + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t\t}\n\t\tdumpKind(\"Data\")\n\t}\n\n\tc.detail(\"Out Value:\", out)\n\tdumpKind(\"Out\")\n\tfor _, varItem := range c.varList {\n\t\tif strings.HasPrefix(out, varItem.Destination) {\n\t\t\tout = strings.Replace(out, varItem.Destination, varItem.Name, 1)\n\t\t}\n\t}\n\n\t_, ok := c.stats[out]\n\tif ok {\n\t\tc.stats[out]++\n\t} else {\n\t\tc.stats[out] = 1\n\t}\n\n\tc.retCall(\"compileIfVarSub\", out, cur)\n\treturn out, cur\n}\n\nfunc (c *CTemplateSet) compileBoolSub(con CContext, varname string) string {\n\tc.dumpCall(\"compileBoolSub\", con, varname)\n\tout, val := c.compileIfVarSub(con, varname)\n\t// TODO: What if it's a pointer or an interface? I *think* we've got pointers handled somewhere, but not interfaces which we don't know the types of at compile time\n\tswitch val.Kind() {\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:\n\t\tout += \">0\"\n\tcase reflect.Bool: // Do nothing\n\tcase reflect.String:\n\t\tout += \"!=\\\"\\\"\"\n\tcase reflect.Slice, reflect.Map:\n\t\tout = \"len(\" + out + \")!=0\"\n\t// TODO: Follow the pointer and evaluate it?\n\tcase reflect.Ptr:\n\t\tout += \"!=nil\"\n\tdefault:\n\t\tc.logger.Println(\"Variable Name:\", varname)\n\t\tc.logger.Println(\"Variable Holder:\", con.VarHolder)\n\t\tc.logger.Println(\"Variable Kind:\", con.HoldReflect.Kind())\n\t\tpanic(\"I don't know what this variable's type is o.o\\n\")\n\t}\n\tc.retCall(\"compileBoolSub\", out)\n\treturn out\n}\n\n// For debugging the template generator\nfunc (c *CTemplateSet) debugParam(param interface{}, depth int) (pstr string) {\n\tswitch p := param.(type) {\n\tcase CContext:\n\t\treturn \"con,\"\n\tcase reflect.Value:\n\t\tif p.Kind() == reflect.Ptr || p.Kind() == reflect.Interface {\n\t\t\tfor p.Kind() == reflect.Ptr || p.Kind() == reflect.Interface {\n\t\t\t\tif p.Kind() == reflect.Ptr {\n\t\t\t\t\tpstr += \"*\"\n\t\t\t\t} else {\n\t\t\t\t\tpstr += \"£\"\n\t\t\t\t}\n\t\t\t\tp = p.Elem()\n\t\t\t}\n\t\t}\n\t\tkind := p.Kind().String()\n\t\tif kind != \"struct\" {\n\t\t\tpstr += kind\n\t\t} else {\n\t\t\tpstr += p.Type().Name()\n\t\t}\n\t\treturn pstr + \",\"\n\tcase string:\n\t\treturn \"\\\"\" + p + \"\\\",\"\n\tcase int:\n\t\treturn strconv.Itoa(p) + \",\"\n\tcase bool:\n\t\tif p {\n\t\t\treturn \"true,\"\n\t\t}\n\t\treturn \"false,\"\n\tcase func(string) string:\n\t\tif p == nil {\n\t\t\treturn \"nil,\"\n\t\t}\n\t\treturn \"func(string) string),\"\n\tdefault:\n\t\treturn \"?,\"\n\t}\n}\nfunc (c *CTemplateSet) dumpCall(name string, params ...interface{}) {\n\tvar pstr string\n\tfor _, param := range params {\n\t\tpstr += c.debugParam(param, 0)\n\t}\n\tif len(pstr) > 0 {\n\t\tpstr = pstr[:len(pstr)-1]\n\t}\n\tc.detail(\"called \" + name + \"(\" + pstr + \")\")\n}\nfunc (c *CTemplateSet) retCall(name string, params ...interface{}) {\n\tvar pstr string\n\tfor _, param := range params {\n\t\tpstr += c.debugParam(param, 0)\n\t}\n\tif len(pstr) > 0 {\n\t\tpstr = pstr[:len(pstr)-1]\n\t}\n\tc.detail(\"returned from \" + name + \" => (\" + pstr + \")\")\n}\n\nfunc buildUserExprs(holder string) ([]string, []string) {\n\tuserExprs := []string{\n\t\tholder + \".CurrentUser.Loggedin\",\n\t\tholder + \".CurrentUser.IsSuperMod\",\n\t\tholder + \".CurrentUser.IsAdmin\",\n\t}\n\tnegUserExprs := []string{\n\t\t\"!\" + holder + \".CurrentUser.Loggedin\",\n\t\t\"!\" + holder + \".CurrentUser.IsSuperMod\",\n\t\t\"!\" + holder + \".CurrentUser.IsAdmin\",\n\t}\n\treturn userExprs, negUserExprs\n}\n\nfunc (c *CTemplateSet) compileVarSub(con CContext, varname string, val reflect.Value, assLines string, onEnd func(string) string) {\n\tc.dumpCall(\"compileVarSub\", con, varname, val, assLines, onEnd)\n\tdefer c.retCall(\"compileVarSub\")\n\tif onEnd == nil {\n\t\tonEnd = func(in string) string {\n\t\t\treturn in\n\t\t}\n\t}\n\n\t// Is this a literal string?\n\tif len(varname) != 0 && varname[0] == '\"' {\n\t\tcon.Push(\"lvarsub\", onEnd(assLines+\"w.Write(StringToBytes(\"+varname+\"/*,tmp*/))\\n\"))\n\t\treturn\n\t}\n\tfor _, varItem := range c.varList {\n\t\tif strings.HasPrefix(varname, varItem.Destination) {\n\t\t\tvarname = strings.Replace(varname, varItem.Destination, varItem.Name, 1)\n\t\t}\n\t}\n\n\t_, ok := c.stats[varname]\n\tif ok {\n\t\tc.stats[varname]++\n\t} else {\n\t\tc.stats[varname] = 1\n\t}\n\tif val.Kind() == reflect.Interface {\n\t\tval = val.Elem()\n\t}\n\tif val.Kind() == reflect.Ptr {\n\t\tfor val.Kind() == reflect.Ptr {\n\t\t\tval = val.Elem()\n\t\t\tvarname = \"*\" + varname\n\t\t}\n\t}\n\n\tc.detail(\"varname:\", varname)\n\tc.detail(\"assLines:\", assLines)\n\tvar base string\n\tswitch val.Kind() {\n\tcase reflect.Int:\n\t\tc.importMap[\"strconv\"] = \"strconv\"\n\t\tbase = \"StringToBytes(strconv.Itoa(\" + varname + \")/*,tmp*/)\"\n\tcase reflect.Bool:\n\t\t// TODO: Take c.memberOnly into account\n\t\t// TODO: Make this a template fragment so more optimisations can be applied to this\n\t\t// TODO: De-duplicate this logic\n\t\tuserExprs, negUserExprs := buildUserExprs(con.RootHolder)\n\t\tif c.guestOnly {\n\t\t\tc.detail(\"optimising away member branch\")\n\t\t\tif inSlice(userExprs, varname) {\n\t\t\t\tc.detail(\"positive condition:\", varname)\n\t\t\t\tc.addText(con, []byte(\"false\"))\n\t\t\t\treturn\n\t\t\t} else if inSlice(negUserExprs, varname) {\n\t\t\t\tc.detail(\"negative condition:\", varname)\n\t\t\t\tc.addText(con, []byte(\"true\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t} else if c.memberOnly {\n\t\t\tc.detail(\"optimising away guest branch\")\n\t\t\tif (con.RootHolder + \".CurrentUser.Loggedin\") == varname {\n\t\t\t\tc.detail(\"positive condition:\", varname)\n\t\t\t\tc.addText(con, []byte(\"true\"))\n\t\t\t\treturn\n\t\t\t} else if (\"!\" + con.RootHolder + \".CurrentUser.Loggedin\") == varname {\n\t\t\t\tc.detail(\"negative condition:\", varname)\n\t\t\t\tc.addText(con, []byte(\"false\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tstartIf := con.StartIf(\"if \" + varname + \" {\\n\")\n\t\tc.addText(con, []byte(\"true\"))\n\t\tcon.EndIf(startIf, \"} \")\n\t\tcon.Push(\"startelse\", \"else {\\n\")\n\t\tc.addText(con, []byte(\"false\"))\n\t\tcon.Push(\"endelse\", \"}\\n\")\n\t\treturn\n\tcase reflect.Slice:\n\t\tif val.Len() == 0 {\n\t\t\tc.critical(\"varname:\", varname)\n\t\t\tpanic(\"The sample data needs at-least one or more elements for the slices. We're looking into removing this requirement at some point!\")\n\t\t}\n\t\titem := val.Index(0)\n\t\tif item.Type().Name() != \"uint8\" { // uint8 == byte, complicated because it's a type alias\n\t\t\tpanic(\"unable to format \" + item.Type().Name() + \" as text\")\n\t\t}\n\t\tbase = varname\n\tcase reflect.String:\n\t\tif val.Type().Name() != \"string\" && !strings.HasPrefix(varname, \"string(\") {\n\t\t\tvarname = \"string(\" + varname + \")\"\n\t\t}\n\t\tbase = \"StringToBytes(\" + varname + \"/*,tmp*/)\"\n\t\t// We don't to waste time on this conversion / w.Write call when guests don't have sessions\n\t\t// TODO: Implement this properly\n\t\tif c.guestOnly && base == \"StringToBytes(\"+con.RootHolder+\".CurrentUser.Session/*,tmp*/))\" {\n\t\t\treturn\n\t\t}\n\tcase reflect.Int8, reflect.Int16, reflect.Int32:\n\t\tc.importMap[\"strconv\"] = \"strconv\"\n\t\tbase = \"StringToBytes(strconv.FormatInt(int64(\" + varname + \"), 10)/*,tmp*/)\"\n\tcase reflect.Int64:\n\t\tc.importMap[\"strconv\"] = \"strconv\"\n\t\tbase = \"StringToBytes(strconv.FormatInt(\" + varname + \", 10)/*,tmp*/)\"\n\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32:\n\t\tc.importMap[\"strconv\"] = \"strconv\"\n\t\tbase = \"StringToBytes(strconv.FormatUint(uint64(\" + varname + \"), 10)/*,tmp*/)\"\n\tcase reflect.Uint64:\n\t\tc.importMap[\"strconv\"] = \"strconv\"\n\t\tbase = \"StringToBytes(strconv.FormatUint(\" + varname + \", 10)/*,tmp*/)\"\n\tcase reflect.Struct:\n\t\t// TODO: Avoid clashing with other packages which have structs named Time\n\t\tif val.Type().Name() == \"Time\" {\n\t\t\tbase = \"StringToBytes(\" + varname + \".String()/*,tmp*/)\"\n\t\t} else {\n\t\t\tif !val.IsValid() {\n\t\t\t\tpanic(assLines + varname + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t\t\t}\n\t\t\tc.logger.Println(\"Unknown Struct Name:\", varname)\n\t\t\tc.logger.Println(\"Unknown Struct:\", val.Type().Name())\n\t\t\tpanic(\"-- I don't know what this variable's type is o.o\\n\")\n\t\t}\n\tdefault:\n\t\tif !val.IsValid() {\n\t\t\tpanic(assLines + varname + \"^\\n\" + \"Invalid value. Maybe, it doesn't exist?\")\n\t\t}\n\t\tc.logger.Println(\"Unknown Variable Name:\", varname)\n\t\tc.logger.Println(\"Unknown Kind:\", val.Kind())\n\t\tc.logger.Println(\"Unknown Type:\", val.Type().Name())\n\t\tpanic(\"-- I don't know what this variable's type is o.o\\n\")\n\t}\n\tc.detail(\"base:\", base)\n\tif assLines == \"\" {\n\t\tcon.Push(\"varsub\", base)\n\t} else {\n\t\tcon.Push(\"lvarsub\", onEnd(assLines+base))\n\t}\n}\n\nfunc (c *CTemplateSet) compileSubTemplate(pcon CContext, node *parse.TemplateNode) {\n\tc.dumpCall(\"compileSubTemplate\", pcon, node)\n\tdefer c.retCall(\"compileSubTemplate\")\n\tc.detail(\"Template Node: \", node.Name)\n\n\tfname := strings.TrimSuffix(node.Name, filepath.Ext(node.Name))\n\tif c.themeName != \"\" {\n\t\t_, ok := c.perThemeTmpls[fname]\n\t\tif !ok {\n\t\t\tc.detail(\"fname not in c.perThemeTmpls\")\n\t\t\tc.detail(\"c.perThemeTmpls\", c.perThemeTmpls)\n\t\t}\n\t\tfname += \"_\" + c.themeName\n\t}\n\tif c.guestOnly {\n\t\tfname += \"_guest\"\n\t} else if c.memberOnly {\n\t\tfname += \"_member\"\n\t}\n\n\t_, ok := c.templateList[fname]\n\tif !ok {\n\t\t// TODO: Cascade errors back up the tree to the caller?\n\t\tcontent, err := c.loadTemplate(c.fileDir, node.Name)\n\t\tif err != nil {\n\t\t\tc.logger.Fatal(err)\n\t\t}\n\n\t\t//tree := parse.New(node.Name, c.funcMap)\n\t\t//treeSet := make(map[string]*parse.Tree)\n\t\ttreeSet, err := parse.Parse(node.Name, content, \"{{\", \"}}\", c.funcMap)\n\t\tif err != nil {\n\t\t\tc.logger.Fatal(err)\n\t\t}\n\t\tc.detailf(\"treeSet: %+v\\n\", treeSet)\n\n\t\tfor nname, tree := range treeSet {\n\t\t\tif node.Name == nname {\n\t\t\t\tc.templateList[fname] = tree\n\t\t\t} else {\n\t\t\t\tif !strings.HasPrefix(nname, \".html\") {\n\t\t\t\t\tc.templateList[nname] = tree\n\t\t\t\t} else {\n\t\t\t\t\tc.templateList[strings.TrimSuffix(nname, \".html\")] = tree\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tc.detailf(\"c.templateList: %+v\\n\", c.templateList)\n\t}\n\n\tcon := pcon\n\tcon.VarHolder = \"tmpl_\" + fname + \"_vars\"\n\tcon.TemplateName = fname\n\tif node.Pipe != nil {\n\t\tfor _, cmd := range node.Pipe.Cmds {\n\t\t\tswitch p := cmd.Args[0].(type) {\n\t\t\tcase *parse.FieldNode:\n\t\t\t\t// TODO: Incomplete but it should cover the basics\n\t\t\t\tcur := pcon.HoldReflect\n\t\t\t\tvar varBit string\n\t\t\t\tif cur.Kind() == reflect.Interface {\n\t\t\t\t\tcur = cur.Elem()\n\t\t\t\t\tvarBit += \".(\" + cur.Type().Name() + \")\"\n\t\t\t\t}\n\n\t\t\t\tfor _, id := range p.Ident {\n\t\t\t\t\tc.detail(\"Data Kind:\", cur.Kind().String())\n\t\t\t\t\tc.detail(\"Field Bit:\", id)\n\t\t\t\t\tcur = c.skipStructPointers(cur, id)\n\t\t\t\t\tc.checkIfValid(cur, pcon.VarHolder, pcon.HoldReflect, varBit, false)\n\n\t\t\t\t\tif cur.Kind() != reflect.Interface {\n\t\t\t\t\t\tcur = cur.FieldByName(id)\n\t\t\t\t\t\tvarBit += \".\" + id\n\t\t\t\t\t}\n\n\t\t\t\t\t// TODO: Handle deeply nested pointers mixed with interfaces mixed with pointers better\n\t\t\t\t\tif cur.Kind() == reflect.Interface {\n\t\t\t\t\t\tcur = cur.Elem()\n\t\t\t\t\t\tvarBit += \".(\"\n\t\t\t\t\t\t// TODO: Surely, there's a better way of doing this?\n\t\t\t\t\t\tif cur.Type().PkgPath() != \"main\" && cur.Type().PkgPath() != \"\" {\n\t\t\t\t\t\t\tc.importMap[\"html/template\"] = \"html/template\"\n\t\t\t\t\t\t\tvarBit += strings.TrimPrefix(cur.Type().PkgPath(), \"html/\") + \".\"\n\t\t\t\t\t\t}\n\t\t\t\t\t\tvarBit += cur.Type().Name() + \")\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcon.VarHolder = pcon.VarHolder + varBit\n\t\t\t\tcon.HoldReflect = cur\n\t\t\tcase *parse.StringNode:\n\t\t\t\t//con.VarHolder = pcon.VarHolder\n\t\t\t\t//con.HoldReflect = pcon.HoldReflect\n\t\t\t\tcon.VarHolder = p.Quoted\n\t\t\t\tcon.HoldReflect = reflect.ValueOf(p.Quoted)\n\t\t\tcase *parse.DotNode:\n\t\t\t\tcon.VarHolder = pcon.VarHolder\n\t\t\t\tcon.HoldReflect = pcon.HoldReflect\n\t\t\tcase *parse.NilNode:\n\t\t\t\tpanic(\"Nil is not a command x.x\")\n\t\t\tdefault:\n\t\t\t\tc.critical(\"Unknown Param Type:\", p)\n\t\t\t\tpvar := reflect.ValueOf(p)\n\t\t\t\tc.critical(\"param kind:\", pvar.Kind().String())\n\t\t\t\tc.critical(\"param type:\", pvar.Type().Name())\n\t\t\t\tif pvar.Kind() == reflect.Ptr {\n\t\t\t\t\tc.critical(\"Looping over pointer\")\n\t\t\t\t\tfor pvar.Kind() == reflect.Ptr {\n\t\t\t\t\t\tpvar = pvar.Elem()\n\t\t\t\t\t}\n\t\t\t\t\tc.critical(\"concrete kind:\", pvar.Kind().String())\n\t\t\t\t\tc.critical(\"concrete type:\", pvar.Type().Name())\n\t\t\t\t}\n\t\t\t\tpanic(\"\")\n\t\t\t}\n\t\t}\n\t}\n\n\t//c.templateList[fname] = tree\n\tsubtree := c.templateList[fname]\n\tc.detail(\"subtree.Root\", subtree.Root)\n\tc.localVars[fname] = make(map[string]VarItemReflect)\n\tc.localVars[fname][\".\"] = VarItemReflect{\".\", con.VarHolder, con.HoldReflect}\n\tc.fragmentCursor[fname] = 0\n\n\tvar startBit, endBit string\n\tif con.LoopDepth != 0 {\n\t\tstartBit = \"{\\n\"\n\t\tendBit = \"}\\n\"\n\t}\n\tcon.StartTemplate(startBit)\n\tc.rootIterate(subtree, con)\n\tcon.EndTemplate(endBit)\n\t//c.templateFragmentCount[fname] = c.fragmentCursor[fname] + 1\n\tif _, ok := c.fragOnce[fname]; !ok {\n\t\tc.fragOnce[fname] = true\n\t}\n\n\t// map[string]map[string]bool\n\tc.detail(\"overridenTrack loop\")\n\tc.detail(\"fname:\", fname)\n\tfor themeName, track := range c.overridenTrack {\n\t\tc.detail(\"themeName:\", themeName)\n\t\tc.detailf(\"track: %+v\\n\", track)\n\t\tcroot, ok := c.overridenRoots[themeName]\n\t\tif !ok {\n\t\t\tcroot = make(map[string]bool)\n\t\t\tc.overridenRoots[themeName] = croot\n\t\t}\n\t\tc.detailf(\"croot: %+v\\n\", croot)\n\t\tfor tmplName, _ := range track {\n\t\t\tcname := tmplName\n\t\t\tif c.guestOnly {\n\t\t\t\tcname += \"_guest\"\n\t\t\t} else if c.memberOnly {\n\t\t\t\tcname += \"_member\"\n\t\t\t}\n\t\t\tc.detail(\"cname:\", cname)\n\t\t\tif fname == cname {\n\t\t\t\tc.detail(\"match\")\n\t\t\t\tcroot[strings.TrimSuffix(strings.TrimSuffix(con.RootTemplateName, \"_guest\"), \"_member\")] = true\n\t\t\t} else {\n\t\t\t\tc.detail(\"no match\")\n\t\t\t}\n\t\t}\n\t}\n\tc.detailf(\"c.overridenRoots: %+v\\n\", c.overridenRoots)\n}\n\nfunc (c *CTemplateSet) loadTemplate(fileDir, name string) (content string, err error) {\n\tc.dumpCall(\"loadTemplate\", fileDir, name)\n\tc.detail(\"c.themeName:\", c.themeName)\n\tif c.themeName != \"\" {\n\t\tt := \"./themes/\" + c.themeName + \"/overrides/\" + name\n\t\tc.detail(\"per-theme override:\", true)\n\t\tres, err := ioutil.ReadFile(t)\n\t\tif err == nil {\n\t\t\tcontent = string(res)\n\t\t\tif c.config.Minify {\n\t\t\t\tcontent = Minify(content)\n\t\t\t}\n\t\t\treturn content, nil\n\t\t}\n\t\tc.detail(\"override err:\", err)\n\t}\n\n\tres, err := ioutil.ReadFile(c.fileDir + \"overrides/\" + name)\n\tif err != nil {\n\t\tc.detail(\"override path:\", c.fileDir+\"overrides/\"+name)\n\t\tc.detail(\"override err:\", err)\n\t\tres, err = ioutil.ReadFile(c.fileDir + name)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\tcontent = string(res)\n\tif c.config.Minify {\n\t\tcontent = Minify(content)\n\t}\n\treturn content, nil\n}\n\nfunc (c *CTemplateSet) afterTemplate(con CContext, startIndex int /*, svmap map[string]int*/) {\n\tc.dumpCall(\"afterTemplate\", con, startIndex)\n\tdefer c.retCall(\"afterTemplate\")\n\n\tloopDepth := 0\n\tifNilDepth := 0\n\tvar outBuf = *con.OutBuf\n\tvarcounts := make(map[string]int)\n\tloopStart := startIndex\n\totype := outBuf[startIndex].Type\n\tif otype == \"startloop\" && (len(outBuf) > startIndex+1) {\n\t\tloopStart++\n\t}\n\tif otype == \"startif\" && (len(outBuf) > startIndex+1) {\n\t\tloopStart++\n\t}\n\n\t// Exclude varsubs within loops for now\nOLoop:\n\tfor i := loopStart; i < len(outBuf); i++ {\n\t\titem := outBuf[i]\n\t\tc.detail(\"item:\", item)\n\t\tswitch item.Type {\n\t\tcase \"startloop\":\n\t\t\tloopDepth++\n\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\tcase \"endloop\":\n\t\t\tloopDepth--\n\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\t\tif loopDepth == -1 {\n\t\t\t\tbreak OLoop\n\t\t\t}\n\t\tcase \"startif\":\n\t\t\tif item.Extra.(bool) == true {\n\t\t\t\tifNilDepth++\n\t\t\t}\n\t\tcase \"endif\":\n\t\t\titem2 := outBuf[item.Extra.(int)]\n\t\t\tif item2.Extra.(bool) == true {\n\t\t\t\tifNilDepth--\n\t\t\t}\n\t\t\tif ifNilDepth == -1 {\n\t\t\t\tbreak OLoop\n\t\t\t}\n\t\tcase \"varsub\":\n\t\t\tif loopDepth == 0 && ifNilDepth == 0 {\n\t\t\t\tcount := varcounts[item.Body]\n\t\t\t\tvarcounts[item.Body] = count + 1\n\t\t\t\tc.detail(\"count \" + strconv.Itoa(count) + \" for \" + item.Body)\n\t\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar varstr string\n\tvar i int\n\tvarmap := make(map[string]int)\n\t/*for svkey, sventry := range svmap {\n\t\tvarmap[svkey] = sventry\n\t}*/\n\tfor name, count := range varcounts {\n\t\tif count > 1 {\n\t\t\tvarstr += \"var c_v_\" + strconv.Itoa(i) + \"=\" + name + \"\\n\"\n\t\t\tvarmap[name] = i\n\t\t\ti++\n\t\t}\n\t}\n\n\t// Exclude varsubs within loops for now\n\tloopDepth = 0\n\tifNilDepth = 0\nOOLoop:\n\tfor i := loopStart; i < len(outBuf); i++ {\n\t\titem := outBuf[i]\n\t\tswitch item.Type {\n\t\tcase \"startloop\":\n\t\t\tloopDepth++\n\t\tcase \"endloop\":\n\t\t\tloopDepth--\n\t\t\tif loopDepth == -1 {\n\t\t\t\tbreak OOLoop\n\t\t\t} //con.Push(\"startif\", \"if \"+varname+\" {\\n\")\n\t\tcase \"startif\":\n\t\t\tif item.Extra.(bool) == true {\n\t\t\t\tifNilDepth++\n\t\t\t}\n\t\tcase \"endif\":\n\t\t\titem2 := outBuf[item.Extra.(int)]\n\t\t\tif item2.Extra.(bool) == true {\n\t\t\t\tifNilDepth--\n\t\t\t}\n\t\t\tif ifNilDepth == -1 {\n\t\t\t\tbreak OOLoop\n\t\t\t}\n\t\tcase \"varsub\":\n\t\t\tif loopDepth == 0 && ifNilDepth == 0 {\n\t\t\t\tindex, ok := varmap[item.Body]\n\t\t\t\tif ok {\n\t\t\t\t\titem.Body = \"c_v_\" + strconv.Itoa(index)\n\t\t\t\t\titem.Type = \"cvarsub\"\n\t\t\t\t\toutBuf[i] = item\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tcon.AttachVars(varstr, startIndex)\n}\n\nconst (\n\tATTmpl = iota\n\tATLoop\n\tATIfPtr\n)\n\nfunc (c *CTemplateSet) afterTemplateV2(con CContext, startIndex int /*, typ int*/, svmap map[string]int) {\n\tc.dumpCall(\"afterTemplateV2\", con, startIndex)\n\tdefer c.retCall(\"afterTemplateV2\")\n\n\tloopDepth, ifNilDepth := 0, 0\n\tvar outBuf = *con.OutBuf\n\tvarcounts := make(map[string]int)\n\tloopStart := startIndex\n\totype := outBuf[startIndex].Type\n\tif otype == \"startloop\" && (len(outBuf) > startIndex+1) {\n\t\tloopStart++\n\t}\n\tif otype == \"startif\" && (len(outBuf) > startIndex+1) {\n\t\tloopStart++\n\t}\n\n\t// Exclude varsubs within loops for now\nOLoop:\n\tfor i := loopStart; i < len(outBuf); i++ {\n\t\titem := outBuf[i]\n\t\tc.detail(\"item:\", item)\n\t\tswitch item.Type {\n\t\tcase \"startloop\":\n\t\t\tloopDepth++\n\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\tcase \"endloop\":\n\t\t\tloopDepth--\n\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\t\tif loopDepth == -1 {\n\t\t\t\tbreak OLoop\n\t\t\t}\n\t\tcase \"startif\":\n\t\t\tif item.Extra.(bool) == true {\n\t\t\t\tifNilDepth++\n\t\t\t}\n\t\tcase \"endif\":\n\t\t\titem2 := outBuf[item.Extra.(int)]\n\t\t\tif item2.Extra.(bool) == true {\n\t\t\t\tifNilDepth--\n\t\t\t}\n\t\t\tif ifNilDepth == -1 {\n\t\t\t\tbreak OLoop\n\t\t\t}\n\t\tcase \"varsub\":\n\t\t\tif loopDepth == 0 && ifNilDepth == 0 {\n\t\t\t\tcount := varcounts[item.Body]\n\t\t\t\tvarcounts[item.Body] = count + 1\n\t\t\t\tc.detail(\"count \" + strconv.Itoa(count) + \" for \" + item.Body)\n\t\t\t\tc.detail(\"loopDepth:\", loopDepth)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar varstr string\n\tvar i int\n\tvarmap := make(map[string]int)\n\t/*for svkey, sventry := range svmap {\n\t\tvarmap[svkey] = sventry\n\t}*/\n\tfor name, count := range varcounts {\n\t\tif count > 1 {\n\t\t\tvarstr += \"var c_v_\" + strconv.Itoa(i) + \"=\" + name + \"\\n\"\n\t\t\tvarmap[name] = i\n\t\t\ti++\n\t\t}\n\t}\n\n\t// Exclude varsubs within loops for now\n\tloopDepth, ifNilDepth = 0, 0\nOOLoop:\n\tfor i := loopStart; i < len(outBuf); i++ {\n\t\titem := outBuf[i]\n\t\tswitch item.Type {\n\t\tcase \"startloop\":\n\t\t\tloopDepth++\n\t\tcase \"endloop\":\n\t\t\tloopDepth--\n\t\t\tif loopDepth == -1 {\n\t\t\t\tbreak OOLoop\n\t\t\t} //con.Push(\"startif\", \"if \"+varname+\" {\\n\")\n\t\tcase \"startif\":\n\t\t\tif item.Extra.(bool) == true {\n\t\t\t\tifNilDepth++\n\t\t\t}\n\t\tcase \"endif\":\n\t\t\titem2 := outBuf[item.Extra.(int)]\n\t\t\tif item2.Extra.(bool) == true {\n\t\t\t\tifNilDepth--\n\t\t\t}\n\t\t\tif ifNilDepth == -1 {\n\t\t\t\tbreak OOLoop\n\t\t\t}\n\t\tcase \"varsub\":\n\t\t\tif loopDepth == 0 && ifNilDepth == 0 {\n\t\t\t\tindex, ok := varmap[item.Body]\n\t\t\t\tif ok {\n\t\t\t\t\titem.Body = \"c_v_\" + strconv.Itoa(index)\n\t\t\t\t\titem.Type = \"cvarsub\"\n\t\t\t\t\toutBuf[i] = item\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tcon.AttachVars(varstr, startIndex)\n}\n\n// TODO: Should we rethink the way the log methods work or their names?\n\nfunc (c *CTemplateSet) detail(args ...interface{}) {\n\tif c.config.SuperDebug {\n\t\tc.logger.Println(args...)\n\t}\n}\n\nfunc (c *CTemplateSet) detailf(left string, args ...interface{}) {\n\tif c.config.SuperDebug {\n\t\tc.logger.Printf(left, args...)\n\t}\n}\n\nfunc (c *CTemplateSet) error(args ...interface{}) {\n\tif c.config.Debug {\n\t\tc.logger.Println(args...)\n\t}\n}\n\nfunc (c *CTemplateSet) critical(args ...interface{}) {\n\tc.logger.Println(args...)\n}\n"
  },
  {
    "path": "common/thaw.go",
    "content": "package common\n\nimport (\n\t\"sync/atomic\"\n)\n\nvar TopicListThaw ThawInt\n\ntype ThawInt interface {\n\tThawed() bool\n\tThaw()\n\n\tTick() error\n}\n\ntype SingleServerThaw struct {\n\tDefaultThaw\n}\n\nfunc NewSingleServerThaw() *SingleServerThaw {\n\tt := &SingleServerThaw{}\n\tif Config.ServerCount == 1 {\n\t\tTasks.Sec.Add(t.Tick)\n\t}\n\treturn t\n}\n\nfunc (t *SingleServerThaw) Thawed() bool {\n\tif Config.ServerCount == 1 {\n\t\treturn t.DefaultThaw.Thawed()\n\t}\n\treturn true\n}\n\nfunc (t *SingleServerThaw) Thaw() {\n\tif Config.ServerCount == 1 {\n\t\tt.DefaultThaw.Thaw()\n\t}\n}\n\ntype TestThaw struct {\n}\n\nfunc NewTestThaw() *TestThaw {\n\treturn &TestThaw{}\n}\nfunc (t *TestThaw) Thawed() bool {\n\treturn true\n}\nfunc (t *TestThaw) Thaw() {\n}\nfunc (t *TestThaw) Tick() error {\n\treturn nil\n}\n\ntype DefaultThaw struct {\n\tthawed int64\n}\n\nfunc NewDefaultThaw() *DefaultThaw {\n\tt := &DefaultThaw{}\n\tTasks.Sec.Add(t.Tick)\n\treturn t\n}\n\n// Decrement the thawed counter once a second until it goes cold\nfunc (t *DefaultThaw) Tick() error {\n\tprior := t.thawed\n\tif prior > 0 {\n\t\tatomic.StoreInt64(&t.thawed, prior-1)\n\t}\n\treturn nil\n}\n\nfunc (t *DefaultThaw) Thawed() bool {\n\treturn t.thawed > 0\n}\n\nfunc (t *DefaultThaw) Thaw() {\n\tatomic.StoreInt64(&t.thawed, 3)\n}\n"
  },
  {
    "path": "common/theme.go",
    "content": "/* Copyright Azareal 2016 - 2019 */\npackage common\n\nimport (\n\t\"bytes\"\n\t\"crypto/sha256\"\n\t\"database/sql\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"errors\"\n\thtmpl \"html/template\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"mime\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"text/template\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nvar ErrNoDefaultTheme = errors.New(\"The default theme isn't registered in the system\")\nvar ErrBadDefaultTemplate = errors.New(\"The template you tried to load doesn't exist in the interpreted pool.\")\n\ntype Theme struct {\n\tPath string // Redirect this file to another folder\n\n\tName           string\n\tFriendlyName   string\n\tVersion        string\n\tCreator        string\n\tFullImage      string\n\tMobileFriendly bool\n\tDisabled       bool\n\tHideFromThemes bool\n\tBgAvatars      bool // For profiles, at the moment\n\tGridLists      bool // User Manager\n\tForkOf         string\n\tTag            string\n\tURL            string\n\tDocks          []string // Allowed Values: leftSidebar, rightSidebar, footer\n\tDocksID        []int    // Integer versions of Docks to try to get a speed boost in BuildWidget()\n\tSettings       map[string]ThemeSetting\n\tIntTmplHandle  *htmpl.Template\n\t// TODO: Do we really need both OverridenTemplates AND OverridenMap?\n\tOverridenTemplates []string\n\tOverridenMap       map[string]bool\n\tTemplates          []TemplateMapping\n\tTemplatesMap       map[string]string\n\tTmplPtr            map[string]interface{}\n\tResources          []ThemeResource\n\tResourceTemplates  *template.Template\n\n\t// Dock intercepters\n\t// TODO: Implement this\n\tMapTmplToDock map[string]ThemeMapTmplToDock // map[dockName]data\n\tRunOnDock     func(string) string           //(dock string) (sbody string)\n\tRunOnDockID   func(int) string              //(dock int) (sbody string)\n\n\t// This variable should only be set and unset by the system, not the theme meta file\n\t// TODO: Should we phase out Active and make the default theme store the primary source of truth?\n\tActive bool\n}\n\ntype ThemeSetting struct {\n\tFriendlyName string\n\tOptions      []string\n}\n\ntype TemplateMapping struct {\n\tName   string\n\tSource string\n\t//When string\n}\n\nconst (\n\tResTypeUnknown = iota\n\tResTypeSheet\n\tResTypeScript\n)\nconst (\n\tLocUnknown = iota\n\tLocGlobal\n\tLocFront\n\tLocPanel\n)\n\ntype ThemeResource struct {\n\tName     string\n\tType     int // 0 = unknown, 1 = sheet, 2 = script\n\tLocation string\n\tLocID    int\n\tLoggedin bool // Only serve this resource to logged in users\n\tAsync    bool\n}\n\ntype ThemeMapTmplToDock struct {\n\t//Name string\n\tFile string\n}\n\n// TODO: It might be unsafe to call the template parsing functions with fsnotify, do something more concurrent\nfunc (t *Theme) LoadStaticFiles() error {\n\tt.ResourceTemplates = template.New(\"\")\n\tfmap := make(map[string]interface{})\n\tfmap[\"lang\"] = func(phraseNameInt, tmplInt interface{}) interface{} {\n\t\tphraseName, ok := phraseNameInt.(string)\n\t\tif !ok {\n\t\t\tpanic(\"phraseNameInt is not a string\")\n\t\t}\n\t\ttmpl, ok := tmplInt.(CSSData)\n\t\tif !ok {\n\t\t\tpanic(\"tmplInt is not a CSSData\")\n\t\t}\n\t\tphrase, ok := tmpl.Phrases[phraseName]\n\t\tif !ok {\n\t\t\t// TODO: XSS? Only server admins should have access to theme files anyway, but think about it\n\t\t\treturn \"{lang.\" + phraseName + \"}\"\n\t\t}\n\t\treturn phrase\n\t}\n\tfmap[\"toArr\"] = func(args ...interface{}) []interface{} {\n\t\treturn args\n\t}\n\tfmap[\"concat\"] = func(args ...interface{}) interface{} {\n\t\tvar out string\n\t\tfor _, arg := range args {\n\t\t\tout += arg.(string)\n\t\t}\n\t\treturn out\n\t}\n\tt.ResourceTemplates.Funcs(fmap)\n\t// TODO: Minify these\n\t//template.Must(t.ResourceTemplates.ParseGlob(\"./themes/\" + t.Name + \"/public/*.css\"))\n\tfnames, err := filepath.Glob(\"./themes/\" + t.Name + \"/public/*.css\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, fname := range fnames {\n\t\tb, err := ioutil.ReadFile(fname)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t//b := []byte(\"trolololol\")\n\t\t//b = bytes.ReplaceAll(b, []byte{10}, []byte(\"\"))\n\t\t//b = bytes.Replace(b, []byte(\"\\n\\n\"), []byte(\"\"), -1)\n\t\t//b = bytes.Replace(b, []byte(\"}\\n.\"), []byte(\"}.\"), -1)\n\t\t//b = bytes.Replace(b, []byte(\"}\\n:\"), []byte(\"}:\"), -1)\n\t\ts := func() string {\n\t\t\ts := string(b)\n\t\t\trep := func(from, to string) {\n\t\t\t\ts = strings.Replace(s, from, to, -1)\n\t\t\t}\n\t\t\trep(\"\\r\", \"\")\n\t\t\trep(\"}\\n\", \"}\")\n\t\t\trep(\"\\n{\", \"{\")\n\t\t\trep(\"\\n\", \"\")\n\t\t\trep(`\n`, \"\")\n\t\t\trep(\": {{\", \":{{\")\n\t\t\trep(\"display: \", \"display:\")\n\t\t\trep(\"float: \", \"float:\")\n\t\t\trep(\"-left: \", \"-left:\")\n\t\t\trep(\"-right: \", \"-right:\")\n\t\t\trep(\"-top: \", \"-top:\")\n\t\t\trep(\"-bottom: \", \"-bottom:\")\n\t\t\trep(\"border: \", \"border:\")\n\t\t\trep(\"radius: \", \"radius:\")\n\t\t\trep(\"content: \", \"content:\")\n\t\t\trep(\"width: \", \"width:\")\n\t\t\trep(\"padding: \", \"padding:\")\n\t\t\trep(\"-size: \", \"-size:\")\n\t\t\treturn s\n\t\t}()\n\t\tname := filepath.Base(fname)\n\t\tt := t.ResourceTemplates\n\t\tvar tmpl *template.Template\n\t\t/*if name == t.Name() {\n\t\t\ttmpl = t\n\t\t} else {*/\n\t\ttmpl = t.New(name)\n\t\t//}\n\t\t_, err = tmpl.Parse(s)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// It should be safe for us to load the files for all the themes in memory, as-long as the admin hasn't setup a ridiculous number of themes\n\treturn t.AddThemeStaticFiles()\n}\n\nfunc (t *Theme) AddThemeStaticFiles() error {\n\tphraseMap := p.GetTmplPhrases()\n\t// TODO: Use a function instead of a closure to make this more testable? What about a function call inside the closure to take the theme variable into account?\n\treturn filepath.Walk(\"./themes/\"+t.Name+\"/public\", func(path string, f os.FileInfo, err error) error {\n\t\tDebugLog(\"Attempting to add static file '\" + path + \"' for default theme '\" + t.Name + \"'\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif f.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tpath = strings.Replace(path, \"\\\\\", \"/\", -1)\n\t\tdata, err := ioutil.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\text := filepath.Ext(path)\n\t\tif ext == \".js\" {\n\t\t\tdata = bytes.Replace(data, []byte(\"\\r\"), []byte(\"\"), -1)\n\t\t}\n\t\tif ext == \".css\" && len(data) != 0 {\n\t\t\tvar b bytes.Buffer\n\t\t\tpieces := strings.Split(path, \"/\")\n\t\t\tfilename := pieces[len(pieces)-1]\n\t\t\t// TODO: Prepare resource templates for each loaded langpack?\n\t\t\terr = t.ResourceTemplates.ExecuteTemplate(&b, filename, CSSData{Phrases: phraseMap})\n\t\t\tif err != nil {\n\t\t\t\tlog.Print(\"Failed in adding static file '\" + path + \"' for default theme '\" + t.Name + \"'\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdata = b.Bytes()\n\t\t\trep := func(from, to string) {\n\t\t\t\tdata = bytes.Replace(data, []byte(from), []byte(to), -1)\n\t\t\t}\n\t\t\trep(\"\\t\", \"\")\n\t\t\t//rep(\"\\n\\n\", \"\")\n\t\t\trep(\"\\n\", \"\")\n\t\t\trep(\"\\n\", \"\")\n\t\t\trep(`\n`, \"\")\n\t\t\trep(\"}\\n.\", \"}.\")\n\t\t\trep(\"}\\n:\", \"}:\")\n\t\t\trep(\": #\", \":#\")\n\t\t\trep(\" {\", \"{\")\n\t\t\trep(\"{\\n\", \"{\")\n\t\t\trep(\",\\n\", \",\")\n\t\t\trep(\";\\n\", \";\")\n\t\t\trep(\";\\n}\", \";}\")\n\t\t\trep(\": 0px;\", \":0;\")\n\t\t\trep(\"; }\", \";}\")\n\t\t\trep(\", #\", \",#\")\n\t\t}\n\n\t\tpath = strings.TrimPrefix(path, \"themes/\"+t.Name+\"/public\")\n\n\t\tbrData, err := CompressBytesBrotli(data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Don't use Brotli if we get meagre gains from it as it takes longer to process the responses\n\t\tif len(brData) >= (len(data) + 130) {\n\t\t\tbrData = nil\n\t\t} else {\n\t\t\tdiff := len(data) - len(brData)\n\t\t\tif diff <= len(data)/100 {\n\t\t\t\tbrData = nil\n\t\t\t}\n\t\t}\n\n\t\tgzipData, err := CompressBytesGzip(data)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Don't use Gzip if we get meagre gains from it as it takes longer to process the responses\n\t\tif len(gzipData) >= (len(data) + 150) {\n\t\t\tgzipData = nil\n\t\t} else {\n\t\t\tdiff := len(data) - len(gzipData)\n\t\t\tif diff <= len(data)/100 {\n\t\t\t\tgzipData = nil\n\t\t\t}\n\t\t}\n\n\t\t// Get a checksum for CSPs and cache busting\n\t\thasher := sha256.New()\n\t\thasher.Write(data)\n\t\tsum := hasher.Sum(nil)\n\t\tchecksum := hex.EncodeToString(sum)\n\t\tintegrity := base64.StdEncoding.EncodeToString(sum)\n\n\t\tStaticFiles.Set(StaticFiles.Prefix+t.Name+path, &SFile{data, gzipData, brData, checksum, integrity, StaticFiles.Prefix + t.Name + path + \"?h=\" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)})\n\n\t\tDebugLog(\"Added the '/\" + t.Name + path + \"' static file for theme \" + t.Name + \".\")\n\t\treturn nil\n\t})\n}\n\nfunc (t *Theme) MapTemplates() {\n\tif t.Templates != nil {\n\t\tfor _, themeTmpl := range t.Templates {\n\t\t\tif themeTmpl.Name == \"\" {\n\t\t\t\tLogError(errors.New(\"Invalid destination template name\"))\n\t\t\t}\n\t\t\tif themeTmpl.Source == \"\" {\n\t\t\t\tLogError(errors.New(\"Invalid source template name\"))\n\t\t\t}\n\n\t\t\t// `go generate` is one possibility for letting plugins inject custom page structs, but it would simply add another step of compilation. It might be simpler than the current build process from the perspective of the administrator?\n\n\t\t\tdestTmplPtr, ok := TmplPtrMap[themeTmpl.Name]\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsourceTmplPtr, ok := TmplPtrMap[themeTmpl.Source]\n\t\t\tif !ok {\n\t\t\t\tLogError(errors.New(\"The source template doesn't exist!\"))\n\t\t\t}\n\n\t\t\tdTmplPtr, ok := destTmplPtr.(*func(interface{}, io.Writer) error)\n\t\t\tif !ok {\n\t\t\t\tlog.Print(\"themeTmpl.Name: \", themeTmpl.Name)\n\t\t\t\tlog.Print(\"themeTmpl.Source: \", themeTmpl.Source)\n\t\t\t\tLogError(errors.New(\"Unknown destination template type!\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsTmplPtr, ok := sourceTmplPtr.(*func(interface{}, io.Writer) error)\n\t\t\tif !ok {\n\t\t\t\tLogError(errors.New(\"The source and destination templates are incompatible\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\toverridenTemplates[themeTmpl.Name] = true\n\t\t\t*dTmplPtr = *sTmplPtr\n\t\t}\n\t}\n}\n\nfunc (t *Theme) setActive(active bool) error {\n\tvar sink bool\n\terr := themeStmts.isDefault.QueryRow(t.Name).Scan(&sink)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn err\n\t}\n\n\thasTheme := err != sql.ErrNoRows\n\tif hasTheme {\n\t\t_, err = themeStmts.update.Exec(active, t.Name)\n\t} else {\n\t\t_, err = themeStmts.add.Exec(t.Name, active)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// TODO: Think about what we want to do for multi-server configurations\n\tlog.Printf(\"Setting theme '%s' as the default theme\", t.Name)\n\tt.Active = active\n\treturn nil\n}\n\nfunc UpdateDefaultTheme(t *Theme) error {\n\tChangeDefaultThemeMutex.Lock()\n\tdefer ChangeDefaultThemeMutex.Unlock()\n\n\terr := t.setActive(true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefaultTheme := DefaultThemeBox.Load().(string)\n\tdtheme, ok := Themes[defaultTheme]\n\tif !ok {\n\t\treturn ErrNoDefaultTheme\n\t}\n\terr = dtheme.setActive(false)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tDefaultThemeBox.Store(t.Name)\n\tResetTemplateOverrides()\n\tt.MapTemplates()\n\n\treturn nil\n}\n\nfunc (t Theme) HasDock(name string) bool {\n\tfor _, dock := range t.Docks {\n\t\tif dock == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (t Theme) BuildDock(dock string) (sbody string) {\n\trunOnDock := t.RunOnDock\n\tif runOnDock != nil {\n\t\treturn runOnDock(dock)\n\t}\n\treturn \"\"\n}\n\nfunc (t Theme) HasDockByID(id int) bool {\n\tfor _, dock := range t.DocksID {\n\t\tif dock == id {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (t Theme) BuildDockByID(id int) (sbody string) {\n\trunOnDock := t.RunOnDockID\n\tif runOnDock != nil {\n\t\treturn runOnDock(id)\n\t}\n\treturn \"\"\n}\n\ntype GzipResponseWriter struct {\n\tio.Writer\n\thttp.ResponseWriter\n}\n\nfunc (w GzipResponseWriter) Write(b []byte) (int, error) {\n\treturn w.Writer.Write(b)\n}\n\n// NEW method of doing theme templates to allow one user to have a different theme to another. Under construction.\n// TODO: Generate the type switch instead of writing it by hand\n// TODO: Cut the number of types in half\nfunc (t *Theme) RunTmpl(template string, pi interface{}, w io.Writer) error {\n\t// Unpack this to avoid an indirect call\n\tif gzw, ok := w.(GzipResponseWriter); ok {\n\t\tw = gzw.Writer\n\t\tgzw.Header().Set(\"Content-Type\", \"text/html;charset=utf-8\")\n\t}\n\n\tgetTmpl := t.GetTmpl(template)\n\tswitch tmplO := getTmpl.(type) {\n\tcase *func(interface{}, io.Writer) error:\n\t\tvar tmpl = *tmplO\n\t\treturn tmpl(pi, w)\n\tcase func(interface{}, io.Writer) error:\n\t\treturn tmplO(pi, w)\n\tcase nil, string:\n\t\t//fmt.Println(\"falling back to interpreted for \" + template)\n\t\tmapping, ok := t.TemplatesMap[template]\n\t\tif !ok {\n\t\t\tmapping = template\n\t\t}\n\t\tif t.IntTmplHandle.Lookup(mapping+\".html\") == nil {\n\t\t\treturn ErrBadDefaultTemplate\n\t\t}\n\t\treturn t.IntTmplHandle.ExecuteTemplate(w, mapping+\".html\", pi)\n\tdefault:\n\t\tlog.Print(\"theme \", t)\n\t\tlog.Print(\"template \", template)\n\t\tlog.Print(\"pi \", pi)\n\t\tlog.Print(\"tmplO \", tmplO)\n\t\tlog.Print(\"getTmpl \", getTmpl)\n\n\t\tvalueOf := reflect.ValueOf(tmplO)\n\t\tlog.Print(\"initial valueOf.Type()\", valueOf.Type())\n\t\tfor valueOf.Kind() == reflect.Interface || valueOf.Kind() == reflect.Ptr {\n\t\t\tvalueOf = valueOf.Elem()\n\t\t\tlog.Print(\"valueOf.Elem().Type() \", valueOf.Type())\n\t\t}\n\t\tlog.Print(\"deferenced valueOf.Type() \", valueOf.Type())\n\t\tlog.Print(\"valueOf.Kind() \", valueOf.Kind())\n\n\t\treturn errors.New(\"Unknown template type\")\n\t}\n}\n\n// GetTmpl attempts to get the template for a specific theme, otherwise it falls back on the default template pointer, which if absent will fallback onto the template interpreter\nfunc (t *Theme) GetTmpl(template string) interface{} {\n\t// TODO: Figure out why we're getting a nil pointer here when transpiled templates are disabled, I would have assumed that we would just fall back to !ok on this\n\t// Might have something to do with it being the theme's TmplPtr map, investigate.\n\ttmpl, ok := t.TmplPtr[template]\n\tif ok {\n\t\treturn tmpl\n\t}\n\ttmpl, ok = TmplPtrMap[template+\"_\"+t.Name]\n\tif ok {\n\t\treturn tmpl\n\t}\n\ttmpl, ok = TmplPtrMap[template]\n\tif ok {\n\t\treturn tmpl\n\t}\n\treturn template\n}\n"
  },
  {
    "path": "common/theme_list.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"html/template\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Something more thread-safe\ntype ThemeList map[string]*Theme\n\nvar Themes ThemeList = make(map[string]*Theme) // ? Refactor this into a store?\nvar DefaultThemeBox atomic.Value\nvar ChangeDefaultThemeMutex sync.Mutex\nvar ThemesSlice []*Theme\n\n// TODO: Fallback to a random theme if this doesn't exist, so admins can remove themes they don't use\n// TODO: Use this when the default theme doesn't exist\nvar fallbackTheme = \"cosora\"\nvar overridenTemplates = make(map[string]bool) // ? What is this used for?\n\ntype ThemeStmts struct {\n\tgetAll    *sql.Stmt\n\tisDefault *sql.Stmt\n\tupdate    *sql.Stmt\n\tadd       *sql.Stmt\n}\n\nvar themeStmts ThemeStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tt, cols := \"themes\", \"uname,default\"\n\t\tthemeStmts = ThemeStmts{\n\t\t\tgetAll:    acc.Select(t).Columns(cols).Prepare(),\n\t\t\tisDefault: acc.Select(t).Columns(\"default\").Where(\"uname=?\").Prepare(),\n\t\t\tupdate:    acc.Update(t).Set(\"default=?\").Where(\"uname=?\").Prepare(),\n\t\t\tadd:       acc.Insert(t).Columns(cols).Fields(\"?,?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc NewThemeList() (themes ThemeList, err error) {\n\tthemes = make(map[string]*Theme)\n\tthemeFiles, err := ioutil.ReadDir(\"./themes\")\n\tif err != nil {\n\t\treturn themes, err\n\t}\n\tif len(themeFiles) == 0 {\n\t\treturn themes, errors.New(\"You don't have any themes\")\n\t}\n\n\tvar lastTheme, defaultTheme string\n\tfor _, themeFile := range themeFiles {\n\t\tif !themeFile.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tthemeName := themeFile.Name()\n\t\tlog.Printf(\"Adding theme '%s'\", themeName)\n\t\tthemePath := \"./themes/\" + themeName\n\t\tthemeFile, err := ioutil.ReadFile(themePath + \"/theme.json\")\n\t\tif err != nil {\n\t\t\treturn themes, err\n\t\t}\n\n\t\tth := &Theme{}\n\t\terr = json.Unmarshal(themeFile, th)\n\t\tif err != nil {\n\t\t\treturn themes, err\n\t\t}\n\n\t\tif th.Name == \"\" {\n\t\t\treturn themes, errors.New(\"Theme \" + themePath + \" doesn't have a name set in theme.json\")\n\t\t}\n\t\tif th.Name == fallbackTheme {\n\t\t\tdefaultTheme = fallbackTheme\n\t\t}\n\t\tlastTheme = th.Name\n\n\t\t// TODO: Implement the static file part of this and fsnotify\n\t\tif th.Path != \"\" {\n\t\t\tlog.Print(\"Resolving redirect to \" + th.Path)\n\t\t\tthemeFile, err := ioutil.ReadFile(th.Path + \"/theme.json\")\n\t\t\tif err != nil {\n\t\t\t\treturn themes, err\n\t\t\t}\n\t\t\tth = &Theme{Path: th.Path}\n\t\t\terr = json.Unmarshal(themeFile, th)\n\t\t\tif err != nil {\n\t\t\t\treturn themes, err\n\t\t\t}\n\t\t} else {\n\t\t\tth.Path = themePath\n\t\t}\n\n\t\tth.Active = false // Set this to false, just in case someone explicitly overrode this value in the JSON file\n\n\t\t// TODO: Let the theme specify where it's resources are via the JSON file?\n\t\t// TODO: Let the theme inherit CSS from another theme?\n\t\t// ? - This might not be too helpful, as it only searches for /public/ and not if /public/ is empty. Still, it might help some people with a slightly less cryptic error\n\t\tlog.Print(th.Path + \"/public/\")\n\t\t_, err = os.Stat(th.Path + \"/public/\")\n\t\tif err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\treturn themes, errors.New(\"We couldn't find this theme's resources. E.g. the /public/ folder.\")\n\t\t\t} else {\n\t\t\t\tlog.Print(\"We weren't able to access this theme's resources due to a permissions issue or some other problem\")\n\t\t\t\treturn themes, err\n\t\t\t}\n\t\t}\n\n\t\tif th.FullImage != \"\" {\n\t\t\tDebugLog(\"Adding theme image\")\n\t\t\terr = StaticFiles.Add(th.Path+\"/\"+th.FullImage, themePath)\n\t\t\tif err != nil {\n\t\t\t\treturn themes, err\n\t\t\t}\n\t\t}\n\n\t\tth.TemplatesMap = make(map[string]string)\n\t\tth.TmplPtr = make(map[string]interface{})\n\t\tif th.Templates != nil {\n\t\t\tfor _, themeTmpl := range th.Templates {\n\t\t\t\tth.TemplatesMap[themeTmpl.Name] = themeTmpl.Source\n\t\t\t\tth.TmplPtr[themeTmpl.Name] = TmplPtrMap[\"o_\"+themeTmpl.Source]\n\t\t\t}\n\t\t}\n\n\t\tth.IntTmplHandle = DefaultTemplates\n\t\toverrides, err := ioutil.ReadDir(th.Path + \"/overrides/\")\n\t\tif err != nil && !os.IsNotExist(err) {\n\t\t\treturn themes, err\n\t\t}\n\t\tif len(overrides) > 0 {\n\t\t\toverCount := 0\n\t\t\tth.OverridenMap = make(map[string]bool)\n\t\t\tfor _, override := range overrides {\n\t\t\t\tif override.IsDir() {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\text := filepath.Ext(themePath + \"/overrides/\" + override.Name())\n\t\t\t\tlog.Print(\"attempting to add \" + themePath + \"/overrides/\" + override.Name())\n\t\t\t\tif ext != \".html\" {\n\t\t\t\t\tlog.Print(\"not a html file\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\toverCount++\n\t\t\t\tnosuf := strings.TrimSuffix(override.Name(), ext)\n\t\t\t\tth.OverridenTemplates = append(th.OverridenTemplates, nosuf)\n\t\t\t\tth.OverridenMap[nosuf] = true\n\t\t\t\t//th.TmplPtr[nosuf] = TmplPtrMap[\"o_\"+nosuf]\n\t\t\t\tlog.Print(\"succeeded\")\n\t\t\t}\n\n\t\t\tlocalTmpls := template.New(\"\")\n\t\t\terr = loadTemplates(localTmpls, th.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn themes, err\n\t\t\t}\n\t\t\tth.IntTmplHandle = localTmpls\n\t\t\tlog.Printf(\"theme.OverridenTemplates: %+v\\n\", th.OverridenTemplates)\n\t\t\tlog.Printf(\"theme.IntTmplHandle: %+v\\n\", th.IntTmplHandle)\n\t\t} else {\n\t\t\tlog.Print(\"no overrides for \" + th.Name)\n\t\t}\n\n\t\tfor i, res := range th.Resources {\n\t\t\text := filepath.Ext(res.Name)\n\t\t\tswitch ext {\n\t\t\tcase \".css\":\n\t\t\t\tres.Type = ResTypeSheet\n\t\t\tcase \".js\":\n\t\t\t\tres.Type = ResTypeScript\n\t\t\t}\n\t\t\tswitch res.Location {\n\t\t\tcase \"global\":\n\t\t\t\tres.LocID = LocGlobal\n\t\t\tcase \"frontend\":\n\t\t\t\tres.LocID = LocFront\n\t\t\tcase \"panel\":\n\t\t\t\tres.LocID = LocPanel\n\t\t\t}\n\t\t\tth.Resources[i] = res\n\t\t}\n\n\t\tfor _, dock := range th.Docks {\n\t\t\tif id, ok := DockToID[dock]; ok {\n\t\t\t\tth.DocksID = append(th.DocksID, id)\n\t\t\t}\n\t\t}\n\n\t\t// TODO: Bind the built template, or an interpreted one for any dock overrides this theme has\n\n\t\tthemes[th.Name] = th\n\t\tThemesSlice = append(ThemesSlice, th)\n\t}\n\tif defaultTheme == \"\" {\n\t\tdefaultTheme = lastTheme\n\t}\n\tDefaultThemeBox.Store(defaultTheme)\n\n\treturn themes, nil\n}\n\n// TODO: Make the initThemes and LoadThemes functions less confusing\n// ? - Delete themes which no longer exist in the themes folder from the database?\nfunc (t ThemeList) LoadActiveStatus() error {\n\tChangeDefaultThemeMutex.Lock()\n\tdefer ChangeDefaultThemeMutex.Unlock()\n\n\trows, e := themeStmts.getAll.Query()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\n\tvar uname string\n\tvar defaultThemeSwitch bool\n\tfor rows.Next() {\n\t\te = rows.Scan(&uname, &defaultThemeSwitch)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\t// Was the theme deleted at some point?\n\t\ttheme, ok := t[uname]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif defaultThemeSwitch {\n\t\t\tDebugLogf(\"Loading the default theme '%s'\", theme.Name)\n\t\t\ttheme.Active = true\n\t\t\tDefaultThemeBox.Store(theme.Name)\n\t\t\ttheme.MapTemplates()\n\t\t} else {\n\t\t\tDebugLogf(\"Loading the theme '%s'\", theme.Name)\n\t\t\ttheme.Active = false\n\t\t}\n\n\t\tt[uname] = theme\n\t}\n\treturn rows.Err()\n}\n\nfunc (t ThemeList) LoadStaticFiles() error {\n\tfor _, theme := range t {\n\t\tif e := theme.LoadStaticFiles(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ResetTemplateOverrides() {\n\tlog.Print(\"Resetting the template overrides\")\n\tfor name := range overridenTemplates {\n\t\tlog.Print(\"Resetting '\" + name + \"' template override\")\n\t\toriginPointer, ok := TmplPtrMap[\"o_\"+name]\n\t\tif !ok {\n\t\t\tlog.Print(\"The origin template doesn't exist!\")\n\t\t\treturn\n\t\t}\n\t\tdestTmplPtr, ok := TmplPtrMap[name]\n\t\tif !ok {\n\t\t\tlog.Print(\"The destination template doesn't exist!\")\n\t\t\treturn\n\t\t}\n\n\t\t// Not really a pointer, more of a function handle, an artifact from one of the earlier versions of themes.go\n\t\toPtr, ok := originPointer.(func(interface{}, io.Writer) error)\n\t\tif !ok {\n\t\t\tlog.Print(\"name: \", name)\n\t\t\tLogError(errors.New(\"Unknown destination template type!\"))\n\t\t\treturn\n\t\t}\n\n\t\tdPtr, ok := destTmplPtr.(*func(interface{}, io.Writer) error)\n\t\tif !ok {\n\t\t\tLogError(errors.New(\"The source and destination templates are incompatible\"))\n\t\t\treturn\n\t\t}\n\t\t*dPtr = oPtr\n\t\tlog.Print(\"The template override was reset\")\n\t}\n\toverridenTemplates = make(map[string]bool)\n\tlog.Print(\"All of the template overrides have been reset\")\n}\n\n// CreateThemeTemplate creates a theme template on the current default theme\nfunc CreateThemeTemplate(theme, name string) {\n\tThemes[theme].TmplPtr[name] = func(pi Page, w http.ResponseWriter) error {\n\t\tmapping, ok := Themes[DefaultThemeBox.Load().(string)].TemplatesMap[name]\n\t\tif !ok {\n\t\t\tmapping = name\n\t\t}\n\t\treturn DefaultTemplates.ExecuteTemplate(w, mapping+\".html\", pi)\n\t}\n}\n\nfunc GetDefaultThemeName() string {\n\treturn DefaultThemeBox.Load().(string)\n}\n\nfunc SetDefaultThemeName(name string) {\n\tDefaultThemeBox.Store(name)\n}\n"
  },
  {
    "path": "common/thumbnailer.go",
    "content": "package common\n\nimport (\n\t\"image\"\n\t\"image/gif\"\n\t\"image/jpeg\"\n\t\"image/png\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"golang.org/x/image/tiff\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/pkg/errors\"\n)\n\nfunc ThumbTask(thumbChan chan bool) {\n\tdefer EatPanics()\n\tacc := qgen.NewAcc()\n\tfor {\n\t\t// Put this goroutine to sleep until we have work to do\n\t\t<-thumbChan\n\n\t\t// TODO: Use a real queue\n\t\t// TODO: Transactions? Self-repairing?\n\t\terr := acc.Select(\"users_avatar_queue\").Columns(\"uid\").Limit(\"0,5\").EachInt(func(uid int) error {\n\t\t\t// TODO: Do a bulk user fetch instead?\n\t\t\tu, err := Users.Get(uid)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t// Has the avatar been removed or already been processed by the thumbnailer?\n\t\t\tif len(u.RawAvatar) < 2 || u.RawAvatar[1] == '.' {\n\t\t\t\t_, _ = acc.Delete(\"users_avatar_queue\").Where(\"uid=?\").Run(uid)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\t_, err = os.Stat(\"./uploads/avatar_\" + strconv.Itoa(u.ID) + u.RawAvatar)\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\t_, _ = acc.Delete(\"users_avatar_queue\").Where(\"uid=?\").Run(uid)\n\t\t\t\treturn nil\n\t\t\t} else if err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t// This means it's an external image, they aren't currently implemented, but this is here for when they are\n\t\t\tif u.RawAvatar[0] != '.' {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\t/*if user.RawAvatar == \".gif\" {\n\t\t\t\treturn nil\n\t\t\t}*/\n\t\t\tcanResize := func(ext string) bool {\n\t\t\t\t// TODO: Fix tif and tiff extensions?\n\t\t\t\treturn ext == \".png\" && ext == \".jpg\" && ext == \".jpe\" && ext == \".jpeg\" && ext == \".jif\" && ext == \".jfi\" && ext == \".jfif\" && ext == \".gif\" && ext == \".tiff\" && ext == \".tif\"\n\t\t\t}\n\t\t\tif !canResize(u.RawAvatar) {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tap := \"./uploads/avatar_\"\n\t\t\terr = Thumbnailer.Resize(u.RawAvatar[1:], ap+strconv.Itoa(u.ID)+u.RawAvatar, ap+strconv.Itoa(u.ID)+\"_tmp\"+u.RawAvatar, ap+strconv.Itoa(u.ID)+\"_w48\"+u.RawAvatar, 48)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\terr = u.ChangeAvatar(\".\" + u.RawAvatar)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\t\t\t_, err = acc.Delete(\"users_avatar_queue\").Where(\"uid=?\").Run(uid)\n\t\t\treturn errors.WithStack(err)\n\t\t})\n\t\tif err != nil {\n\t\t\tLogError(err)\n\t\t}\n\n\t\t/*\n\t\t\terr := acc.Select(\"attach_image_queue\").Columns(\"attachID\").Limit(\"0,5\").EachInt(func(attachID int) error {\n\t\t\t\treturn nil\n\n\t\t\t\t_, err = acc.Delete(\"attach_image_queue\").Where(\"attachID = ?\").Run(uid)\n\t\t\t}\n\t\t*/\n\t\tif err = acc.FirstError(); err != nil {\n\t\t\tLogError(err)\n\t\t}\n\t}\n}\n\nvar Thumbnailer ThumbnailerInt\n\ntype ThumbnailerInt interface {\n\tResize(format, inPath, tmpPath, outPath string, width int) error\n}\n\ntype RezThumbnailer struct {\n}\n\nfunc (thumb *RezThumbnailer) Resize(format, inPath, tmpPath, outPath string, width int) error {\n\t// TODO: Sniff the aspect ratio of the image and calculate the dest height accordingly, bug make sure it isn't excessively high\n\treturn nil\n}\n\nfunc (thumb *RezThumbnailer) resize(format, inPath, outPath string, width, height int) error {\n\treturn nil\n}\n\n// ! Note: CaireThumbnailer can't handle gifs, so we'll have to either cap their sizes or have another resizer deal with them\ntype CaireThumbnailer struct {\n}\n\nfunc NewCaireThumbnailer() *CaireThumbnailer {\n\treturn &CaireThumbnailer{}\n}\n\nfunc precodeImage(format, inPath, tmpPath string) error {\n\timageFile, err := os.Open(inPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer imageFile.Close()\n\n\timg, _, err := image.Decode(imageFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\toutFile, err := os.Create(tmpPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer outFile.Close()\n\n\t// TODO: Make sure animated gifs work after being encoded\n\tswitch format {\n\tcase \"gif\":\n\t\treturn gif.Encode(outFile, img, nil)\n\tcase \"png\":\n\t\treturn png.Encode(outFile, img)\n\tcase \"tiff\", \"tif\":\n\t\treturn tiff.Encode(outFile, img, nil)\n\t}\n\treturn jpeg.Encode(outFile, img, nil)\n}\n\nfunc (thumb *CaireThumbnailer) Resize(format, inPath, tmpPath, outPath string, width int) error {\n\terr := precodeImage(format, inPath, tmpPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n\n\t// TODO: Caire doesn't work. Try something else. Or get them to fix the index out of range. We get enough wins from re-encoding as jpeg anyway\n\t/*imageFile, err := os.Open(tmpPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer imageFile.Close()\n\n\toutFile, err := os.Create(outPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer outFile.Close()\n\n\tp := &caire.Processor{NewWidth: width, Scale: true}\n\treturn p.Process(imageFile, outFile)*/\n}\n\n/*\ntype LilliputThumbnailer struct {\n\n}\n*/\n"
  },
  {
    "path": "common/tickloop.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/pkg/errors\"\n)\n\nvar CTickLoop *TickLoop\n\ntype TickLoop struct {\n\tHalfSec    *time.Ticker\n\tSec        *time.Ticker\n\tFifteenMin *time.Ticker\n\tHour       *time.Ticker\n\tDay        *time.Ticker\n\n\tHalfSecf    func() error\n\tSecf        func() error\n\tFifteenMinf func() error\n\tHourf       func() error\n\tDayf        func() error\n}\n\nfunc NewTickLoop() *TickLoop {\n\treturn &TickLoop{\n\t\t// TODO: Write tests for these\n\t\t// Run this goroutine once every half second\n\t\tHalfSec:    time.NewTicker(time.Second / 2),\n\t\tSec:        time.NewTicker(time.Second),\n\t\tFifteenMin: time.NewTicker(15 * time.Minute),\n\t\tHour:       time.NewTicker(time.Hour),\n\t\tDay:        time.NewTicker(time.Hour * 24),\n\t}\n}\n\nfunc (l *TickLoop) Loop() {\n\tr := func(e error) {\n\t\tif e != nil {\n\t\t\tLogError(e)\n\t\t}\n\t}\n\tdefer EatPanics()\n\tfor {\n\t\tselect {\n\t\tcase <-l.HalfSec.C:\n\t\t\tr(l.HalfSecf())\n\t\tcase <-l.Sec.C:\n\t\t\tr(l.Secf())\n\t\tcase <-l.FifteenMin.C:\n\t\t\tr(l.FifteenMinf())\n\t\tcase <-l.Hour.C:\n\t\t\tr(l.Hourf())\n\t\t// TODO: Handle the instance going down a lot better\n\t\tcase <-l.Day.C:\n\t\t\tr(l.Dayf())\n\t\t}\n\t}\n}\n\nvar ErrDBDown = errors.New(\"The database is down\")\n\nfunc DBTimeout() time.Duration {\n\tif Dev.HourDBTimeout {\n\t\treturn time.Hour\n\t}\n\t//db.SetConnMaxLifetime(time.Second * 60 * 5) // Make this infinite as the temporary lifetime change will purge the stale connections?\n\treturn -1\n}\n\nvar pint int64\n\nfunc StartTick() (abort bool) {\n\topint := pint\n\tdb := qgen.Builder.GetConn()\n\tisDBDown := atomic.LoadInt32(&IsDBDown)\n\tif e := db.Ping(); e != nil {\n\t\t// TODO: There's a bit of a race here, but it doesn't matter if this error appears multiple times in the logs as it's capped at three times, we just want to cut it down 99% of the time\n\t\tif isDBDown == 0 {\n\t\t\tdb.SetConnMaxLifetime(time.Second / 4) // Drop all the connections and start over\n\t\t\tLogWarning(e, ErrDBDown.Error())\n\t\t}\n\t\tatomic.StoreInt32(&IsDBDown, 1)\n\t\treturn true\n\t}\n\tif isDBDown == 1 {\n\t\tlog.Print(\"The database is back\")\n\t}\n\tdb.SetConnMaxLifetime(DBTimeout())\n\tatomic.StoreInt32(&IsDBDown, 0)\n\treturn opint != pint\n}\n\n// TODO: Move these into DailyTick() methods?\nfunc asmMatches() error {\n\t// TODO: Find a more efficient way of doing this\n\treturn qgen.NewAcc().Select(\"activity_stream\").Cols(\"asid\").EachInt(func(asid int) error {\n\t\tif ActivityMatches.CountAsid(asid) > 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn Activity.Delete(asid)\n\t})\n}\n\n// TODO: Name the tasks so we can figure out which one it was when something goes wrong? Or maybe toss it up WithStack down there?\nfunc RunTasks(tasks []func() error) error {\n\tfor _, task := range tasks {\n\t\tif e := task(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\n/*func init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\treplyStmts = ReplyStmts{\n\t\t\tisLiked:                acc.Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? and targetItem=? and targetType='replies'\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}*/\n\nfunc StartupTasks() (e error) {\n\tr := func(ee error) {\n\t\tif e == nil {\n\t\t\te = ee\n\t\t}\n\t}\n\tif Config.DisableRegLog {\n\t\tr(RegLogs.Purge())\n\t}\n\tif Config.DisableLoginLog {\n\t\tr(LoginLogs.Purge())\n\t}\n\tif Config.DisablePostIP {\n\t\t// TODO: Clear the caches?\n\t\tr(Topics.ClearIPs())\n\t\tr(Rstore.ClearIPs())\n\t\tr(Prstore.ClearIPs())\n\t}\n\tif Config.DisablePollIP {\n\t\tr(Polls.ClearIPs())\n\t}\n\tif Config.DisableLastIP {\n\t\tr(Users.ClearLastIPs())\n\t}\n\treturn e\n}\n\nfunc Dailies() (e error) {\n\tif e = asmMatches(); e != nil {\n\t\treturn e\n\t}\n\tnewAcc := func() *qgen.Accumulator {\n\t\treturn qgen.NewAcc()\n\t}\n\texec := func(ac qgen.AccExec) {\n\t\tif e != nil {\n\t\t\treturn\n\t\t}\n\t\t_, ee := ac.Exec()\n\t\te = ee\n\t}\n\tr := func(ee error) {\n\t\tif e == nil {\n\t\t\te = ee\n\t\t}\n\t}\n\n\tif Config.LogPruneCutoff > -1 {\n\t\t// TODO: Clear the caches?\n\t\tif !Config.DisableLoginLog {\n\t\t\tr(LoginLogs.DeleteOlderThanDays(Config.LogPruneCutoff))\n\t\t}\n\t\tif !Config.DisableRegLog {\n\t\t\tr(RegLogs.DeleteOlderThanDays(Config.LogPruneCutoff))\n\t\t}\n\t}\n\n\tif !Config.DisablePostIP && Config.PostIPCutoff > -1 {\n\t\t// TODO: Use unixtime to remove this MySQLesque logic?\n\t\tf := func(tbl string) {\n\t\t\texec(newAcc().Update(tbl).Set(\"ip=''\").DateOlderThan(\"createdAt\", Config.PostIPCutoff, \"day\").Where(\"ip!=''\"))\n\t\t}\n\t\tf(\"topics\")\n\t\tf(\"replies\")\n\t\tf(\"users_replies\")\n\t}\n\n\tif !Config.DisablePollIP && Config.PollIPCutoff > -1 {\n\t\t// TODO: Use unixtime to remove this MySQLesque logic?\n\t\texec(newAcc().Update(\"polls_votes\").Set(\"ip=''\").DateOlderThan(\"castAt\", Config.PollIPCutoff, \"day\").Where(\"ip!=''\"))\n\n\t\t// TODO: Find some way of purging the ip data in polls_votes without breaking any anti-cheat measures which might be running... maybe hash it instead?\n\t}\n\n\t// TODO: lastActiveAt isn't currently set, so we can't rely on this to purge last_ips of users who haven't been on in a while\n\tif !Config.DisableLastIP && Config.LastIPCutoff > 0 {\n\t\t//exec(newAcc().Update(\"users\").Set(\"last_ip='0'\").DateOlderThan(\"lastActiveAt\",c.Config.PostIPCutoff,\"day\").Where(\"last_ip!='0'\"))\n\t\tmon := time.Now().Month()\n\t\texec(newAcc().Update(\"users\").Set(\"last_ip=''\").Where(\"last_ip!='' AND last_ip NOT LIKE '\" + strconv.Itoa(int(mon)) + \"-%'\"))\n\t}\n\n\tif e != nil {\n\t\treturn e\n\t}\n\tif e = Tasks.Day.Run(); e != nil {\n\t\treturn e\n\t}\n\tif e = ForumActionStore.DailyTick(); e != nil {\n\t\treturn e\n\t}\n\n\t{\n\t\te := Meta.SetInt64(\"lastDaily\", time.Now().Unix())\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\n\treturn nil\n}\n\ntype TickWatch struct {\n\tName      string\n\tStart     int64\n\tDBCheck   int64\n\tStartHook int64\n\tTasks     int64\n\tEndHook   int64\n\n\tTicker     *time.Ticker\n\tDeadline   *time.Ticker\n\tEndChan    chan bool\n\tOutEndChan chan bool\n}\n\nfunc NewTickWatch() *TickWatch {\n\treturn &TickWatch{\n\t\tTicker:   time.NewTicker(time.Second * 5),\n\t\tDeadline: time.NewTicker(time.Hour),\n\t}\n}\n\nfunc (w *TickWatch) DumpElapsed() {\n\tvar sb strings.Builder\n\tf := func(str string) {\n\t\tsb.WriteString(str)\n\t}\n\tff := func(str string, args ...interface{}) {\n\t\tf(fmt.Sprintf(str, args...))\n\t}\n\telapse := func(name string, bef, v int64) {\n\t\tif bef == 0 || v == 0 {\n\t\t\tff(\"%s: %d\\n\", v)\n\t\t\treturn\n\t\t}\n\t\tdur := time.Duration(v - bef)\n\t\tmilli := dur.Milliseconds()\n\t\tif milli < 1000 {\n\t\t\tff(\"%s: %d - %d ms\\n\", name, v, milli)\n\t\t} else if milli > 60000 {\n\t\t\tsecs := milli / 1000\n\t\t\tmins := secs / 60\n\t\t\tff(\"%s: %d - m%d s%.2f\\n\", name, v, mins, float64(milli-(mins*60*1000))/1000)\n\t\t} else {\n\t\t\tff(\"%s: %d - %.2f secs\\n\", name, v, dur.Seconds())\n\t\t}\n\t}\n\n\tf(\"Name: \" + w.Name + \"\\n\")\n\tff(\"Start: %d\\n\", w.Start)\n\telapse(\"DBCheck\", w.Start, w.DBCheck)\n\tif w.DBCheck == 0 {\n\t\tLog(sb.String())\n\t\treturn\n\t}\n\telapse(\"StartHook\", w.DBCheck, w.StartHook)\n\tif w.StartHook == 0 {\n\t\tLog(sb.String())\n\t\treturn\n\t}\n\telapse(\"Tasks\", w.StartHook, w.Tasks)\n\tif w.Tasks == 0 {\n\t\tLog(sb.String())\n\t\treturn\n\t}\n\telapse(\"EndHook\", w.Tasks, w.EndHook)\n\n\tLog(sb.String())\n}\n\nfunc (w *TickWatch) Run() {\n\tw.EndChan = make(chan bool)\n\t// Use a goroutine to circumvent ticks which never end\n\t// TODO: Reuse goroutines across multiple *TickWatch?\n\tgo func() {\n\t\tdefer w.Ticker.Stop()\n\t\tdefer close(w.EndChan)\n\t\tdefer EatPanics()\n\t\tvar n int\n\t\tvar downOnce bool\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-w.Ticker.C:\n\t\t\t\t// Less noisy logs\n\t\t\t\tif n > 20 && n%5 == 0 {\n\t\t\t\t\tLogf(\"%d seconds elapsed since tick %s started\", 5*n, w.Name)\n\t\t\t\t} else if n > 2 && n%2 != 0 {\n\t\t\t\t\tLogf(\"%d seconds elapsed since tick %s started\", 5*n, w.Name)\n\t\t\t\t}\n\t\t\t\tif !downOnce && w.DBCheck == 0 {\n\t\t\t\t\tqgen.Builder.GetConn().SetConnMaxLifetime(time.Second / 4) // Drop all the connections and start over\n\t\t\t\t\tLogWarning(ErrDBDown)\n\t\t\t\t\tatomic.StoreInt32(&IsDBDown, 1)\n\t\t\t\t\tdownOnce = true\n\t\t\t\t}\n\t\t\t\tn++\n\t\t\tcase <-w.Deadline.C:\n\t\t\t\tLog(\"Hit TickWatch deadline\")\n\t\t\t\tdur := time.Duration(uutils.Nanotime() - w.Start)\n\t\t\t\tif dur.Seconds() > 5 {\n\t\t\t\t\tLog(\"tick \" + w.Name + \" has run for \" + dur.String())\n\t\t\t\t\tw.DumpElapsed()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\tcase <-w.EndChan:\n\t\t\t\tdur := time.Duration(uutils.Nanotime() - w.Start)\n\t\t\t\tif dur.Seconds() > 5 {\n\t\t\t\t\tLog(\"tick \" + w.Name + \" completed in \" + dur.String())\n\t\t\t\t\tw.DumpElapsed()\n\t\t\t\t}\n\t\t\t\tif w.OutEndChan != nil {\n\t\t\t\t\tw.OutEndChan <- true\n\t\t\t\t\tclose(w.OutEndChan)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n}\n\nfunc (w *TickWatch) Stop() {\n\tw.EndChan <- true\n}\n\nfunc (w *TickWatch) Set(a *int64, v int64) {\n\tatomic.StoreInt64(a, v)\n}\n\nfunc (w *TickWatch) Clear() {\n\tw.Start = 0\n\tw.DBCheck = 0\n\tw.StartHook = 0\n\tw.Tasks = 0\n\tw.EndHook = 0\n}\n"
  },
  {
    "path": "common/topic.go",
    "content": "/*\n*\n*\tGosora Topic File\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"html\"\n\t\"html/template\"\n\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t//\"log\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// This is also in reply.go\n//var ErrAlreadyLiked = errors.New(\"This item was already liked by this user\")\n\n// ? - Add a TopicMeta struct for *Forums?\n\ntype Topic struct {\n\tID          int\n\tLink        string\n\tTitle       string\n\tContent     string\n\tCreatedBy   int\n\tIsClosed    bool\n\tSticky      bool\n\tCreatedAt   time.Time\n\tLastReplyAt time.Time\n\tLastReplyBy int\n\tLastReplyID int\n\tParentID    int\n\tStatus      string // Deprecated. Marked for removal. -Is there anything we could use it for?\n\tIP          string\n\tViewCount   int64\n\tPostCount   int\n\tLikeCount   int\n\tAttachCount int\n\tWeekViews   int\n\tClassName   string // CSS Class Name\n\tPoll        int\n\tData        string // Used for report metadata\n\n\tRids []int\n}\n\ntype TopicUser struct {\n\tID          int\n\tLink        string\n\tTitle       string\n\tContent     string // TODO: Avoid converting this to bytes in templates, particularly if it's long\n\tCreatedBy   int\n\tIsClosed    bool\n\tSticky      bool\n\tCreatedAt   time.Time\n\tLastReplyAt time.Time\n\tLastReplyBy int\n\tLastReplyID int\n\tParentID    int\n\tStatus      string // Deprecated. Marked for removal.\n\tIP          string\n\tViewCount   int64\n\tPostCount   int\n\tLikeCount   int\n\tAttachCount int\n\tClassName   string\n\tPoll        int\n\tData        string // Used for report metadata\n\n\tUserLink      string\n\tCreatedByName string\n\tGroup         int\n\tAvatar        string\n\tMicroAvatar   string\n\tContentLines  int\n\tContentHTML   string // TODO: Avoid converting this to bytes in templates, particularly if it's long\n\tTag           string\n\tURL           string\n\t//URLPrefix     string\n\t//URLName       string\n\tLevel int\n\tLiked bool\n\n\tAttachments []*MiniAttachment\n\tRids        []int\n\tDeletable   bool\n}\n\ntype TopicsRowMut struct {\n\t*TopicsRow\n\tCanMod bool\n}\n\n// TODO: Embed TopicUser to simplify this structure and it's related logic?\ntype TopicsRow struct {\n\tTopic\n\tLastPage int\n\n\tCreator      *User\n\tCSS          template.CSS\n\tContentLines int\n\tLastUser     *User\n\n\tForumName string //TopicsRow\n\tForumLink string\n}\n\ntype WsTopicsRow struct {\n\tID                  int\n\tLink                string\n\tTitle               string\n\tCreatedBy           int\n\tIsClosed            bool\n\tSticky              bool\n\tCreatedAt           time.Time\n\tLastReplyAt         time.Time\n\tRelativeLastReplyAt string\n\tLastReplyBy         int\n\tLastReplyID         int\n\tParentID            int\n\tViewCount           int64\n\tPostCount           int\n\tLikeCount           int\n\tAttachCount         int\n\tClassName           string\n\tCreator             *WsJSONUser\n\tLastUser            *WsJSONUser\n\tForumName           string\n\tForumLink           string\n\tCanMod              bool\n}\n\n// TODO: Can we get the client side to render the relative times instead?\nfunc (r *TopicsRow) WebSockets() *WsTopicsRow {\n\treturn &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, false}\n}\n\n// TODO: Can we get the client side to render the relative times instead?\nfunc (r *TopicsRow) WebSockets2(canMod bool) *WsTopicsRow {\n\treturn &WsTopicsRow{r.ID, r.Link, r.Title, r.CreatedBy, r.IsClosed, r.Sticky, r.CreatedAt, r.LastReplyAt, RelativeTime(r.LastReplyAt), r.LastReplyBy, r.LastReplyID, r.ParentID, r.ViewCount, r.PostCount, r.LikeCount, r.AttachCount, r.ClassName, r.Creator.WebSockets(), r.LastUser.WebSockets(), r.ForumName, r.ForumLink, canMod}\n}\n\n// TODO: Stop relying on so many struct types?\n// ! Not quite safe as Topic doesn't contain all the data needed to constructs a TopicsRow\nfunc (t *Topic) TopicsRow() *TopicsRow {\n\tlastPage := 1\n\tvar creator *User = nil\n\tcontentLines := 1\n\tvar lastUser *User = nil\n\tforumName := \"\"\n\tforumLink := \"\"\n\n\t//return &TopicsRow{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, lastPage, t.ClassName, t.Poll, t.Data, creator, \"\", contentLines, lastUser, forumName, forumLink, t.Rids}\n\treturn &TopicsRow{*t, lastPage, creator, \"\", contentLines, lastUser, forumName, forumLink}\n}\n\n// ! Some data may be lost in the conversion\n/*func (t *TopicsRow) Topic() *Topic {\n\t//return &Topic{t.ID, t.Link, t.Title, t.Content, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, t.LastReplyBy, t.LastReplyID, t.ParentID, t.Status, t.IP, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, t.Poll, t.Data, t.Rids}\n\treturn &t.Topic\n}*/\n\n// ! Not quite safe as Topic doesn't contain all the data needed to constructs a WsTopicsRow\n/*func (t *Topic) WsTopicsRows() *WsTopicsRow {\n\tvar creator *User = nil\n\tvar lastUser *User = nil\n\tforumName := \"\"\n\tforumLink := \"\"\n\treturn &WsTopicsRow{t.ID, t.Link, t.Title, t.CreatedBy, t.IsClosed, t.Sticky, t.CreatedAt, t.LastReplyAt, RelativeTime(t.LastReplyAt), t.LastReplyBy, t.LastReplyID, t.ParentID, t.ViewCount, t.PostCount, t.LikeCount, t.AttachCount, t.ClassName, creator, lastUser, forumName, forumLink}\n}*/\n\ntype TopicStmts struct {\n\tgetRids             *sql.Stmt\n\tgetReplies          *sql.Stmt\n\tgetReplies2         *sql.Stmt\n\tgetReplies3         *sql.Stmt\n\taddReplies          *sql.Stmt\n\tupdateLastReply     *sql.Stmt\n\tlock                *sql.Stmt\n\tunlock              *sql.Stmt\n\tmoveTo              *sql.Stmt\n\tstick               *sql.Stmt\n\tunstick             *sql.Stmt\n\thasLikedTopic       *sql.Stmt\n\tcreateLike          *sql.Stmt\n\taddLikesToTopic     *sql.Stmt\n\tdelete              *sql.Stmt\n\tdeleteReplies       *sql.Stmt\n\tdeleteLikesForTopic *sql.Stmt\n\tdeleteActivity      *sql.Stmt\n\tedit                *sql.Stmt\n\tsetPoll             *sql.Stmt\n\tremovePoll          *sql.Stmt\n\ttestSetCreatedAt    *sql.Stmt\n\tcreateAction        *sql.Stmt\n\n\tgetTopicUser *sql.Stmt // TODO: Can we get rid of this?\n\tgetByReplyID *sql.Stmt\n}\n\nvar topicStmts TopicStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tt, w := \"topics\", \"tid=?\"\n\t\tset := func(s string) *sql.Stmt {\n\t\t\treturn acc.Update(t).Set(s).Where(w).Prepare()\n\t\t}\n\t\ttopicStmts = TopicStmts{\n\t\t\tgetRids:             acc.Select(\"replies\").Columns(\"rid\").Where(w).Orderby(\"rid ASC\").Limit(\"?,?\").Prepare(),\n\t\t\tgetReplies:          acc.SimpleLeftJoin(\"replies AS r\", \"users AS u\", \"r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.ip, r.likeCount, r.attachCount, r.actionType\", \"r.createdBy=u.uid\", \"r.tid=?\", \"r.rid ASC\", \"?,?\"),\n\t\t\tgetReplies2:         acc.SimpleLeftJoin(\"replies AS r\", \"users AS u\", \"r.rid, r.content, r.createdBy, r.createdAt, r.lastEdit, r.lastEditBy, u.avatar, u.name, u.group, u.level, r.likeCount, r.attachCount, r.actionType\", \"r.createdBy=u.uid\", \"r.tid=?\", \"r.rid ASC\", \"?,?\"),\n\t\t\tgetReplies3:         acc.Select(\"replies\").Columns(\"rid,content,createdBy,createdAt,lastEdit,lastEditBy,likeCount,attachCount,actionType\").Where(w).Orderby(\"rid ASC\").Limit(\"?,?\").Prepare(),\n\t\t\taddReplies:          set(\"postCount=postCount+?,lastReplyBy=?,lastReplyAt=UTC_TIMESTAMP()\"),\n\t\t\tupdateLastReply:     acc.Update(t).Set(\"lastReplyID=?\").Where(\"lastReplyID<? AND tid=?\").Prepare(),\n\t\t\tlock:                set(\"is_closed=1\"),\n\t\t\tunlock:              set(\"is_closed=0\"),\n\t\t\tmoveTo:              set(\"parentID=?\"),\n\t\t\tstick:               set(\"sticky=1\"),\n\t\t\tunstick:             set(\"sticky=0\"),\n\t\t\thasLikedTopic:       acc.Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? and targetItem=? and targetType='topics'\").Prepare(),\n\t\t\tcreateLike:          acc.Insert(\"likes\").Columns(\"weight,targetItem,targetType,sentBy,createdAt\").Fields(\"?,?,?,?,UTC_TIMESTAMP()\").Prepare(),\n\t\t\taddLikesToTopic:     set(\"likeCount=likeCount+?\"),\n\t\t\tdelete:              acc.Delete(t).Where(w).Prepare(),\n\t\t\tdeleteReplies:       acc.Delete(\"replies\").Where(w).Prepare(),\n\t\t\tdeleteLikesForTopic: acc.Delete(\"likes\").Where(\"targetItem=? AND targetType='topics'\").Prepare(),\n\t\t\tdeleteActivity:      acc.Delete(\"activity_stream\").Where(\"elementID=? AND elementType='topic'\").Prepare(),\n\t\t\tedit:                set(\"title=?,content=?,parsed_content=?\"), // TODO: Only run the content update bits on non-polls, does this matter?\n\t\t\tsetPoll:             acc.Update(t).Set(\"poll=?\").Where(\"tid=? AND poll=0\").Prepare(),\n\t\t\tremovePoll:          acc.Update(t).Set(\"poll=0\").Where(\"tid=?\").Prepare(),\n\t\t\ttestSetCreatedAt:    set(\"createdAt=?\"),\n\t\t\tcreateAction:        acc.Insert(\"replies\").Columns(\"tid,actionType,ip,createdBy,createdAt,lastUpdated,content,parsed_content\").Fields(\"?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),'',''\").Prepare(),\n\n\t\t\tgetTopicUser: acc.SimpleLeftJoin(\"topics AS t\", \"users AS u\", \"t.title, t.content, t.createdBy, t.createdAt, t.lastReplyAt, t.lastReplyBy, t.lastReplyID, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.attachCount,t.poll, u.name, u.avatar, u.group, u.level\", \"t.createdBy=u.uid\", w, \"\", \"\"),\n\t\t\tgetByReplyID: acc.SimpleLeftJoin(\"replies AS r\", \"topics AS t\", \"t.tid, t.title, t.content, t.createdBy, t.createdAt, t.is_closed, t.sticky, t.parentID, t.ip, t.views, t.postCount, t.likeCount, t.poll, t.data\", \"r.tid=t.tid\", \"rid=?\", \"\", \"\"),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// Flush the topic out of the cache\n// ? - We do a CacheRemove() here instead of mutating the pointer to avoid creating a race condition\nfunc (t *Topic) cacheRemove() {\n\tif tc := Topics.GetCache(); tc != nil {\n\t\ttc.Remove(t.ID)\n\t}\n\tTopicListThaw.Thaw()\n}\n\n// TODO: Write a test for this\nfunc (t *Topic) AddReply(rid, uid int) (e error) {\n\t_, e = topicStmts.addReplies.Exec(1, uid, t.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = topicStmts.updateLastReply.Exec(rid, rid, t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc (t *Topic) Lock() (e error) {\n\t_, e = topicStmts.lock.Exec(t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc (t *Topic) Unlock() (e error) {\n\t_, e = topicStmts.unlock.Exec(t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc (t *Topic) MoveTo(destForum int) (e error) {\n\t_, e = topicStmts.moveTo.Exec(destForum, t.ID)\n\tt.cacheRemove()\n\tif e != nil {\n\t\treturn e\n\t}\n\te = Attachments.MoveTo(destForum, t.ID, \"topics\")\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn Attachments.MoveToByExtra(destForum, \"replies\", strconv.Itoa(t.ID))\n}\n\nfunc (t *Topic) TestSetCreatedAt(s time.Time) (e error) {\n\t_, e = topicStmts.testSetCreatedAt.Exec(s, t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\n// TODO: We might want more consistent terminology rather than using stick in some places and pin in others. If you don't understand the difference, there is none, they are one and the same.\nfunc (t *Topic) Stick() (e error) {\n\t_, e = topicStmts.stick.Exec(t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc (t *Topic) Unstick() (e error) {\n\t_, e = topicStmts.unstick.Exec(t.ID)\n\tt.cacheRemove()\n\treturn e\n}\n\n// TODO: Test this\n// TODO: Use a transaction for this\nfunc (t *Topic) Like(score, uid int) (err error) {\n\tvar disp int // Unused\n\terr = topicStmts.hasLikedTopic.QueryRow(uid, t.ID).Scan(&disp)\n\tif err != nil && err != ErrNoRows {\n\t\treturn err\n\t} else if err != ErrNoRows {\n\t\treturn ErrAlreadyLiked\n\t}\n\t_, err = topicStmts.createLike.Exec(score, t.ID, \"topics\", uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = topicStmts.addLikesToTopic.Exec(1, t.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = userStmts.incLiked.Exec(1, uid)\n\tt.cacheRemove()\n\treturn err\n}\n\n// TODO: Use a transaction\nfunc (t *Topic) Unlike(uid int) error {\n\te := Likes.Delete(t.ID, \"topics\")\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = topicStmts.addLikesToTopic.Exec(-1, t.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = userStmts.decLiked.Exec(1, uid)\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc handleLikedTopicReplies(tid int) error {\n\trows, e := userStmts.getLikedRepliesOfTopic.Query(tid)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar rid int\n\t\tif e := rows.Scan(&rid); e != nil {\n\t\t\treturn e\n\t\t}\n\t\t_, e = replyStmts.deleteLikesForReply.Exec(rid)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\te = Activity.DeleteByParams(\"like\", rid, \"post\")\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\nfunc handleTopicAttachments(tid int) error {\n\te := handleAttachments(userStmts.getAttachmentsOfTopic, tid)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn handleAttachments(userStmts.getAttachmentsOfTopic2, tid)\n}\n\nfunc handleReplyAttachments(rid int) error {\n\treturn handleAttachments(replyStmts.getAidsOfReply, rid)\n}\n\nfunc handleAttachments(stmt *sql.Stmt, id int) error {\n\trows, e := stmt.Query(id)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar aid int\n\t\tif e := rows.Scan(&aid); e != nil {\n\t\t\treturn e\n\t\t}\n\t\ta, e := Attachments.FGet(aid)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\te = deleteAttachment(a)\n\t\tif e != nil && e != sql.ErrNoRows {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\n// TODO: Only load a row per createdBy, maybe with group by?\nfunc handleTopicReplies(umap map[int]struct{}, uid, tid int) error {\n\trows, e := userStmts.getRepliesOfTopic.Query(uid, tid)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tvar createdBy int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&createdBy); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tumap[createdBy] = struct{}{}\n\t}\n\treturn rows.Err()\n}\n\n// TODO: Use a transaction here\nfunc (t *Topic) Delete() error {\n\t/*creator, e := Users.Get(t.CreatedBy)\n\tif e == nil {\n\t\te = creator.DecreasePostStats(WordCount(t.Content), true)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t} else if e != ErrNoRows {\n\t\treturn e\n\t}*/\n\n\t// TODO: Clear reply cache too\n\t_, e := topicStmts.delete.Exec(t.ID)\n\tt.cacheRemove()\n\tif e != nil {\n\t\treturn e\n\t}\n\te = Forums.RemoveTopic(t.ParentID)\n\tif e != nil && e != ErrNoRows {\n\t\treturn e\n\t}\n\t_, e = topicStmts.deleteLikesForTopic.Exec(t.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\tif t.PostCount > 1 {\n\t\tif e = handleLikedTopicReplies(t.ID); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tumap := make(map[int]struct{})\n\t\te = handleTopicReplies(umap, t.CreatedBy, t.ID)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\t_, e = topicStmts.deleteReplies.Exec(t.ID)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tfor uid := range umap {\n\t\t\te = (&User{ID: uid}).RecalcPostStats()\n\t\t\tif e != nil {\n\t\t\t\t//log.Printf(\"e: %+v\\n\", e)\n\t\t\t\treturn e\n\t\t\t}\n\t\t}\n\t}\n\te = (&User{ID: t.CreatedBy}).RecalcPostStats()\n\tif e != nil {\n\t\treturn e\n\t}\n\te = handleTopicAttachments(t.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\te = Subscriptions.DeleteResource(t.ID, \"topic\")\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = topicStmts.deleteActivity.Exec(t.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif t.Poll > 0 {\n\t\te = (&Poll{ID: t.Poll}).Delete()\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\n// TODO: Write tests for this\nfunc (t *Topic) Update(name, content string) error {\n\tname = SanitiseSingleLine(html.UnescapeString(name))\n\tif name == \"\" {\n\t\treturn ErrNoTitle\n\t}\n\t// ? This number might be a little screwy with Unicode, but it's the only consistent thing we have, as Unicode characters can be any number of bytes in theory?\n\tif len(name) > Config.MaxTopicTitleLength {\n\t\treturn ErrLongTitle\n\t}\n\n\tcontent = PreparseMessage(html.UnescapeString(content))\n\tparsedContent := ParseMessage(content, t.ParentID, \"forums\", nil, nil)\n\t_, err := topicStmts.edit.Exec(name, content, parsedContent, t.ID)\n\tt.cacheRemove()\n\treturn err\n}\n\nfunc (t *Topic) SetPoll(pollID int) error {\n\t_, e := topicStmts.setPoll.Exec(pollID, t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll\n\tt.cacheRemove()\n\treturn e\n}\n\nfunc (t *Topic) RemovePoll() error {\n\t_, e := topicStmts.removePoll.Exec(t.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll\n\tt.cacheRemove()\n\treturn e\n}\n\n// TODO: Have this go through the ReplyStore?\n// TODO: Return the rid?\nfunc (t *Topic) CreateActionReply(action, ip string, uid int) (err error) {\n\tif Config.DisablePostIP {\n\t\tip = \"\"\n\t}\n\tres, err := topicStmts.createAction.Exec(t.ID, action, ip, uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = topicStmts.addReplies.Exec(1, uid, t.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlid, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn err\n\t}\n\trid := int(lid)\n\t_, err = topicStmts.updateLastReply.Exec(rid, rid, t.ID)\n\tt.cacheRemove()\n\t// ? - Update the last topic cache for the parent forum?\n\treturn err\n}\n\nfunc GetRidsForTopic(tid, offset int) (rids []int, e error) {\n\trows, e := topicStmts.getRids.Query(tid, offset, Config.ItemsPerPage)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\tvar rid int\n\tfor rows.Next() {\n\t\tif e := rows.Scan(&rid); e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\trids = append(rids, rid)\n\t}\n\treturn rids, rows.Err()\n}\n\nvar aipost = \";&#xFE0E\"\nvar lockai = \"&#x1F512\" + aipost\nvar unlockai = \"&#x1F513\"\nvar stickai = \"&#x1F4CC\"\nvar unstickai = \"&#x1F4CC\" + aipost\n\nfunc (ru *ReplyUser) Init(u *User) (group *Group, err error) {\n\tru.ContentLines = strings.Count(ru.Content, \"\\n\")\n\n\tpostGroup, err := Groups.Get(ru.Group)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif postGroup.IsMod {\n\t\tru.ClassName = Config.StaffCSS\n\t}\n\tru.Tag = postGroup.Tag\n\n\tif u.ID != ru.CreatedBy {\n\t\tru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)\n\t\t// TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this?\n\t\tru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar)\n\t} else {\n\t\tru.UserLink = u.Link\n\t\tru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar\n\t}\n\n\t// We really shouldn't have inline HTML, we should do something about this...\n\tif ru.ActionType != \"\" {\n\t\taarr := strings.Split(ru.ActionType, \"-\")\n\t\taction := aarr[0]\n\t\tswitch action {\n\t\tcase \"lock\":\n\t\t\tru.ActionIcon = lockai\n\t\tcase \"unlock\":\n\t\t\tru.ActionIcon = unlockai\n\t\tcase \"stick\":\n\t\t\tru.ActionIcon = stickai\n\t\tcase \"unstick\":\n\t\t\tru.ActionIcon = unstickai\n\t\tcase \"move\":\n\t\t\tif len(aarr) == 2 {\n\t\t\t\tfid, _ := strconv.Atoi(aarr[1])\n\t\t\t\tforum, err := Forums.Get(fid)\n\t\t\t\tif err == nil {\n\t\t\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_move_dest\", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)\n\t\t\t\t\treturn postGroup, nil\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\t// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?\n\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_default\", ru.ActionType)\n\t\t\treturn postGroup, nil\n\t\t}\n\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_\"+action, ru.UserLink, ru.CreatedByName)\n\t}\n\n\treturn postGroup, nil\n}\n\nfunc (ru *ReplyUser) Init2() (group *Group, err error) {\n\t//ru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)\n\tru.ContentLines = strings.Count(ru.Content, \"\\n\")\n\n\tpostGroup, err := Groups.Get(ru.Group)\n\tif err != nil {\n\t\treturn postGroup, err\n\t}\n\tif postGroup.IsMod {\n\t\tru.ClassName = Config.StaffCSS\n\t}\n\tru.Tag = postGroup.Tag\n\n\t// We really shouldn't have inline HTML, we should do something about this...\n\tif ru.ActionType != \"\" {\n\t\taarr := strings.Split(ru.ActionType, \"-\")\n\t\taction := aarr[0]\n\t\tswitch action {\n\t\tcase \"lock\":\n\t\t\tru.ActionIcon = lockai\n\t\tcase \"unlock\":\n\t\t\tru.ActionIcon = unlockai\n\t\tcase \"stick\":\n\t\t\tru.ActionIcon = stickai\n\t\tcase \"unstick\":\n\t\t\tru.ActionIcon = unstickai\n\t\tcase \"move\":\n\t\t\tif len(aarr) == 2 {\n\t\t\t\tfid, _ := strconv.Atoi(aarr[1])\n\t\t\t\tforum, err := Forums.Get(fid)\n\t\t\t\tif err == nil {\n\t\t\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_move_dest\", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)\n\t\t\t\t\treturn postGroup, nil\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\t// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?\n\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_default\", ru.ActionType)\n\t\t\treturn postGroup, nil\n\t\t}\n\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_\"+action, ru.UserLink, ru.CreatedByName)\n\t}\n\n\treturn postGroup, nil\n}\n\nfunc (ru *ReplyUser) Init3(u *User, tu *TopicUser) (group *Group, err error) {\n\tru.ContentLines = strings.Count(ru.Content, \"\\n\")\n\n\tpostGroup, err := Groups.Get(ru.Group)\n\tif err != nil {\n\t\treturn postGroup, err\n\t}\n\tif postGroup.IsMod {\n\t\tru.ClassName = Config.StaffCSS\n\t}\n\tru.Tag = postGroup.Tag\n\n\tif u.ID == ru.CreatedBy {\n\t\tru.UserLink = u.Link\n\t\tru.Avatar, ru.MicroAvatar = u.Avatar, u.MicroAvatar\n\t} else if tu.CreatedBy == ru.CreatedBy {\n\t\tru.UserLink = tu.UserLink\n\t\tru.Avatar, ru.MicroAvatar = tu.Avatar, tu.MicroAvatar\n\t} else {\n\t\tru.UserLink = BuildProfileURL(NameToSlug(ru.CreatedByName), ru.CreatedBy)\n\t\t// TODO: Make a function for this? Build a more sophisticated noavatar handling system? Do bulk user loads and let the c.UserStore initialise this?\n\t\tru.Avatar, ru.MicroAvatar = BuildAvatar(ru.CreatedBy, ru.Avatar)\n\t}\n\n\t// We really shouldn't have inline HTML, we should do something about this...\n\tif ru.ActionType != \"\" {\n\t\taarr := strings.Split(ru.ActionType, \"-\")\n\t\taction := aarr[0]\n\t\tswitch action {\n\t\tcase \"lock\":\n\t\t\tru.ActionIcon = lockai\n\t\t\taction = \"topic.action_topic_lock\"\n\t\tcase \"unlock\":\n\t\t\tru.ActionIcon = unlockai\n\t\t\taction = \"topic.action_topic_unlock\"\n\t\tcase \"stick\":\n\t\t\tru.ActionIcon = stickai\n\t\t\taction = \"topic.action_topic_stick\"\n\t\tcase \"unstick\":\n\t\t\tru.ActionIcon = unstickai\n\t\t\taction = \"topic.action_topic_unstick\"\n\t\tcase \"move\":\n\t\t\tif len(aarr) == 2 {\n\t\t\t\tfid, _ := strconv.Atoi(aarr[1])\n\t\t\t\tforum, err := Forums.Get(fid)\n\t\t\t\tif err == nil {\n\t\t\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_move_dest\", forum.Link, forum.Name, ru.UserLink, ru.CreatedByName)\n\t\t\t\t\treturn postGroup, nil\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\t// TODO: Only fire this off if a corresponding phrase for the ActionType doesn't exist? Or maybe have some sort of action registry?\n\t\t\tru.ActionType = p.GetTmplPhrasef(\"topic.action_topic_default\", ru.ActionType)\n\t\t\treturn postGroup, nil\n\t\t}\n\t\tru.ActionType = p.GetTmplPhrasef(action, ru.UserLink, ru.CreatedByName)\n\t}\n\n\treturn postGroup, nil\n}\n\n// TODO: Factor TopicUser into a *Topic and *User, as this starting to become overly complicated x.x\nfunc (t *TopicUser) Replies(offset int /*pFrag int, */, user *User) (rlist []*ReplyUser /*, ogdesc string*/, externalHead bool, err error) {\n\tvar likedMap, attachMap map[int]int\n\tvar likedQueryList, attachQueryList []int\n\n\tvar rid int\n\tif len(t.Rids) > 0 {\n\t\t//log.Print(\"have rid\")\n\t\trid = t.Rids[0]\n\t}\n\tre, err := Rstore.GetCache().Get(rid)\n\tucache := Users.GetCache()\n\tvar ruser *User\n\tif ucache != nil {\n\t\t//log.Print(\"ucache step\")\n\t\tif err == nil {\n\t\t\truser = ucache.Getn(re.CreatedBy)\n\t\t} else if t.PostCount == 2 {\n\t\t\truser = ucache.Getn(t.LastReplyBy)\n\t\t}\n\t}\n\n\thTbl := GetHookTable()\n\trf := func(r *ReplyUser) (err error) {\n\t\t//log.Printf(\"before r: %+v\\n\", r)\n\t\tpostGroup, err := r.Init3(user, t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t//log.Printf(\"after r: %+v\\n\", r)\n\n\t\tvar parseSettings *ParseSettings\n\t\tif (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {\n\t\t\tparseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tparseSettings.NoEmbed = true\n\t\t} else {\n\t\t\tparseSettings = user.ParseSettings\n\t\t}\n\t\t/*if user.ParseSettings == nil {\n\t\t\tparseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tparseSettings.NoEmbed = Config.NoEmbed || !postGroup.Perms.AutoEmbed\n\t\t\tparseSettings.NoLink = !postGroup.Perms.AutoLink\n\t\t} else {\n\t\t\tparseSettings = user.ParseSettings\n\t\t}*/\n\n\t\tvar eh bool\n\t\tr.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, \"forums\", parseSettings, user)\n\t\tif eh {\n\t\t\texternalHead = true\n\t\t}\n\t\t// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.\n\t\tif r.ContentHtml == r.Content {\n\t\t\tr.ContentHtml = r.Content\n\t\t}\n\t\tr.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID\n\n\t\t// TODO: This doesn't work properly so pick the first one instead?\n\t\t/*if r.ID == pFrag {\n\t\t\togdesc = r.Content\n\t\t\tif len(ogdesc) > 200 {\n\t\t\t\togdesc = ogdesc[:197] + \"...\"\n\t\t\t}\n\t\t}*/\n\n\t\treturn nil\n\t}\n\n\trf3 := func(r *ReplyUser) error {\n\t\t//log.Printf(\"before r: %+v\\n\", r)\n\t\tpostGroup, err := r.Init2()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar parseSettings *ParseSettings\n\t\tif (Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {\n\t\t\tparseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tparseSettings.NoEmbed = true\n\t\t} else {\n\t\t\tparseSettings = user.ParseSettings\n\t\t}\n\n\t\tvar eh bool\n\t\tr.ContentHtml, eh = ParseMessage2(r.Content, t.ParentID, \"forums\", parseSettings, user)\n\t\tif eh {\n\t\t\texternalHead = true\n\t\t}\n\t\t// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.\n\t\tif r.ContentHtml == r.Content {\n\t\t\tr.ContentHtml = r.Content\n\t\t}\n\t\tr.Deletable = user.Perms.DeleteReply || r.CreatedBy == user.ID\n\n\t\treturn nil\n\t}\n\n\t// TODO: Factor the user fields out and embed a user struct instead\n\tif err == nil && ruser != nil {\n\t\t//log.Print(\"reply cached serve\")\n\t\tr := &ReplyUser{ /*ClassName: \"\", */ Reply: *re, CreatedByName: ruser.Name, UserLink: ruser.Link, Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar /*URLPrefix: ruser.URLPrefix, URLName: ruser.URLName, */, Group: ruser.Group, Level: ruser.Level, Tag: ruser.Tag}\n\t\tif err = rf3(r); err != nil {\n\t\t\treturn nil, externalHead, err\n\t\t}\n\n\t\tif r.LikeCount > 0 && user.Liked > 0 {\n\t\t\tlikedMap = map[int]int{r.ID: 0}\n\t\t\tlikedQueryList = []int{r.ID}\n\t\t}\n\t\tif user.Perms.EditReply && r.AttachCount > 0 {\n\t\t\tif likedMap == nil {\n\t\t\t\tattachMap = map[int]int{r.ID: 0}\n\t\t\t\tattachQueryList = []int{r.ID}\n\t\t\t} else {\n\t\t\t\tattachMap = likedMap\n\t\t\t\tattachQueryList = likedQueryList\n\t\t\t}\n\t\t}\n\n\t\tH_topic_reply_row_assign_hook(hTbl, r)\n\t\t// TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis?\n\t\trlist = []*ReplyUser{r}\n\t\t//log.Printf(\"r: %d-%d\", r.ID, len(rlist)-1)\n\t} else {\n\t\t//log.Print(\"reply query serve\")\n\t\tap1 := func(r *ReplyUser) {\n\t\t\tH_topic_reply_row_assign_hook(hTbl, r)\n\t\t\t// TODO: Use a pointer instead to make it easier to abstract this loop? What impact would this have on escape analysis?\n\t\t\trlist = append(rlist, r)\n\t\t\t//log.Printf(\"r: %d-%d\", r.ID, len(rlist)-1)\n\t\t}\n\t\trf2 := func(r *ReplyUser) {\n\t\t\tif r.LikeCount > 0 && user.Liked > 0 {\n\t\t\t\tif likedMap == nil {\n\t\t\t\t\tlikedMap = map[int]int{r.ID: len(rlist)}\n\t\t\t\t\tlikedQueryList = []int{r.ID}\n\t\t\t\t} else {\n\t\t\t\t\tlikedMap[r.ID] = len(rlist)\n\t\t\t\t\tlikedQueryList = append(likedQueryList, r.ID)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif user.Perms.EditReply && r.AttachCount > 0 {\n\t\t\t\tif attachMap == nil {\n\t\t\t\t\tattachMap = map[int]int{r.ID: len(rlist)}\n\t\t\t\t\tattachQueryList = []int{r.ID}\n\t\t\t\t} else {\n\t\t\t\t\tattachMap[r.ID] = len(rlist)\n\t\t\t\t\tattachQueryList = append(attachQueryList, r.ID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !user.Perms.ViewIPs && ruser != nil {\n\t\t\trows, e := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, externalHead, e\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\tfor rows.Next() {\n\t\t\t\tr := &ReplyUser{Avatar: ruser.Avatar, MicroAvatar: ruser.MicroAvatar, UserLink: ruser.Link, CreatedByName: ruser.Name, Group: ruser.Group, Level: ruser.Level}\n\t\t\t\te := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.LikeCount, &r.AttachCount, &r.ActionType)\n\t\t\t\tif e != nil {\n\t\t\t\t\treturn nil, externalHead, e\n\t\t\t\t}\n\t\t\t\tif e = rf3(r); e != nil {\n\t\t\t\t\treturn nil, externalHead, e\n\t\t\t\t}\n\t\t\t\trf2(r)\n\t\t\t\tap1(r)\n\t\t\t}\n\t\t\tif e = rows.Err(); e != nil {\n\t\t\t\treturn nil, externalHead, e\n\t\t\t}\n\t\t} else if user.Perms.ViewIPs {\n\t\t\trows, err := topicStmts.getReplies.Query(t.ID, offset, Config.ItemsPerPage)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\tfor rows.Next() {\n\t\t\t\tr := &ReplyUser{}\n\t\t\t\terr := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t}\n\t\t\t\tif err = rf(r); err != nil {\n\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t}\n\t\t\t\trf2(r)\n\t\t\t\tap1(r)\n\t\t\t}\n\t\t\tif err = rows.Err(); err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\t\t} else if t.PostCount >= 20 {\n\t\t\t//log.Print(\"t.PostCount >= 20\")\n\t\t\trows, err := topicStmts.getReplies3.Query(t.ID, offset, Config.ItemsPerPage)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\treqUserList := make(map[int]bool)\n\t\t\tfor rows.Next() {\n\t\t\t\tr := &ReplyUser{}\n\t\t\t\terr := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy /*&r.URLPrefix, &r.URLName,*/, &r.LikeCount, &r.AttachCount, &r.ActionType)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t}\n\t\t\t\tif r.CreatedBy != t.CreatedBy && r.CreatedBy != user.ID {\n\t\t\t\t\treqUserList[r.CreatedBy] = true\n\t\t\t\t}\n\t\t\t\tap1(r)\n\t\t\t}\n\t\t\tif err = rows.Err(); err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\n\t\t\tif len(reqUserList) == 1 {\n\t\t\t\t//log.Print(\"len(reqUserList) == 1: \", len(reqUserList) == 1)\n\t\t\t\tvar uitem *User\n\t\t\t\tfor uid, _ := range reqUserList {\n\t\t\t\t\tuitem, err = Users.Get(uid)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, externalHead, nil // TODO: Implement this!\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfor _, r := range rlist {\n\t\t\t\t\tif r.CreatedBy == t.CreatedBy {\n\t\t\t\t\t\tr.CreatedByName = t.CreatedByName\n\t\t\t\t\t\tr.Avatar = t.Avatar\n\t\t\t\t\t\tr.MicroAvatar = t.MicroAvatar\n\t\t\t\t\t\tr.Group = t.Group\n\t\t\t\t\t\tr.Level = t.Level\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar u *User\n\t\t\t\t\t\tif r.CreatedBy == user.ID {\n\t\t\t\t\t\t\tu = user\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tu = uitem\n\t\t\t\t\t\t}\n\t\t\t\t\t\tr.CreatedByName = u.Name\n\t\t\t\t\t\tr.Avatar = u.Avatar\n\t\t\t\t\t\tr.MicroAvatar = u.MicroAvatar\n\t\t\t\t\t\tr.Group = u.Group\n\t\t\t\t\t\tr.Level = u.Level\n\t\t\t\t\t}\n\t\t\t\t\tif err = rf(r); err != nil {\n\t\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t\t}\n\t\t\t\t\trf2(r)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t//log.Print(\"len(reqUserList) != 1: \", len(reqUserList) != 1)\n\t\t\t\tvar userList map[int]*User\n\t\t\t\tif len(reqUserList) > 0 {\n\t\t\t\t\t//log.Print(\"len(reqUserList) > 0: \", len(reqUserList) > 0)\n\t\t\t\t\t// Convert the user ID map to a slice, then bulk load the users\n\t\t\t\t\tidSlice := make([]int, len(reqUserList))\n\t\t\t\t\tvar i int\n\t\t\t\t\tfor userID := range reqUserList {\n\t\t\t\t\t\tidSlice[i] = userID\n\t\t\t\t\t\ti++\n\t\t\t\t\t}\n\t\t\t\t\tuserList, err = Users.BulkGetMap(idSlice)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, externalHead, nil // TODO: Implement this!\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfor _, r := range rlist {\n\t\t\t\t\tif r.CreatedBy == t.CreatedBy {\n\t\t\t\t\t\tr.CreatedByName = t.CreatedByName\n\t\t\t\t\t\tr.Avatar = t.Avatar\n\t\t\t\t\t\tr.MicroAvatar = t.MicroAvatar\n\t\t\t\t\t\tr.Group = t.Group\n\t\t\t\t\t\tr.Level = t.Level\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar u *User\n\t\t\t\t\t\tif r.CreatedBy == user.ID {\n\t\t\t\t\t\t\tu = user\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tu = userList[r.CreatedBy]\n\t\t\t\t\t\t}\n\t\t\t\t\t\tr.CreatedByName = u.Name\n\t\t\t\t\t\tr.Avatar = u.Avatar\n\t\t\t\t\t\tr.MicroAvatar = u.MicroAvatar\n\t\t\t\t\t\tr.Group = u.Group\n\t\t\t\t\t\tr.Level = u.Level\n\t\t\t\t\t}\n\t\t\t\t\tif err = rf(r); err != nil {\n\t\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t\t}\n\t\t\t\t\trf2(r)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t//log.Print(\"reply fallback\")\n\t\t\trows, err := topicStmts.getReplies2.Query(t.ID, offset, Config.ItemsPerPage)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\tfor rows.Next() {\n\t\t\t\tr := &ReplyUser{}\n\t\t\t\terr := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &r.Group /*&r.URLPrefix, &r.URLName,*/, &r.Level, &r.LikeCount, &r.AttachCount, &r.ActionType)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t}\n\t\t\t\tif err = rf(r); err != nil {\n\t\t\t\t\treturn nil, externalHead, err\n\t\t\t\t}\n\t\t\t\trf2(r)\n\t\t\t\tap1(r)\n\t\t\t}\n\t\t\tif err = rows.Err(); err != nil {\n\t\t\t\treturn nil, externalHead, err\n\t\t\t}\n\t\t}\n\t}\n\n\t// TODO: Add a config setting to disable the liked query for a burst of extra speed\n\tif user.Liked > 0 && len(likedQueryList) > 0 /*&& user.LastLiked <= time.Now()*/ {\n\t\te := Likes.BulkExistsFunc(likedQueryList, user.ID, \"replies\", func(eid int) error {\n\t\t\trlist[likedMap[eid]].Liked = true\n\t\t\treturn nil\n\t\t})\n\t\tif e != nil {\n\t\t\treturn nil, externalHead, e\n\t\t}\n\t}\n\n\tif user.Perms.EditReply && len(attachQueryList) > 0 {\n\t\t//log.Printf(\"attachQueryList: %+v\\n\", attachQueryList)\n\t\tamap, err := Attachments.BulkMiniGetList(\"replies\", attachQueryList)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn nil, externalHead, err\n\t\t}\n\t\t//log.Printf(\"amap: %+v\\n\", amap)\n\t\t//log.Printf(\"attachMap: %+v\\n\", attachMap)\n\t\tfor id, attach := range amap {\n\t\t\t//log.Print(\"id:\", id)\n\t\t\trlist[attachMap[id]].Attachments = attach\n\t\t\t/*for _, a := range attach {\n\t\t\t\tlog.Printf(\"a: %+v\\n\", a)\n\t\t\t}*/\n\t\t}\n\t}\n\n\t//hTbl.VhookNoRet(\"topic_reply_end\", &rlist)\n\n\treturn rlist, externalHead, nil\n}\n\n// TODO: Test this\nfunc (t *Topic) Author() (*User, error) {\n\treturn Users.Get(t.CreatedBy)\n}\n\nfunc (t *Topic) GetID() int {\n\treturn t.ID\n}\nfunc (t *Topic) GetTable() string {\n\treturn \"topics\"\n}\n\n// Copy gives you a non-pointer concurrency safe copy of the topic\nfunc (t *Topic) Copy() Topic {\n\treturn *t\n}\n\n// TODO: Load LastReplyAt and LastReplyID?\nfunc TopicByReplyID(rid int) (*Topic, error) {\n\tt := Topic{ID: 0}\n\terr := topicStmts.getByReplyID.QueryRow(rid).Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.Poll, &t.Data)\n\tt.Link = BuildTopicURL(NameToSlug(t.Title), t.ID)\n\treturn &t, err\n}\n\n// TODO: Refactor the caller to take a Topic and a User rather than a combined TopicUser\n// TODO: Load LastReplyAt everywhere in here?\nfunc GetTopicUser(user *User, tid int) (tu TopicUser, err error) {\n\ttcache := Topics.GetCache()\n\tucache := Users.GetCache()\n\tif tcache != nil && ucache != nil {\n\t\ttopic, err := tcache.Get(tid)\n\t\tif err == nil {\n\t\t\tif topic.CreatedBy != user.ID {\n\t\t\t\tuser, err = Users.Get(topic.CreatedBy)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn TopicUser{ID: tid}, err\n\t\t\t\t}\n\t\t\t}\n\t\t\t// We might be better off just passing separate topic and user structs to the caller?\n\t\t\treturn copyTopicToTopicUser(topic, user), nil\n\t\t} else if ucache.Length() < ucache.GetCapacity() {\n\t\t\ttopic, err = Topics.Get(tid)\n\t\t\tif err != nil {\n\t\t\t\treturn TopicUser{ID: tid}, err\n\t\t\t}\n\t\t\tif topic.CreatedBy != user.ID {\n\t\t\t\tuser, err = Users.Get(topic.CreatedBy)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn TopicUser{ID: tid}, err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn copyTopicToTopicUser(topic, user), nil\n\t\t}\n\t}\n\n\ttu = TopicUser{ID: tid}\n\t// TODO: This misses some important bits...\n\terr = topicStmts.getTopicUser.QueryRow(tid).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.LastReplyAt, &tu.LastReplyBy, &tu.LastReplyID, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.ViewCount, &tu.PostCount, &tu.LikeCount, &tu.AttachCount, &tu.Poll, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level)\n\ttu.Avatar, tu.MicroAvatar = BuildAvatar(tu.CreatedBy, tu.Avatar)\n\ttu.Link = BuildTopicURL(NameToSlug(tu.Title), tu.ID)\n\ttu.UserLink = BuildProfileURL(NameToSlug(tu.CreatedByName), tu.CreatedBy)\n\ttu.Tag = Groups.DirtyGet(tu.Group).Tag\n\n\tif tcache != nil {\n\t\t// TODO: weekly views\n\t\ttheTopic := Topic{ID: tu.ID, Link: tu.Link, Title: tu.Title, Content: tu.Content, CreatedBy: tu.CreatedBy, IsClosed: tu.IsClosed, Sticky: tu.Sticky, CreatedAt: tu.CreatedAt, LastReplyAt: tu.LastReplyAt, LastReplyID: tu.LastReplyID, ParentID: tu.ParentID, IP: tu.IP, ViewCount: tu.ViewCount, PostCount: tu.PostCount, LikeCount: tu.LikeCount, AttachCount: tu.AttachCount, Poll: tu.Poll}\n\t\t//log.Printf(\"theTopic: %+v\\n\", theTopic)\n\t\t_ = tcache.Set(&theTopic)\n\t}\n\treturn tu, err\n}\n\nfunc copyTopicToTopicUser(t *Topic, u *User) (tu TopicUser) {\n\ttu.UserLink = u.Link\n\ttu.CreatedByName = u.Name\n\ttu.Group = u.Group\n\ttu.Avatar = u.Avatar\n\ttu.MicroAvatar = u.MicroAvatar\n\t//tu.URLPrefix = u.URLPrefix\n\t//tu.URLName = u.URLName\n\ttu.Level = u.Level\n\n\ttu.ID = t.ID\n\ttu.Link = t.Link\n\ttu.Title = t.Title\n\ttu.Content = t.Content\n\ttu.CreatedBy = t.CreatedBy\n\ttu.IsClosed = t.IsClosed\n\ttu.Sticky = t.Sticky\n\ttu.CreatedAt = t.CreatedAt\n\ttu.LastReplyAt = t.LastReplyAt\n\ttu.LastReplyBy = t.LastReplyBy\n\ttu.ParentID = t.ParentID\n\ttu.IP = t.IP\n\ttu.ViewCount = t.ViewCount\n\ttu.PostCount = t.PostCount\n\ttu.LikeCount = t.LikeCount\n\ttu.AttachCount = t.AttachCount\n\ttu.Poll = t.Poll\n\ttu.Data = t.Data\n\ttu.Rids = t.Rids\n\n\treturn tu\n}\n\n// For use in tests and for generating blank topics for forums which don't have a last poster\nfunc BlankTopic() *Topic {\n\treturn new(Topic)\n}\n\nfunc BuildTopicURL(slug string, tid int) string {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\treturn \"/topic/\" + strconv.Itoa(tid)\n\t}\n\treturn \"/topic/\" + slug + \".\" + strconv.Itoa(tid)\n}\n\nfunc BuildTopicURLSb(sb *strings.Builder, slug string, tid int) {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\tsb.Grow(7 + 2)\n\t\tsb.WriteString(\"/topic/\")\n\t\tsb.WriteString(strconv.Itoa(tid))\n\t\treturn\n\t}\n\tsb.Grow(7 + 3 + len(slug))\n\tsb.WriteString(\"/topic/\")\n\tsb.WriteString(slug)\n\tsb.WriteRune('.')\n\tsb.WriteString(strconv.Itoa(tid))\n}\n\n// I don't care if it isn't used,, it will likely be in the future. Nolint.\n// nolint\nfunc getTopicURLPrefix() string {\n\treturn \"/topic/\"\n}\n"
  },
  {
    "path": "common/topic_cache.go",
    "content": "package common\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// TopicCache is an interface which spits out topics from a fast cache rather than the database, whether from memory or from an application like Redis. Topics may not be present in the cache but may be in the database\ntype TopicCache interface {\n\tGet(id int) (*Topic, error)\n\tGetUnsafe(id int) (*Topic, error)\n\tBulkGet(ids []int) (list []*Topic)\n\tSet(item *Topic) error\n\tAdd(item *Topic) error\n\tAddUnsafe(item *Topic) error\n\tRemove(id int) error\n\tRemoveUnsafe(id int) error\n\tRemoveMany(ids []int) error\n\tFlush()\n\tLength() int\n\tSetCapacity(cap int)\n\tGetCapacity() int\n}\n\n// MemoryTopicCache stores and pulls topics out of the current process' memory\ntype MemoryTopicCache struct {\n\titems    map[int]*Topic\n\tlength   int64 // sync/atomic only lets us operate on int32s and int64s\n\tcapacity int\n\n\tsync.RWMutex\n}\n\n// NewMemoryTopicCache gives you a new instance of MemoryTopicCache\nfunc NewMemoryTopicCache(cap int) *MemoryTopicCache {\n\treturn &MemoryTopicCache{\n\t\titems:    make(map[int]*Topic),\n\t\tcapacity: cap,\n\t}\n}\n\n// Get fetches a topic by ID. Returns ErrNoRows if not present.\nfunc (s *MemoryTopicCache) Get(id int) (*Topic, error) {\n\ts.RLock()\n\titem, ok := s.items[id]\n\ts.RUnlock()\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// GetUnsafe fetches a topic by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryTopicCache) GetUnsafe(id int) (*Topic, error) {\n\titem, ok := s.items[id]\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// BulkGet fetches multiple topics by their IDs. Indices without topics will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing.\nfunc (s *MemoryTopicCache) BulkGet(ids []int) (list []*Topic) {\n\tlist = make([]*Topic, len(ids))\n\ts.RLock()\n\tfor i, id := range ids {\n\t\tlist[i] = s.items[id]\n\t}\n\ts.RUnlock()\n\treturn list\n}\n\n// Set overwrites the value of a topic in the cache, whether it's present or not. May return a capacity overflow error.\nfunc (s *MemoryTopicCache) Set(it *Topic) error {\n\ts.Lock()\n\t_, ok := s.items[it.ID]\n\tif ok {\n\t\ts.items[it.ID] = it\n\t} else if int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t} else {\n\t\ts.items[it.ID] = it\n\t\tatomic.AddInt64(&s.length, 1)\n\t}\n\ts.Unlock()\n\treturn nil\n}\n\n// Add adds a topic to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error.\n// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used?\nfunc (s *MemoryTopicCache) Add(item *Topic) error {\n\ts.Lock()\n\tif int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, 1)\n\treturn nil\n}\n\n// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryTopicCache) AddUnsafe(item *Topic) error {\n\tif int(s.length) >= s.capacity {\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\treturn nil\n}\n\n// Remove removes a topic from the cache by ID, if they exist. Returns ErrNoRows if no items exist.\nfunc (s *MemoryTopicCache) Remove(id int) error {\n\tvar ok bool\n\ts.Lock()\n\tif _, ok = s.items[id]; !ok {\n\t\ts.Unlock()\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\nfunc (s *MemoryTopicCache) RemoveMany(ids []int) error {\n\tvar n int64\n\tvar ok bool\n\ts.Lock()\n\tfor _, id := range ids {\n\t\tif _, ok = s.items[id]; ok {\n\t\t\tdelete(s.items, id)\n\t\t\tn++\n\t\t}\n\t}\n\tatomic.AddInt64(&s.length, -n)\n\ts.Unlock()\n\treturn nil\n}\n\n// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryTopicCache) RemoveUnsafe(id int) error {\n\tif _, ok := s.items[id]; !ok {\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// Flush removes all the topics from the cache, useful for tests.\nfunc (s *MemoryTopicCache) Flush() {\n\ts.Lock()\n\ts.items = make(map[int]*Topic)\n\ts.length = 0\n\ts.Unlock()\n}\n\n// ! Is this concurrent?\n// Length returns the number of topics in the memory cache\nfunc (s *MemoryTopicCache) Length() int {\n\treturn int(s.length)\n}\n\n// SetCapacity sets the maximum number of topics which this cache can hold\nfunc (s *MemoryTopicCache) SetCapacity(cap int) {\n\t// Ints are moved in a single instruction, so this should be thread-safe\n\ts.capacity = cap\n}\n\n// GetCapacity returns the maximum number of topics this cache can hold\nfunc (s *MemoryTopicCache) GetCapacity() int {\n\treturn s.capacity\n}\n"
  },
  {
    "path": "common/topic_list.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar TopicList TopicListInt\n\nconst (\n\tTopicListDefault = iota\n\tTopicListMostViewed\n\tTopicListWeekViews\n)\n\ntype TopicListHolder struct {\n\tList      []*TopicsRow\n\tForumList []Forum\n\tPaginator Paginator\n}\n\ntype ForumTopicListHolder struct {\n\tList      []*TopicsRow\n\tPaginator Paginator\n}\n\n// TODO: Should we return no rows errors on empty pages? Is this likely to break something?\ntype TopicListInt interface {\n\tGetListByCanSee(canSee []int, page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error)\n\tGetListByGroup(g *Group, page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error)\n\tGetListByForum(f *Forum, page, orderby int) (topicList []*TopicsRow, pagi Paginator, err error)\n\tGetList(page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error)\n}\n\ntype TopicListIntTest interface {\n\tRawGetListByForum(f *Forum, page, orderby int) (topicList []*TopicsRow, pagi Paginator, err error)\n\tTick() error\n}\n\ntype DefaultTopicList struct {\n\t// TODO: Rewrite this to put permTree as the primary and put canSeeStr on each group?\n\toddGroups  map[int][2]*TopicListHolder\n\tevenGroups map[int][2]*TopicListHolder\n\toddLock    sync.RWMutex\n\tevenLock   sync.RWMutex\n\n\tforums    map[int]*ForumTopicListHolder\n\tforumLock sync.RWMutex\n\n\tqcounts  map[int]*sql.Stmt\n\tqcounts2 map[int]*sql.Stmt\n\tqLock    sync.RWMutex\n\tqLock2   sync.RWMutex\n\n\t//permTree atomic.Value // [string(canSee)]canSee\n\t//permTree map[string][]int // [string(canSee)]canSee\n\n\tgetTopicsByForum *sql.Stmt\n\t//getTidsByForum *sql.Stmt\n}\n\n// We've removed the topic list cache cap as admins really shouldn't be abusing groups like this with plugin_guilds around and it was extremely fiddly.\n// If this becomes a problem later on, then we can revisit this with a fresh perspective, particularly with regards to what people expect a group to really be\n// Also, keep in mind that as-long as the groups don't all have unique sets of forums they can see, then we can optimise a large portion of the work away.\nfunc NewDefaultTopicList(acc *qgen.Accumulator) (*DefaultTopicList, error) {\n\ttList := &DefaultTopicList{\n\t\toddGroups:        make(map[int][2]*TopicListHolder),\n\t\tevenGroups:       make(map[int][2]*TopicListHolder),\n\t\tforums:           make(map[int]*ForumTopicListHolder),\n\t\tqcounts:          make(map[int]*sql.Stmt),\n\t\tqcounts2:         make(map[int]*sql.Stmt),\n\t\tgetTopicsByForum: acc.Select(\"topics\").Columns(\"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,views,postCount,likeCount\").Where(\"parentID=?\").Orderby(\"sticky DESC,lastReplyAt DESC,createdBy DESC\").Limit(\"?,?\").Prepare(),\n\t\t//getTidsByForum: acc.Select(\"topics\").Columns(\"tid\").Where(\"parentID=?\").Orderby(\"sticky DESC,lastReplyAt DESC,createdBy DESC\").Limit(\"?,?\").Prepare(),\n\t}\n\tif e := acc.FirstError(); e != nil {\n\t\treturn nil, e\n\t}\n\tif e := tList.Tick(); e != nil {\n\t\treturn nil, e\n\t}\n\n\tTasks.HalfSec.Add(tList.Tick)\n\t//Tasks.Sec.Add(tList.GroupCountTick) // TODO: Dynamically change the groups in the short list to be optimised every second\n\treturn tList, nil\n}\n\nfunc (tList *DefaultTopicList) Tick() error {\n\t//fmt.Println(\"TopicList.Tick\")\n\tif !TopicListThaw.Thawed() {\n\t\treturn nil\n\t}\n\t//fmt.Println(\"building topic list\")\n\n\toddLists := make(map[int][2]*TopicListHolder)\n\tevenLists := make(map[int][2]*TopicListHolder)\n\taddList := func(gid int, h [2]*TopicListHolder) {\n\t\tif gid%2 == 0 {\n\t\t\tevenLists[gid] = h\n\t\t} else {\n\t\t\toddLists[gid] = h\n\t\t}\n\t}\n\n\tallGroups, err := Groups.GetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgidToCanSee := make(map[int]string)\n\tpermTree := make(map[string][]int) // [string(canSee)]canSee\n\tfor _, g := range allGroups {\n\t\t// ? - Move the user count check to instance initialisation? Might require more book-keeping, particularly when a user moves into a zero user group\n\t\tif g.UserCount == 0 && g.ID != GuestUser.Group {\n\t\t\tcontinue\n\t\t}\n\n\t\tcanSee := make([]byte, len(g.CanSee))\n\t\tfor i, item := range g.CanSee {\n\t\t\tcanSee[i] = byte(item)\n\t\t}\n\n\t\tcanSeeInt := make([]int, len(canSee))\n\t\tcopy(canSeeInt, g.CanSee)\n\t\tsCanSee := string(canSee)\n\t\tpermTree[sCanSee] = canSeeInt\n\t\tgidToCanSee[g.ID] = sCanSee\n\t}\n\n\tcanSeeHolders := make(map[string][2]*TopicListHolder)\n\tforumCounts := make(map[int]int)\n\tfor name, canSee := range permTree {\n\t\ttopicList, forumList, pagi, err := tList.GetListByCanSee(canSee, 1, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttopicList2, forumList2, pagi2, err := tList.GetListByCanSee(canSee, 2, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcanSeeHolders[name] = [2]*TopicListHolder{\n\t\t\t{topicList, forumList, pagi},\n\t\t\t{topicList2, forumList2, pagi2},\n\t\t}\n\t\tif len(canSee) > 1 {\n\t\t\tforumCounts[len(canSee)] += 1\n\t\t}\n\t}\n\tfor gid, canSee := range gidToCanSee {\n\t\taddList(gid, canSeeHolders[canSee])\n\t}\n\n\ttList.oddLock.Lock()\n\ttList.oddGroups = oddLists\n\ttList.oddLock.Unlock()\n\n\ttList.evenLock.Lock()\n\ttList.evenGroups = evenLists\n\ttList.evenLock.Unlock()\n\n\ttopc := []int{0, 0, 0, 0, 0, 0}\n\taddC := func(c int) {\n\t\tlowI, low := 0, topc[0]\n\t\tfor i, top := range topc {\n\t\t\tif top < low {\n\t\t\t\tlowI = i\n\t\t\t\tlow = top\n\t\t\t}\n\t\t}\n\t\tif c > low {\n\t\t\ttopc[lowI] = c\n\t\t}\n\t}\n\tfor forumCount := range forumCounts {\n\t\taddC(forumCount)\n\t}\n\n\tqcounts := make(map[int]*sql.Stmt)\n\tqcounts2 := make(map[int]*sql.Stmt)\n\tfor _, top := range topc {\n\t\tif top == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tqlist := inqbuild2(top - 1)\n\t\tcols := \"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,parentID,views,postCount,likeCount,attachCount,poll,data\"\n\n\t\tstmt, err := qgen.Builder.SimpleSelect(\"topics\", cols, \"parentID IN(\"+qlist+\")\", \"views DESC,lastReplyAt DESC,createdBy DESC\", \"?,?\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tqcounts[top] = stmt\n\n\t\tstmt, err = qgen.Builder.SimpleSelect(\"topics\", cols, \"parentID IN(\"+qlist+\")\", \"sticky DESC,lastReplyAt DESC,createdBy DESC\", \"?,?\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tqcounts2[top] = stmt\n\t}\n\n\ttList.qLock.Lock()\n\ttList.qcounts = qcounts\n\ttList.qLock.Unlock()\n\n\ttList.qLock2.Lock()\n\ttList.qcounts2 = qcounts2\n\ttList.qLock2.Unlock()\n\n\tfmt.Printf(\"Forums: %+v\\n\", Forums)\n\tforums, err := Forums.GetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttop8 := []*Forum{nil, nil, nil, nil, nil, nil, nil, nil}\n\tz := true\n\taddScore2 := func(f *Forum) {\n\t\tfor i, top := range top8 {\n\t\t\tif top.TopicCount < f.TopicCount {\n\t\t\t\ttop8[i] = f\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\taddScore := func(f *Forum) {\n\t\tif z {\n\t\t\tfor i, top := range top8 {\n\t\t\t\tif top == nil {\n\t\t\t\t\ttop8[i] = f\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tz = false\n\t\t\taddScore2(f)\n\t\t}\n\t\taddScore2(f)\n\t}\n\n\tvar fshort []*Forum\n\tfor _, f := range forums {\n\t\tif f.Name == \"\" || !f.Active || (f.ParentType != \"\" && f.ParentType != \"forum\") {\n\t\t\tcontinue\n\t\t}\n\t\tif f.TopicCount == 0 {\n\t\t\tfshort = append(fshort, f)\n\t\t\tcontinue\n\t\t}\n\t\taddScore(f)\n\t}\n\tfor _, f := range top8 {\n\t\tif f != nil {\n\t\t\tfshort = append(fshort, f)\n\t\t}\n\t}\n\n\t// TODO: Avoid rebuilding the entire list on every tick\n\tfList := make(map[int]*ForumTopicListHolder)\n\tfor _, f := range fshort {\n\t\ttopicList, pagi := []*TopicsRow{}, tList.defaultPagi()\n\t\tif f.TopicCount != 0 {\n\t\t\ttopicList, pagi, err = tList.RawGetListByForum(f, 1, 0)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tfList[f.ID] = &ForumTopicListHolder{topicList, pagi}\n\n\t\t/*topicList, pagi, err := tList.GetListByForum(f, 1, 0)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfList[f.ID] = &ForumTopicListHolder{topicList, pagi}*/\n\t}\n\n\t//fmt.Printf(\"fList: %+v\\n\", fList)\n\ttList.setForumList(fList)\n\n\thTbl := GetHookTable()\n\t_, _ = hTbl.VhookSkippable(\"tasks_tick_topic_list\", tList)\n\n\treturn nil\n}\n\nfunc (tList *DefaultTopicList) defaultPagi() Paginator {\n\t/*_, page, lastPage := PageOffset(f.TopicCount, page, Config.ItemsPerPage)\n\tpageList := Paginate(page, lastPage, 5)\n\treturn topicList, Paginator{pageList, page, lastPage}, nil*/\n\treturn Paginator{[]int{}, 1, 1}\n}\n\nfunc (tList *DefaultTopicList) setForumList(forums map[int]*ForumTopicListHolder) {\n\ttList.forumLock.Lock()\n\ttList.forums = forums\n\ttList.forumLock.Unlock()\n}\n\n/*var reloadForumMutex sync.Mutex\n\n// TODO: Avoid firing this multiple times per sec tick\n// TODO: Shard the forum topic list map\nfunc (tList *DefaultTopicList) ReloadForum(id int) error {\n\treloadForumMutex.Lock()\n\tdefer reloadForumMutex.Unlock()\n\n\tforum, err := Forums.Get(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tofList := make(map[int]*ForumTopicListHolder)\n\tfList := make(map[int]*ForumTopicListHolder)\n\ttList.forumLock.Lock()\n\tofList = tList.forums\n\tfor id, f := range ofList {\n\t\tfList[id] = f\n\t}\n\ttList.forumLock.Unlock()\n\n\ttopicList, pagi := []*TopicsRow{}, tList.defaultPagi()\n\tif forum.TopicCount != 0 {\n\t\ttopicList, pagi, err = tList.getListByForum(forum, 1, 0)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfList[forum.ID] = &ForumTopicListHolder{topicList, pagi}\n\n\ttList.setForumList(fList)\n\treturn nil\n}*/\n\n// TODO: Add Topics() method to *Forum?\n// TODO: Implement orderby\nfunc (tList *DefaultTopicList) GetListByForum(f *Forum, page, orderby int) (topicList []*TopicsRow, pagi Paginator, err error) {\n\tif page == 0 {\n\t\tpage = 1\n\t}\n\tif f.TopicCount == 0 {\n\t\treturn topicList, tList.defaultPagi(), nil\n\t}\n\tif page == 1 && orderby == 0 {\n\t\tvar h *ForumTopicListHolder\n\t\tvar ok bool\n\t\ttList.forumLock.RLock()\n\t\th, ok = tList.forums[f.ID]\n\t\ttList.forumLock.RUnlock()\n\t\tif ok {\n\t\t\treturn h.List, h.Paginator, nil\n\t\t}\n\t}\n\treturn tList.RawGetListByForum(f, page, orderby)\n}\n\nfunc (tList *DefaultTopicList) RawGetListByForum(f *Forum, page, orderby int) (topicList []*TopicsRow, pagi Paginator, err error) {\n\t// TODO: Does forum.TopicCount take the deleted items into consideration for guests? We don't have soft-delete yet, only hard-delete\n\toffset, page, lastPage := PageOffset(f.TopicCount, page, Config.ItemsPerPage)\n\n\trows, err := tList.getTopicsByForum.Query(f.ID, offset, Config.ItemsPerPage)\n\tif err != nil {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\tdefer rows.Close()\n\n\t// TODO: Use something other than TopicsRow as we don't need to store the forum name and link on each and every topic item?\n\treqUserList := make(map[int]bool)\n\tfor rows.Next() {\n\t\tt := TopicsRow{Topic: Topic{ParentID: f.ID}}\n\t\terr := rows.Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.IsClosed, &t.Sticky, &t.CreatedAt, &t.LastReplyAt, &t.LastReplyBy, &t.LastReplyID, &t.ViewCount, &t.PostCount, &t.LikeCount)\n\t\tif err != nil {\n\t\t\treturn nil, tList.defaultPagi(), err\n\t\t}\n\n\t\tt.Link = BuildTopicURL(NameToSlug(t.Title), t.ID)\n\t\t// TODO: Create a specialised function with a bit less overhead for getting the last page for a post count\n\t\t_, _, lastPage := PageOffset(t.PostCount, 1, Config.ItemsPerPage)\n\t\tt.LastPage = lastPage\n\n\t\t//header.Hooks.VhookNoRet(\"forum_trow_assign\", &t, &forum)\n\t\ttopicList = append(topicList, &t)\n\t\treqUserList[t.CreatedBy] = true\n\t\treqUserList[t.LastReplyBy] = true\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\n\t// Convert the user ID map to a slice, then bulk load the users\n\tidSlice := make([]int, len(reqUserList))\n\tvar i int\n\tfor userID := range reqUserList {\n\t\tidSlice[i] = userID\n\t\ti++\n\t}\n\n\t// TODO: What if a user is deleted via the Control Panel?\n\tuserList, err := Users.BulkGetMap(idSlice)\n\tif err != nil {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\n\t// Second pass to the add the user data\n\t// TODO: Use a pointer to TopicsRow instead of TopicsRow itself?\n\tfor _, t := range topicList {\n\t\tt.Creator = userList[t.CreatedBy]\n\t\tt.LastUser = userList[t.LastReplyBy]\n\t}\n\n\tif len(topicList) == 0 {\n\t\treturn topicList, tList.defaultPagi(), nil\n\t}\n\tpageList := Paginate(page, lastPage, 5)\n\treturn topicList, Paginator{pageList, page, lastPage}, nil\n}\n\nfunc (tList *DefaultTopicList) GetListByGroup(g *Group, page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error) {\n\tif page == 0 {\n\t\tpage = 1\n\t}\n\t// TODO: Cache the first three pages not just the first along with all the topics on this beaten track\n\t// TODO: Move this into CanSee to reduce redundancy\n\tif (page == 1 || page == 2) && orderby == 0 && len(filterIDs) == 0 {\n\t\tvar h [2]*TopicListHolder\n\t\tvar ok bool\n\t\tif g.ID%2 == 0 {\n\t\t\ttList.evenLock.RLock()\n\t\t\th, ok = tList.evenGroups[g.ID]\n\t\t\ttList.evenLock.RUnlock()\n\t\t} else {\n\t\t\ttList.oddLock.RLock()\n\t\t\th, ok = tList.oddGroups[g.ID]\n\t\t\ttList.oddLock.RUnlock()\n\t\t}\n\t\tif ok {\n\t\t\treturn h[page-1].List, h[page-1].ForumList, h[page-1].Paginator, nil\n\t\t}\n\t}\n\n\t// TODO: Make CanSee a method on *Group with a canSee field? Have a CanSee method on *User to cover the case of superadmins?\n\t//log.Printf(\"deoptimising for %d on page %d\\n\", g.ID, page)\n\treturn tList.GetListByCanSee(g.CanSee, page, orderby, filterIDs)\n}\n\nfunc (tList *DefaultTopicList) GetListByCanSee(canSee []int, page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error) {\n\t// TODO: Optimise this by filtering canSee and then fetching the forums?\n\t// We need a list of the visible forums for Quick Topic\n\t// ? - Would it be useful, if we could post in social groups from /topics/?\n\tfor _, fid := range canSee {\n\t\tf := Forums.DirtyGet(fid)\n\t\tif f.Name != \"\" && f.Active && (f.ParentType == \"\" || f.ParentType == \"forum\") /*&& f.TopicCount != 0*/ {\n\t\t\tfcopy := f.Copy()\n\t\t\t// TODO: Add a hook here for plugin_guilds !!\n\t\t\tforumList = append(forumList, fcopy)\n\t\t}\n\t}\n\n\tinSlice := func(haystack []int, needle int) bool {\n\t\tfor _, it := range haystack {\n\t\t\tif needle == it {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar filteredForums []Forum\n\tif len(filterIDs) > 0 {\n\t\tfor _, f := range forumList {\n\t\t\tif inSlice(filterIDs, f.ID) {\n\t\t\t\tfilteredForums = append(filteredForums, f)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tfilteredForums = forumList\n\t}\n\tif len(filteredForums) == 1 && orderby == 0 {\n\t\ttopicList, pagi, err = tList.GetListByForum(&filteredForums[0], page, orderby)\n\t\treturn topicList, forumList, pagi, err\n\t}\n\n\tvar topicCount int\n\tfor _, f := range filteredForums {\n\t\ttopicCount += f.TopicCount\n\t}\n\n\t// ? - Should we be showing plugin_guilds posts on /topics/?\n\targList, qlist := ForumListToArgQ(filteredForums)\n\tif qlist == \"\" {\n\t\t// We don't want to kill the page, so pass an empty slice and nil error\n\t\treturn topicList, filteredForums, tList.defaultPagi(), nil\n\t}\n\n\ttopicList, pagi, err = tList.getList(page, orderby, topicCount, argList, qlist)\n\treturn topicList, filteredForums, pagi, err\n}\n\n// TODO: Reduce the number of returns\nfunc (tList *DefaultTopicList) GetList(page, orderby int, filterIDs []int) (topicList []*TopicsRow, forumList []Forum, pagi Paginator, err error) {\n\t// TODO: Make CanSee a method on *Group with a canSee field? Have a CanSee method on *User to cover the case of superadmins?\n\tcCanSee, err := Forums.GetAllVisibleIDs()\n\tif err != nil {\n\t\treturn nil, nil, tList.defaultPagi(), err\n\t}\n\t//log.Printf(\"cCanSee: %+v\\n\", cCanSee)\n\tinSlice := func(haystack []int, needle int) bool {\n\t\tfor _, it := range haystack {\n\t\t\tif needle == it {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar canSee []int\n\tif len(filterIDs) > 0 {\n\t\tfor _, fid := range cCanSee {\n\t\t\tif inSlice(filterIDs, fid) {\n\t\t\t\tcanSee = append(canSee, fid)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tcanSee = cCanSee\n\t}\n\t//log.Printf(\"canSee: %+v\\n\", canSee)\n\n\t// We need a list of the visible forums for Quick Topic\n\t// ? - Would it be useful, if we could post in social groups from /topics/?\n\tvar topicCount int\n\tfor _, fid := range canSee {\n\t\tf := Forums.DirtyGet(fid)\n\t\tif f.Name != \"\" && f.Active && (f.ParentType == \"\" || f.ParentType == \"forum\") /*&& f.TopicCount != 0*/ {\n\t\t\tfcopy := f.Copy()\n\t\t\t// TODO: Add a hook here for plugin_guilds\n\t\t\tforumList = append(forumList, fcopy)\n\t\t\ttopicCount += fcopy.TopicCount\n\t\t}\n\t}\n\tif len(forumList) == 1 && orderby == 0 {\n\t\ttopicList, pagi, err = tList.GetListByForum(&forumList[0], page, orderby)\n\t\treturn topicList, forumList, pagi, err\n\t}\n\n\t// ? - Should we be showing plugin_guilds posts on /topics/?\n\targList, qlist := ForumListToArgQ(forumList)\n\tif qlist == \"\" {\n\t\t// If the super admin can't see anything, then things have gone terribly wrong\n\t\treturn topicList, forumList, tList.defaultPagi(), err\n\t}\n\n\ttopicList, pagi, err = tList.getList(page, orderby, topicCount, argList, qlist)\n\treturn topicList, forumList, pagi, err\n}\n\n// TODO: Rename this to TopicListStore and pass back a TopicList instance holding the pagination data and topic list rather than passing them back one argument at a time\n// TODO: Make orderby an enum of sorts\nfunc (tList *DefaultTopicList) getList(page, orderby, topicCount int, argList []interface{}, qlist string) (topicList []*TopicsRow, paginator Paginator, err error) {\n\tif topicCount == 0 {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\t//log.Printf(\"argList: %+v\\n\",argList)\n\t//log.Printf(\"qlist: %+v\\n\",qlist)\n\tvar cols, orderq string\n\tvar stmt *sql.Stmt\n\tswitch orderby {\n\tcase TopicListWeekViews:\n\t\ttList.qLock.RLock()\n\t\tstmt = tList.qcounts[len(argList)-2]\n\t\ttList.qLock.RUnlock()\n\t\tif stmt == nil {\n\t\t\torderq = \"weekViews DESC,lastReplyAt DESC,createdBy DESC\"\n\t\t\tnow := time.Now()\n\t\t\t_, week := now.ISOWeek()\n\t\t\tday := int(now.Weekday()) + 1\n\t\t\tif week%2 == 0 { // is even?\n\t\t\t\tcols = \"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,parentID,views,postCount,likeCount,attachCount,poll,data,FLOOR(weekEvenViews+((weekOddViews/7)*\" + strconv.Itoa(day) + \")) AS weekViews\"\n\t\t\t} else {\n\t\t\t\tcols = \"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,parentID,views,postCount,likeCount,attachCount,poll,data,FLOOR(weekOddViews+((weekEvenViews/7)*\" + strconv.Itoa(day) + \")) AS weekViews\"\n\t\t\t}\n\t\t\ttopicCount, err = ArgQToWeekViewTopicCount(argList, qlist)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, tList.defaultPagi(), err\n\t\t\t}\n\t\t\tacc := qgen.NewAcc()\n\t\t\tstmt = acc.Select(\"topics\").Columns(cols).Where(\"parentID IN(\" + qlist + \") AND (weekEvenViews!=0 OR weekOddViews!=0)\").Orderby(orderq).Limit(\"?,?\").ComplexPrepare()\n\t\t\tif e := acc.FirstError(); e != nil {\n\t\t\t\treturn nil, tList.defaultPagi(), e\n\t\t\t}\n\t\t\tdefer stmt.Close()\n\t\t}\n\tcase TopicListMostViewed:\n\t\ttList.qLock.RLock()\n\t\tstmt = tList.qcounts[len(argList)-2]\n\t\ttList.qLock.RUnlock()\n\t\tif stmt == nil {\n\t\t\torderq = \"views DESC,lastReplyAt DESC,createdBy DESC\"\n\t\t\tcols = \"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,parentID,views,postCount,likeCount,attachCount,poll,data,weekEvenViews\"\n\t\t}\n\tdefault:\n\t\ttList.qLock2.RLock()\n\t\tstmt = tList.qcounts2[len(argList)-2]\n\t\ttList.qLock2.RUnlock()\n\t\tif stmt == nil {\n\t\t\torderq = \"sticky DESC,lastReplyAt DESC,createdBy DESC\"\n\t\t\tcols = \"tid,title,content,createdBy,is_closed,sticky,createdAt,lastReplyAt,lastReplyBy,lastReplyID,parentID,views,postCount,likeCount,attachCount,poll,data,weekEvenViews\"\n\t\t}\n\t}\n\toffset, page, lastPage := PageOffset(topicCount, page, Config.ItemsPerPage)\n\n\t// TODO: Prepare common qlist lengths to speed this up in common cases, prepared statements are prepared lazily anyway, so it probably doesn't matter if we do ten or so\n\tif stmt == nil {\n\t\tstmt, err = qgen.Builder.SimpleSelect(\"topics\", cols, \"parentID IN(\"+qlist+\")\", orderq, \"?,?\")\n\t\tif err != nil {\n\t\t\treturn nil, tList.defaultPagi(), err\n\t\t}\n\t\tdefer stmt.Close()\n\t}\n\n\targList = append(argList, offset)\n\targList = append(argList, Config.ItemsPerPage)\n\n\trows, err := stmt.Query(argList...)\n\tif err != nil {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\tdefer rows.Close()\n\n\trc, tc := Rstore.GetCache(), Topics.GetCache()\n\trcap := rc.GetCapacity()\n\trlen := rc.Length()\n\treqUserList := make(map[int]bool)\n\tfor rows.Next() {\n\t\t// TODO: Embed Topic structs in TopicsRow to make it easier for us to reuse this work in the topic cache\n\t\tt := TopicsRow{}\n\t\t//var weekViews []uint8\n\t\terr := rows.Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.IsClosed, &t.Sticky, &t.CreatedAt, &t.LastReplyAt, &t.LastReplyBy, &t.LastReplyID, &t.ParentID, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.AttachCount, &t.Poll, &t.Data, &t.WeekViews)\n\t\tif err != nil {\n\t\t\treturn nil, tList.defaultPagi(), err\n\t\t}\n\t\t//t.WeekViews = int(weekViews[0])\n\t\t//log.Printf(\"t: %+v\\n\", t)\n\t\t//log.Printf(\"weekViews: %+v\\n\", weekViews)\n\n\t\tt.Link = BuildTopicURL(NameToSlug(t.Title), t.ID)\n\t\t// TODO: Pass forum to something like topicItem.Forum and use that instead of these two properties? Could be more flexible.\n\t\tforum := Forums.DirtyGet(t.ParentID)\n\t\tt.ForumName = forum.Name\n\t\tt.ForumLink = forum.Link\n\n\t\t// TODO: Create a specialised function with a bit less overhead for getting the last page for a post count\n\t\t_, _, lastPage := PageOffset(t.PostCount, 1, Config.ItemsPerPage)\n\t\tt.LastPage = lastPage\n\n\t\t// TODO: Rename this Vhook to better reflect moving the topic list from /routes/ to /common/\n\t\tGetHookTable().Vhook(\"topics_topic_row_assign\", &t, &forum)\n\t\ttopicList = append(topicList, &t)\n\t\treqUserList[t.CreatedBy] = true\n\t\treqUserList[t.LastReplyBy] = true\n\n\t\t//log.Print(\"rlen: \", rlen)\n\t\t//log.Print(\"rcap: \", rcap)\n\t\t//log.Print(\"t.PostCount: \", t.PostCount)\n\t\t//log.Print(\"t.PostCount == 2 && rlen < rcap: \", t.PostCount == 2 && rlen < rcap)\n\n\t\t// Avoid the extra queries on topic list pages, if we already have what we want...\n\t\thRids := false\n\t\tif tc != nil {\n\t\t\tif t, e := tc.Get(t.ID); e == nil {\n\t\t\t\thRids = len(t.Rids) != 0\n\t\t\t}\n\t\t}\n\n\t\tif t.PostCount == 2 && rlen < rcap && !hRids && page < 5 {\n\t\t\trids, err := GetRidsForTopic(t.ID, 0)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, tList.defaultPagi(), err\n\t\t\t}\n\n\t\t\t//log.Print(\"rids: \", rids)\n\t\t\tif len(rids) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t_, _ = Rstore.Get(rids[0])\n\t\t\trlen++\n\t\t\tt.Rids = []int{rids[0]}\n\t\t}\n\n\t\tif tc != nil {\n\t\t\tif _, e := tc.Get(t.ID); e == sql.ErrNoRows {\n\t\t\t\t//_ = tc.Set(t.Topic())\n\t\t\t\t_ = tc.Set(&t.Topic)\n\t\t\t}\n\t\t}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn nil, tList.defaultPagi(), err\n\t}\n\n\t// TODO: specialcase for when reqUserList only has one or two items to avoid map alloc\n\tif len(reqUserList) == 1 {\n\t\tvar u *User\n\t\tfor uid, _ := range reqUserList {\n\t\t\tu, err = Users.Get(uid)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, tList.defaultPagi(), err\n\t\t\t}\n\t\t}\n\t\tfor _, t := range topicList {\n\t\t\tt.Creator = u\n\t\t\tt.LastUser = u\n\t\t}\n\t} else if len(reqUserList) > 0 {\n\t\t// Convert the user ID map to a slice, then bulk load the users\n\t\tidSlice := make([]int, len(reqUserList))\n\t\tvar i int\n\t\tfor userID := range reqUserList {\n\t\t\tidSlice[i] = userID\n\t\t\ti++\n\t\t}\n\n\t\t// TODO: What if a user is deleted via the Control Panel?\n\t\tuserList, err := Users.BulkGetMap(idSlice)\n\t\tif err != nil {\n\t\t\treturn nil, tList.defaultPagi(), err\n\t\t}\n\n\t\t// Second pass to the add the user data\n\t\t// TODO: Use a pointer to TopicsRow instead of TopicsRow itself?\n\t\tfor _, t := range topicList {\n\t\t\tt.Creator = userList[t.CreatedBy]\n\t\t\tt.LastUser = userList[t.LastReplyBy]\n\t\t}\n\t}\n\n\tpageList := Paginate(page, lastPage, 5)\n\treturn topicList, Paginator{pageList, page, lastPage}, nil\n}\n\n// Internal. Don't rely on it.\nfunc ForumListToArgQ(forums []Forum) (argList []interface{}, qlist string) {\n\tfor _, forum := range forums {\n\t\targList = append(argList, strconv.Itoa(forum.ID))\n\t\tqlist += \"?,\"\n\t}\n\tif qlist != \"\" {\n\t\tqlist = qlist[0 : len(qlist)-1]\n\t}\n\treturn argList, qlist\n}\n\n// Internal. Don't rely on it.\n// TODO: Check the TopicCount field on the forums instead? Make sure it's in sync first.\nfunc ArgQToTopicCount(argList []interface{}, qlist string) (topicCount int, err error) {\n\ttopicCountStmt, err := qgen.Builder.SimpleCount(\"topics\", \"parentID IN(\"+qlist+\")\", \"\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer topicCountStmt.Close()\n\n\terr = topicCountStmt.QueryRow(argList...).Scan(&topicCount)\n\tif err != nil && err != ErrNoRows {\n\t\treturn 0, err\n\t}\n\treturn topicCount, err\n}\n\n// Internal. Don't rely on it.\nfunc ArgQToWeekViewTopicCount(argList []interface{}, qlist string) (topicCount int, err error) {\n\ttopicCountStmt, err := qgen.Builder.SimpleCount(\"topics\", \"parentID IN(\"+qlist+\") AND (weekEvenViews!=0 OR weekOddViews!=0)\", \"\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer topicCountStmt.Close()\n\n\terr = topicCountStmt.QueryRow(argList...).Scan(&topicCount)\n\tif err != nil && err != ErrNoRows {\n\t\treturn 0, err\n\t}\n\treturn topicCount, err\n}\n\nfunc TopicCountInForums(forums []Forum) (topicCount int, err error) {\n\tfor _, f := range forums {\n\t\ttopicCount += f.TopicCount\n\t}\n\treturn topicCount, nil\n}\n"
  },
  {
    "path": "common/topic_store.go",
    "content": "/*\n*\n*\tGosora Topic Store\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Add the watchdog goroutine\n// TODO: Add some sort of update method\n// ? - Should we add stick, lock, unstick, and unlock methods? These might be better on the Topics not the TopicStore\nvar Topics TopicStore\nvar ErrNoTitle = errors.New(\"This message is missing a title\")\nvar ErrLongTitle = errors.New(\"The title is too long\")\nvar ErrNoBody = errors.New(\"This message is missing a body\")\n\ntype TopicStore interface {\n\tDirtyGet(id int) *Topic\n\tGet(id int) (*Topic, error)\n\tBypassGet(id int) (*Topic, error)\n\tBulkGetMap(ids []int) (list map[int]*Topic, err error)\n\tExists(id int) bool\n\tCreate(fid int, name, content string, uid int, ip string) (tid int, err error)\n\tAddLastTopic(t *Topic, fid int) error // unimplemented\n\tReload(id int) error                  // Too much SQL logic to move into TopicCache\n\t// TODO: Implement these two methods\n\t//Replies(tid int) ([]*Reply, error)\n\t//RepliesRange(tid, lower, higher int) ([]*Reply, error)\n\tCount() int\n\tCountUser(uid int) int\n\tCountMegaUser(uid int) int\n\tCountBigUser(uid int) int\n\n\tClearIPs() error\n\tLockMany(tids []int) error\n\n\tSetCache(cache TopicCache)\n\tGetCache() TopicCache\n}\n\ntype DefaultTopicStore struct {\n\tcache TopicCache\n\n\tget           *sql.Stmt\n\texists        *sql.Stmt\n\tcount         *sql.Stmt\n\tcountUser     *sql.Stmt\n\tcountWordUser *sql.Stmt\n\tcreate        *sql.Stmt\n\n\tclearIPs *sql.Stmt\n\tlockTen *sql.Stmt\n}\n\n// NewDefaultTopicStore gives you a new instance of DefaultTopicStore\nfunc NewDefaultTopicStore(cache TopicCache) (*DefaultTopicStore, error) {\n\tacc := qgen.NewAcc()\n\tif cache == nil {\n\t\tcache = NewNullTopicCache()\n\t}\n\tt := \"topics\"\n\treturn &DefaultTopicStore{\n\t\tcache:         cache,\n\t\tget:           acc.Select(t).Columns(\"title,content,createdBy,createdAt,lastReplyBy,lastReplyAt,lastReplyID,is_closed,sticky,parentID,ip,views,postCount,likeCount,attachCount,poll,data\").Where(\"tid=?\").Stmt(),\n\t\texists:        acc.Exists(t, \"tid\").Stmt(),\n\t\tcount:         acc.Count(t).Stmt(),\n\t\tcountUser:     acc.Count(t).Where(\"createdBy=?\").Stmt(),\n\t\tcountWordUser: acc.Count(t).Where(\"createdBy=? AND words>=?\").Stmt(),\n\t\tcreate:        acc.Insert(t).Columns(\"parentID,title,content,parsed_content,createdAt,lastReplyAt,lastReplyBy,ip,words,createdBy\").Fields(\"?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),?,?,?,?\").Prepare(),\n\n\t\tclearIPs: acc.Update(t).Set(\"ip=''\").Where(\"ip!=''\").Stmt(),\n\t\tlockTen: acc.Update(t).Set(\"is_closed=1\").Where(\"tid IN(\" + inqbuild2(10) + \")\").Stmt(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultTopicStore) DirtyGet(id int) *Topic {\n\tt, e := s.cache.Get(id)\n\tif e == nil {\n\t\treturn t\n\t}\n\tt, e = s.BypassGet(id)\n\tif e == nil {\n\t\t_ = s.cache.Set(t)\n\t\treturn t\n\t}\n\treturn BlankTopic()\n}\n\n// TODO: Log weird cache errors?\nfunc (s *DefaultTopicStore) Get(id int) (t *Topic, e error) {\n\tt, e = s.cache.Get(id)\n\tif e == nil {\n\t\treturn t, nil\n\t}\n\tt, e = s.BypassGet(id)\n\tif e == nil {\n\t\t_ = s.cache.Set(t)\n\t}\n\treturn t, e\n}\n\n// BypassGet will always bypass the cache and pull the topic directly from the database\nfunc (s *DefaultTopicStore) BypassGet(id int) (*Topic, error) {\n\tt := &Topic{ID: id}\n\te := s.get.QueryRow(id).Scan(&t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.LastReplyBy, &t.LastReplyAt, &t.LastReplyID, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.AttachCount, &t.Poll, &t.Data)\n\tif e == nil {\n\t\tt.Link = BuildTopicURL(NameToSlug(t.Title), id)\n\t}\n\treturn t, e\n}\n\n/*func (s *DefaultTopicStore) GetByUser(uid int) (list map[int]*Topic, err error) {\n\tt := &Topic{ID: id}\n\terr := s.get.QueryRow(id).Scan(&t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.LastReplyBy, &t.LastReplyAt, &t.LastReplyID, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.AttachCount, &t.Poll, &t.Data)\n\tif err == nil {\n\t\tt.Link = BuildTopicURL(NameToSlug(t.Title), id)\n\t}\n\treturn t, err\n}*/\n\n// TODO: Avoid duplicating much of this logic from user_store.go\nfunc (s *DefaultTopicStore) BulkGetMap(ids []int) (list map[int]*Topic, e error) {\n\tidCount := len(ids)\n\tlist = make(map[int]*Topic)\n\tif idCount == 0 {\n\t\treturn list, nil\n\t}\n\n\tvar stillHere []int\n\tsliceList := s.cache.BulkGet(ids)\n\tif len(sliceList) > 0 {\n\t\tfor i, sliceItem := range sliceList {\n\t\t\tif sliceItem != nil {\n\t\t\t\tlist[sliceItem.ID] = sliceItem\n\t\t\t} else {\n\t\t\t\tstillHere = append(stillHere, ids[i])\n\t\t\t}\n\t\t}\n\t\tids = stillHere\n\t}\n\n\t// If every user is in the cache, then return immediately\n\tif len(ids) == 0 {\n\t\treturn list, nil\n\t} else if len(ids) == 1 {\n\t\tt, e := s.Get(ids[0])\n\t\tif e != nil {\n\t\t\treturn list, e\n\t\t}\n\t\tlist[t.ID] = t\n\t\treturn list, nil\n\t}\n\n\tidList, q := inqbuild(ids)\n\trows, e := qgen.NewAcc().Select(\"topics\").Columns(\"tid,title,content,createdBy,createdAt,lastReplyBy,lastReplyAt,lastReplyID,is_closed,sticky,parentID,ip,views,postCount,likeCount,attachCount,poll,data\").Where(\"tid IN(\" + q + \")\").Query(idList...)\n\tif e != nil {\n\t\treturn list, e\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tt := &Topic{}\n\t\te := rows.Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.CreatedAt, &t.LastReplyBy, &t.LastReplyAt, &t.LastReplyID, &t.IsClosed, &t.Sticky, &t.ParentID, &t.IP, &t.ViewCount, &t.PostCount, &t.LikeCount, &t.AttachCount, &t.Poll, &t.Data)\n\t\tif e != nil {\n\t\t\treturn list, e\n\t\t}\n\t\tt.Link = BuildTopicURL(NameToSlug(t.Title), t.ID)\n\t\t_ = s.cache.Set(t)\n\t\tlist[t.ID] = t\n\t}\n\tif e = rows.Err(); e != nil {\n\t\treturn list, e\n\t}\n\n\t// Did we miss any topics?\n\tif idCount > len(list) {\n\t\tvar sidList string\n\t\tfor i, id := range ids {\n\t\t\tif _, ok := list[id]; !ok {\n\t\t\t\tif i == 0 {\n\t\t\t\t\tsidList += strconv.Itoa(id)\n\t\t\t\t} else {\n\t\t\t\t\tsidList += \",\"+strconv.Itoa(id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif sidList != \"\" {\n\t\t\te = errors.New(\"Unable to find topics with the following IDs: \" + sidList)\n\t\t}\n\t}\n\n\treturn list, e\n}\n\nfunc (s *DefaultTopicStore) Reload(id int) error {\n\tt, e := s.BypassGet(id)\n\tif e == nil {\n\t\t_ = s.cache.Set(t)\n\t} else {\n\t\t_ = s.cache.Remove(id)\n\t}\n\tTopicListThaw.Thaw()\n\treturn e\n}\n\nfunc (s *DefaultTopicStore) Exists(id int) bool {\n\treturn s.exists.QueryRow(id).Scan(&id) == nil\n}\n\nfunc (s *DefaultTopicStore) ClearIPs() error {\n\t_, e := s.clearIPs.Exec()\n\treturn e\n}\n\nfunc (s *DefaultTopicStore) LockMany(tids []int) (e error) {\n\ttc, i := Topics.GetCache(), 0\n\tsingles := func() error {\n\t\tfor ; i < len(tids); i++ {\n\t\t\t_, e := topicStmts.lock.Exec(tids[i])\n\t\t\tif e != nil {\n\t\t\t\treturn e\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tif len(tids) < 10 {\n\t\tif e = singles(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif tc != nil {\n\t\t\t_ = tc.RemoveMany(tids)\n\t\t}\n\t\tTopicListThaw.Thaw()\n\t\treturn nil\n\t}\n\n\tfor ; (i + 10) < len(tids); i += 10 {\n\t\t_, e := s.lockTen.Exec(tids[i], tids[i+1], tids[i+2], tids[i+3], tids[i+4], tids[i+5], tids[i+6], tids[i+7], tids[i+8], tids[i+9])\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\n\tif e = singles(); e != nil {\n\t\treturn e\n\t}\n\tif tc != nil {\n\t\t_ = tc.RemoveMany(tids)\n\t}\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\nfunc (s *DefaultTopicStore) Create(fid int, name, content string, uid int, ip string) (tid int, err error) {\n\tif name == \"\" {\n\t\treturn 0, ErrNoTitle\n\t}\n\t// ? This number might be a little screwy with Unicode, but it's the only consistent thing we have, as Unicode characters can be any number of bytes in theory?\n\tif len(name) > Config.MaxTopicTitleLength {\n\t\treturn 0, ErrLongTitle\n\t}\n\n\tparsedContent := strings.TrimSpace(ParseMessage(content, fid, \"forums\", nil, nil))\n\tif parsedContent == \"\" {\n\t\treturn 0, ErrNoBody\n\t}\n\n\t// TODO: Move this statement into the topic store\n\tif Config.DisablePostIP {\n\t\tip = \"\"\n\t}\n\tres, err := s.create.Exec(fid, name, content, parsedContent, uid, ip, WordCount(content), uid)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\ttid = int(lastID)\n\t//TopicListThaw.Thaw() // redundant\n\n\treturn tid, Forums.AddTopic(tid, uid, fid)\n}\n\n// ? - What is this? Do we need it? Should it be in the main store interface?\nfunc (s *DefaultTopicStore) AddLastTopic(t *Topic, fid int) error {\n\t// Coming Soon...\n\treturn nil\n}\n\n// Count returns the total number of topics on these forums\nfunc (s *DefaultTopicStore) Count() (count int) {\n\treturn Countf(s.count)\n}\nfunc (s *DefaultTopicStore) CountUser(uid int) (count int) {\n\treturn Countf(s.countUser, uid)\n}\nfunc (s *DefaultTopicStore) CountMegaUser(uid int) (count int) {\n\treturn Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)[\"megapost_min_words\"].(int))\n}\nfunc (s *DefaultTopicStore) CountBigUser(uid int) (count int) {\n\treturn Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)[\"bigpost_min_words\"].(int))\n}\n\nfunc (s *DefaultTopicStore) SetCache(cache TopicCache) {\n\ts.cache = cache\n}\n\n// TODO: We're temporarily doing this so that you can do tcache != nil in getTopicUser. Refactor it.\nfunc (s *DefaultTopicStore) GetCache() TopicCache {\n\t_, ok := s.cache.(*NullTopicCache)\n\tif ok {\n\t\treturn nil\n\t}\n\treturn s.cache\n}\n"
  },
  {
    "path": "common/user.go",
    "content": "/*\n*\n*\tGosora User File\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t//\"log\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/go-sql-driver/mysql\"\n)\n\n// TODO: Replace any literals with this\nvar BanGroup = 4\n\n// TODO: Use something else as the guest avatar, maybe a question mark of some sort?\n// GuestUser is an instance of user which holds guest data to avoid having to initialise a guest every time\nvar GuestUser = User{ID: 0, Name: \"Guest\", Link: \"#\", Group: 6, Perms: GuestPerms, CreatedAt: StartTime} // BuildAvatar is done in site.go to make sure it's done after init\nvar ErrNoTempGroup = errors.New(\"We couldn't find a temporary group for this user\")\n\ntype User struct {\n\tID           int\n\tLink         string\n\tName         string\n\tEmail        string\n\tGroup        int\n\tActive       bool\n\tIsMod        bool\n\tIsSuperMod   bool\n\tIsAdmin      bool\n\tIsSuperAdmin bool\n\tIsBanned     bool\n\tPerms        Perms\n\tPluginPerms  map[string]bool\n\tSession      string\n\t//AuthToken    string\n\tLoggedin    bool\n\tRawAvatar   string\n\tAvatar      string\n\tMicroAvatar string\n\tMessage     string\n\t// TODO: Implement something like this for profiles?\n\t//URLPrefix   string // Move this to another table? Create a user lite?\n\t//URLName     string\n\tTag       string\n\tLevel     int\n\tScore     int\n\tPosts     int\n\tLiked     int\n\tCreatedAt time.Time\n\tLastIP    string // ! This part of the UserCache data might fall out of date\n\tLastAgent int    // ! Temporary hack for http push, don't use\n\tTempGroup int\n\n\tParseSettings *ParseSettings\n\tPrivacy       UserPrivacy\n}\n\ntype UserPrivacy struct {\n\tShowComments int  // 0 = default, 1 = public, 2 = registered, 3 = friends, 4 = self, 5 = disabled / unused\n\tAllowMessage int  // 0 = default, 1 = registered, 2 = friends, 3 = mods, 4 = disabled / unused\n\tNoPresence   bool // false = default, true = true\n}\n\nfunc (u *User) WebSockets() *WsJSONUser {\n\tgroupID := u.Group\n\tif u.TempGroup != 0 {\n\t\tgroupID = u.TempGroup\n\t}\n\t// TODO: Do we want to leak the user's permissions? Users will probably be able to see their status from the group tags, but still\n\treturn &WsJSONUser{u.ID, u.Link, u.Name, groupID, u.IsMod, u.Avatar, u.MicroAvatar, u.Level, u.Score, u.Liked}\n}\n\n// Use struct tags to avoid having to define this? It really depends on the circumstances, sometimes we want the whole thing, sometimes... not.\ntype WsJSONUser struct {\n\tID          int\n\tLink        string\n\tName        string\n\tGroup       int // Be sure to mask with TempGroup\n\tIsMod       bool\n\tAvatar      string\n\tMicroAvatar string\n\tLevel       int\n\tScore       int\n\tLiked       int\n}\n\nfunc (u *User) Me() *MeUser {\n\tgroupID := u.Group\n\tif u.TempGroup != 0 {\n\t\tgroupID = u.TempGroup\n\t}\n\treturn &MeUser{u.ID, u.Link, u.Name, groupID, u.Active, u.IsMod, u.IsSuperMod, u.IsAdmin, u.IsBanned, u.Session, u.Avatar, u.MicroAvatar, u.Tag, u.Level, u.Score, u.Liked}\n}\n\n// For when users need to see their own data, I've omitted some redundancies and less useful items, so we don't wind up sending them on every request\ntype MeUser struct {\n\tID         int\n\tLink       string\n\tName       string\n\tGroup      int\n\tActive     bool\n\tIsMod      bool\n\tIsSuperMod bool\n\tIsAdmin    bool\n\tIsBanned   bool\n\n\t// TODO: Implement these as copies (might already be the case for Perms, but we'll want to look at it's definition anyway)\n\t//Perms       Perms\n\t//PluginPerms map[string]bool\n\n\tS           string // Session\n\tAvatar      string\n\tMicroAvatar string\n\tTag         string\n\tLevel       int\n\tScore       int\n\tLiked       int\n}\n\ntype UserStmts struct {\n\tactivate    *sql.Stmt\n\tchangeGroup *sql.Stmt\n\tdelete      *sql.Stmt\n\tsetAvatar   *sql.Stmt\n\tsetName     *sql.Stmt\n\tupdate      *sql.Stmt\n\n\t// TODO: Split these into a sub-struct\n\tincScore         *sql.Stmt\n\tincPosts         *sql.Stmt\n\tincBigposts      *sql.Stmt\n\tincMegaposts     *sql.Stmt\n\tincPostStats     *sql.Stmt\n\tincBigpostStats  *sql.Stmt\n\tincMegapostStats *sql.Stmt\n\tincLiked         *sql.Stmt\n\tincTopics        *sql.Stmt\n\tupdateLevel      *sql.Stmt\n\tresetStats       *sql.Stmt\n\tsetStats         *sql.Stmt\n\n\tdecLiked      *sql.Stmt\n\tupdateLastIP  *sql.Stmt\n\tupdatePrivacy *sql.Stmt\n\n\tsetPassword *sql.Stmt\n\n\tscheduleAvatarResize *sql.Stmt\n\n\tdeletePosts            *sql.Stmt\n\tdeleteProfilePosts     *sql.Stmt\n\tdeleteReplyPosts       *sql.Stmt\n\tgetLikedRepliesOfTopic *sql.Stmt\n\tgetAttachmentsOfTopic  *sql.Stmt\n\tgetAttachmentsOfTopic2 *sql.Stmt\n\tgetRepliesOfTopic      *sql.Stmt\n}\n\nvar userStmts UserStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tu, w := \"users\", \"uid=?\"\n\t\tset := func(s string) *sql.Stmt {\n\t\t\treturn acc.Update(u).Set(s).Where(w).Prepare()\n\t\t}\n\t\tuserStmts = UserStmts{\n\t\t\tactivate:    set(\"active=1\"),\n\t\t\tchangeGroup: set(\"group=?\"), // TODO: Implement user_count for users_groups here\n\t\t\tdelete:      acc.Delete(u).Where(w).Prepare(),\n\t\t\tsetAvatar:   set(\"avatar=?\"),\n\t\t\tsetName:     set(\"name=?\"),\n\t\t\tupdate:      set(\"name=?,email=?,group=?\"), // TODO: Implement user_count for users_groups on things which use this\n\n\t\t\t// Stat Statements\n\t\t\t// TODO: Do +0 to avoid having as many statements?\n\t\t\tincScore:         set(\"score=score+?\"),\n\t\t\tincPosts:         set(\"posts=posts+?\"),\n\t\t\tincBigposts:      set(\"posts=posts+?,bigposts=bigposts+?\"),\n\t\t\tincMegaposts:     set(\"posts=posts+?,bigposts=bigposts+?,megaposts=megaposts+?\"),\n\t\t\tincPostStats:     set(\"posts=posts+?,score=score+?,level=?\"),\n\t\t\tincBigpostStats:  set(\"posts=posts+?,bigposts=bigposts+?,score=score+?,level=?\"),\n\t\t\tincMegapostStats: set(\"posts=posts+?,bigposts=bigposts+?,megaposts=megaposts+?,score=score+?,level=?\"),\n\t\t\tincTopics:        set(\"topics=topics+?\"),\n\t\t\tupdateLevel:      set(\"level=?\"),\n\t\t\tresetStats:       set(\"score=0,posts=0,bigposts=0,megaposts=0,topics=0,level=0\"),\n\t\t\tsetStats:         set(\"score=?,posts=?,bigposts=?,megaposts=?,topics=?,level=?\"),\n\n\t\t\tincLiked: set(\"liked=liked+?,lastLiked=UTC_TIMESTAMP()\"),\n\t\t\tdecLiked: set(\"liked=liked-?\"),\n\t\t\t//recalcLastLiked: acc...\n\t\t\tupdateLastIP:  set(\"last_ip=?\"),\n\t\t\tupdatePrivacy: set(\"profile_comments=?,enable_embeds=?\"),\n\n\t\t\tsetPassword: set(\"password=?,salt=?\"),\n\n\t\t\tscheduleAvatarResize: acc.Insert(\"users_avatar_queue\").Columns(\"uid\").Fields(\"?\").Prepare(),\n\n\t\t\t// Delete All Posts Statements\n\t\t\tdeletePosts:            acc.Select(\"topics\").Columns(\"tid,parentID,postCount,poll\").Where(\"createdBy=?\").Prepare(),\n\t\t\tdeleteProfilePosts:     acc.Select(\"users_replies\").Columns(\"rid,uid\").Where(\"createdBy=?\").Prepare(),\n\t\t\tdeleteReplyPosts:       acc.Select(\"replies\").Columns(\"rid,tid\").Where(\"createdBy=?\").Prepare(),\n\t\t\tgetLikedRepliesOfTopic: acc.Select(\"replies\").Columns(\"rid\").Where(\"tid=? AND likeCount>0\").Prepare(),\n\t\t\tgetAttachmentsOfTopic:  acc.Select(\"attachments\").Columns(\"attachID\").Where(\"originID=? AND originTable='topics'\").Prepare(),\n\t\t\tgetAttachmentsOfTopic2: acc.Select(\"attachments\").Columns(\"attachID\").Where(\"extra=? AND originTable='replies'\").Prepare(),\n\t\t\tgetRepliesOfTopic:      acc.Select(\"replies\").Columns(\"words\").Where(\"createdBy!=? AND tid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc (u *User) Init() {\n\t// TODO: Let admins configure the minimum default?\n\tif u.Privacy.ShowComments < 1 {\n\t\tu.Privacy.ShowComments = 1\n\t}\n\tu.Avatar, u.MicroAvatar = BuildAvatar(u.ID, u.RawAvatar)\n\tu.Link = BuildProfileURL(NameToSlug(u.Name), u.ID)\n\tu.Tag = Groups.DirtyGet(u.Group).Tag\n\tu.InitPerms()\n}\n\n// TODO: Refactor this idiom into something shorter, maybe with a NullUserCache when one isn't set?\nfunc (u *User) CacheRemove() {\n\tif uc := Users.GetCache(); uc != nil {\n\t\tuc.Remove(u.ID)\n\t}\n\tTopicListThaw.Thaw()\n}\n\nfunc (u *User) Ban(dur time.Duration, issuedBy int) error {\n\treturn u.ScheduleGroupUpdate(BanGroup, issuedBy, dur)\n}\n\nfunc (u *User) Unban() error {\n\treturn u.RevertGroupUpdate()\n}\n\nfunc (u *User) deleteScheduleGroupTx(tx *sql.Tx) error {\n\tdeleteScheduleGroupStmt, e := qgen.Builder.SimpleDeleteTx(tx, \"users_groups_scheduler\", \"uid=?\")\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = deleteScheduleGroupStmt.Exec(u.ID)\n\treturn e\n}\n\nfunc (u *User) setTempGroupTx(tx *sql.Tx, tempGroup int) error {\n\tsetTempGroupStmt, e := qgen.Builder.SimpleUpdateTx(tx, \"users\", \"temp_group=?\", \"uid=?\")\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = setTempGroupStmt.Exec(tempGroup, u.ID)\n\treturn e\n}\n\n// Make this more stateless?\nfunc (u *User) ScheduleGroupUpdate(gid, issuedBy int, dur time.Duration) error {\n\tvar temp bool\n\tif dur.Nanoseconds() != 0 {\n\t\ttemp = true\n\t}\n\trevertAt := time.Now().Add(dur)\n\n\ttx, e := qgen.Builder.Begin()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer tx.Rollback()\n\n\te = u.deleteScheduleGroupTx(tx)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\tcreateScheduleGroupTx, e := qgen.Builder.SimpleInsertTx(tx, \"users_groups_scheduler\", \"uid,set_group,issued_by,issued_at,revert_at,temporary\", \"?,?,?,UTC_TIMESTAMP(),?,?\")\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = createScheduleGroupTx.Exec(u.ID, gid, issuedBy, revertAt, temp)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\te = u.setTempGroupTx(tx, gid)\n\tif e != nil {\n\t\treturn e\n\t}\n\te = tx.Commit()\n\n\tu.CacheRemove()\n\treturn e\n}\n\nfunc (u *User) RevertGroupUpdate() error {\n\ttx, e := qgen.Builder.Begin()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer tx.Rollback()\n\n\te = u.deleteScheduleGroupTx(tx)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\te = u.setTempGroupTx(tx, 0)\n\tif e != nil {\n\t\treturn e\n\t}\n\te = tx.Commit()\n\n\tu.CacheRemove()\n\treturn e\n}\n\n// TODO: Use a transaction here\n// ? - Add a Deactivate method? Not really needed, if someone's been bad you could do a ban, I guess it might be useful, if someone says that email x isn't actually owned by the user in question?\nfunc (u *User) Activate() (e error) {\n\t_, e = userStmts.activate.Exec(u.ID)\n\tif e != nil {\n\t\treturn e\n\t}\n\t_, e = userStmts.changeGroup.Exec(Config.DefaultGroup, u.ID)\n\tu.CacheRemove()\n\treturn e\n}\n\n// TODO: Write tests for this\n// TODO: Delete this user's content too?\n// TODO: Expose this to the admin?\nfunc (u *User) Delete() error {\n\t_, e := userStmts.delete.Exec(u.ID)\n\tu.CacheRemove()\n\treturn e\n}\n\n// TODO: dismiss-event\nfunc (u *User) DeletePosts() error {\n\trows, err := userStmts.deletePosts.Query(u.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\tdefer TopicListThaw.Thaw()\n\tdefer u.CacheRemove()\n\n\tupdatedForums := make(map[int]int) // forum[count]\n\ttc := Topics.GetCache()\n\tumap := make(map[int]struct{})\n\tfor rows.Next() {\n\t\tvar tid, parentID, postCount, poll int\n\t\terr := rows.Scan(&tid, &parentID, &postCount, &poll)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// TODO: Clear reply cache too\n\t\t_, err = topicStmts.delete.Exec(tid)\n\t\tif tc != nil {\n\t\t\ttc.Remove(tid)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tupdatedForums[parentID] = updatedForums[parentID] + 1\n\n\t\t_, err = topicStmts.deleteLikesForTopic.Exec(tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = handleTopicAttachments(tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif postCount > 1 {\n\t\t\terr = handleLikedTopicReplies(tid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = handleTopicReplies(umap, u.ID, tid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t_, err = topicStmts.deleteReplies.Exec(tid)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\terr = Subscriptions.DeleteResource(tid, \"topic\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = topicStmts.deleteActivity.Exec(tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif poll > 0 {\n\t\t\terr = (&Poll{ID: poll}).Delete()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn err\n\t}\n\terr = u.ResetPostStats()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor uid, _ := range umap {\n\t\terr = (&User{ID: uid}).RecalcPostStats()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor fid, count := range updatedForums {\n\t\terr := Forums.RemoveTopics(fid, count)\n\t\tif err != nil && err != ErrNoRows {\n\t\t\treturn err\n\t\t}\n\t}\n\n\trows, err = userStmts.deleteProfilePosts.Query(u.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tvar rid, uid int\n\t\terr := rows.Scan(&rid, &uid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = profileReplyStmts.delete.Exec(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// TODO: Optimise this\n\t\t// TODO: dismiss-event\n\t\terr = Activity.DeleteByParamsExtra(\"reply\", uid, \"user\", strconv.Itoa(rid))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn err\n\t}\n\n\trows, err = userStmts.deleteReplyPosts.Query(u.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rows.Close()\n\n\trc := Rstore.GetCache()\n\tfor rows.Next() {\n\t\tvar rid, tid int\n\t\terr := rows.Scan(&rid, &tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = replyStmts.delete.Exec(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// TODO: Move this bit to *Topic\n\t\t_, err = replyStmts.removeRepliesFromTopic.Exec(1, tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = replyStmts.updateTopicReplies.Exec(tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = replyStmts.updateTopicReplies2.Exec(tid)\n\t\tif tc != nil {\n\t\t\ttc.Remove(tid)\n\t\t}\n\t\t_ = rc.Remove(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = replyStmts.deleteLikesForReply.Exec(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = Activity.DeleteByParamsExtra(\"reply\", tid, \"topic\", strconv.Itoa(rid))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = replyStmts.deleteActivitySubs.Exec(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = replyStmts.deleteActivity.Exec(rid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// TODO: Restructure alerts so we can delete the \"x replied to topic\" ones too.\n\t}\n\treturn rows.Err()\n}\n\nfunc (u *User) bindStmt(stmt *sql.Stmt, params ...interface{}) (e error) {\n\tparams = append(params, u.ID)\n\t_, e = stmt.Exec(params...)\n\tu.CacheRemove()\n\treturn e\n}\n\nfunc (u *User) ChangeName(name string) error {\n\treturn u.bindStmt(userStmts.setName, name)\n}\n\nfunc (u *User) ChangeAvatar(avatar string) error {\n\treturn u.bindStmt(userStmts.setAvatar, avatar)\n}\n\n// TODO: Abstract this with an interface so we can scale this with an actual dedicated queue in a real cluster\nfunc (u *User) ScheduleAvatarResize() (e error) {\n\t_, e = userStmts.scheduleAvatarResize.Exec(u.ID)\n\tif e != nil {\n\t\t// TODO: Do a more generic check so that we're not as tied to MySQL\n\t\tme, ok := e.(*mysql.MySQLError)\n\t\tif !ok {\n\t\t\treturn e\n\t\t}\n\t\t// If it's just telling us that the item already exists in the database, then we can ignore it, as it doesn't matter if it's this call or another which schedules the item in the queue\n\t\tif me.Number != 1062 {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (u *User) ChangeGroup(group int) error {\n\treturn u.bindStmt(userStmts.changeGroup, group)\n}\n\nfunc (u *User) GetIP() string {\n\tspl := strings.Split(u.LastIP, \"-\")\n\treturn spl[len(spl)-1]\n}\n\n// ! Only updates the database not the *User for safety reasons\nfunc (u *User) UpdateIP(ip string) error {\n\t_, e := userStmts.updateLastIP.Exec(ip, u.ID)\n\tif uc := Users.GetCache(); uc != nil {\n\t\tuc.Remove(u.ID)\n\t}\n\treturn e\n}\n\n//var ErrMalformedInteger = errors.New(\"malformed integer\")\nvar ErrProfileCommentsOutOfBounds = errors.New(\"profile_comments must be an integer between -1 and 4\")\nvar ErrEnableEmbedsOutOfBounds = errors.New(\"enable_embeds must be -1, 0 or 1\")\n\n/*func (u *User) UpdatePrivacyS(sProfileComments, sEnableEmbeds string) error {\n\treturn u.UpdatePrivacy(profileComments, enableEmbeds)\n}*/\n\nfunc (u *User) UpdatePrivacy(profileComments, enableEmbeds int) error {\n\tif profileComments < -1 || profileComments > 4 {\n\t\treturn ErrProfileCommentsOutOfBounds\n\t}\n\tif enableEmbeds < -1 || enableEmbeds > 1 {\n\t\treturn ErrEnableEmbedsOutOfBounds\n\t}\n\t_, e := userStmts.updatePrivacy.Exec(profileComments, enableEmbeds, u.ID)\n\tif uc := Users.GetCache(); uc != nil {\n\t\tuc.Remove(u.ID)\n\t}\n\treturn e\n}\n\nfunc (u *User) Update(name, email string, group int) (err error) {\n\treturn u.bindStmt(userStmts.update, name, email, group)\n}\n\nfunc (u *User) IncreasePostStats(wcount int, topic bool) (err error) {\n\tbaseScore := 1\n\tif topic {\n\t\t_, err = userStmts.incTopics.Exec(1, u.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbaseScore = 2\n\t}\n\n\tsettings := SettingBox.Load().(SettingMap)\n\tvar mod, level int\n\tif wcount >= settings[\"megapost_min_words\"].(int) {\n\t\tmod = 4\n\t\tlevel = GetLevel(u.Score + baseScore + mod)\n\t\t_, err = userStmts.incMegapostStats.Exec(1, 1, 1, baseScore+mod, level, u.ID)\n\t} else if wcount >= settings[\"bigpost_min_words\"].(int) {\n\t\tmod = 1\n\t\tlevel = GetLevel(u.Score + baseScore + mod)\n\t\t_, err = userStmts.incBigpostStats.Exec(1, 1, baseScore+mod, level, u.ID)\n\t} else {\n\t\tlevel = GetLevel(u.Score + baseScore + mod)\n\t\t_, err = userStmts.incPostStats.Exec(1, baseScore+mod, level, u.ID)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = GroupPromotions.PromoteIfEligible(u, level, u.Posts+1, u.CreatedAt)\n\tu.CacheRemove()\n\treturn err\n}\n\nfunc (u *User) countf(stmt *sql.Stmt) (count int) {\n\te := stmt.QueryRow().Scan(&count)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n\treturn count\n}\n\nfunc (u *User) RecalcPostStats() error {\n\tvar score int\n\ttcount := Topics.CountUser(u.ID)\n\trcount := Rstore.CountUser(u.ID)\n\t//log.Print(\"tcount:\", tcount)\n\t//log.Print(\"rcount:\", rcount)\n\tscore += tcount * 2\n\tscore += rcount\n\n\tvar tmega, tbig, rmega, rbig int\n\tif tcount > 0 {\n\t\ttmega = Topics.CountMegaUser(u.ID)\n\t\tscore += tmega * 3\n\t\ttbig := Topics.CountBigUser(u.ID)\n\t\tscore += tbig\n\t}\n\tif rcount > 0 {\n\t\trmega = Rstore.CountMegaUser(u.ID)\n\t\tscore += rmega * 3\n\t\trbig = Rstore.CountBigUser(u.ID)\n\t\tscore += rbig\n\t}\n\n\t_, err := userStmts.setStats.Exec(score, tcount+rcount, tbig+rbig, tmega+rmega, tcount, GetLevel(score), u.ID)\n\tu.CacheRemove()\n\treturn err\n}\n\nfunc (u *User) DecreasePostStats(wcount int, topic bool) (err error) {\n\tbaseScore := -1\n\tif topic {\n\t\t_, err = userStmts.incTopics.Exec(-1, u.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbaseScore = -2\n\t}\n\n\t// TODO: Use a transaction to prevent level desyncs?\n\tvar mod int\n\tsettings := SettingBox.Load().(SettingMap)\n\tif wcount >= settings[\"megapost_min_words\"].(int) {\n\t\tmod = 4\n\t\t_, err = userStmts.incMegapostStats.Exec(-1, -1, -1, baseScore-mod, GetLevel(u.Score-baseScore-mod), u.ID)\n\t} else if wcount >= settings[\"bigpost_min_words\"].(int) {\n\t\tmod = 1\n\t\t_, err = userStmts.incBigpostStats.Exec(-1, -1, baseScore-mod, GetLevel(u.Score-baseScore-mod), u.ID)\n\t} else {\n\t\t_, err = userStmts.incPostStats.Exec(-1, baseScore-mod, GetLevel(u.Score-baseScore-mod), u.ID)\n\t}\n\tu.CacheRemove()\n\treturn err\n}\n\nfunc (u *User) ResetPostStats() error {\n\t_, err := userStmts.resetStats.Exec(u.ID)\n\tu.CacheRemove()\n\treturn err\n}\n\n// Copy gives you a non-pointer concurrency safe copy of the user\nfunc (u *User) Copy() User {\n\treturn *u\n}\n\n// TODO: Write unit tests for this\nfunc (u *User) InitPerms() {\n\tif u.TempGroup != 0 {\n\t\tu.Group = u.TempGroup\n\t}\n\n\tgroup := Groups.DirtyGet(u.Group)\n\tif u.IsSuperAdmin {\n\t\tu.Perms = AllPerms\n\t\tu.PluginPerms = AllPluginPerms\n\t} else {\n\t\tu.Perms = group.Perms\n\t\tu.PluginPerms = group.PluginPerms\n\t}\n\t/*if len(group.CanSee) == 0 {\n\t\tpanic(\"should not be zero\")\n\t}*/\n\n\tu.IsAdmin = u.IsSuperAdmin || group.IsAdmin\n\tu.IsSuperMod = u.IsAdmin || group.IsMod\n\tu.IsMod = u.IsSuperMod\n\tu.IsBanned = group.IsBanned\n\tif u.IsBanned && u.IsSuperMod {\n\t\tu.IsBanned = false\n\t}\n}\n\n// TODO: Write unit tests for this\nfunc InitPerms2(group int, superAdmin bool, tempGroup int) (perms *Perms, admin, superMod, banned bool) {\n\tif tempGroup != 0 {\n\t\tgroup = tempGroup\n\t}\n\n\tg := Groups.DirtyGet(group)\n\tif superAdmin {\n\t\tperms = &AllPerms\n\t} else {\n\t\tperms = &g.Perms\n\t}\n\n\tadmin = superAdmin || g.IsAdmin\n\tsuperMod = admin || g.IsMod\n\tbanned = g.IsBanned\n\tif banned && superMod {\n\t\tbanned = false\n\t}\n\treturn perms, admin, superMod, banned\n}\n\n// TODO: Write tests\n// TODO: Implement and use this\n// TODO: Implement friends\nfunc PrivacyAllowMessage(pu, u *User) (canMsg bool) {\n\tswitch pu.Privacy.AllowMessage {\n\tcase 4: // Unused\n\t\tcanMsg = false\n\tcase 3: // mods\n\t\tcanMsg = u.IsSuperMod\n\t//case 2: // friends\n\tcase 1: // registered\n\t\tcanMsg = true\n\tdefault: // 0\n\t\tcanMsg = true\n\t}\n\treturn canMsg\n}\n\n// TODO: Implement friend system\nfunc PrivacyCommentsShow(pu, u *User) (showComments bool) {\n\tswitch pu.Privacy.ShowComments {\n\tcase 5: // Unused\n\t\tshowComments = false\n\tcase 4: // Self\n\t\tshowComments = u.ID == pu.ID\n\tcase 3: // friends\n\t\tshowComments = u.ID == pu.ID\n\tcase 2: // registered\n\t\tshowComments = u.Loggedin\n\tcase 1: // public\n\t\tshowComments = true\n\tdefault: // 0\n\t\tshowComments = true\n\t}\n\treturn showComments\n}\n\nvar guestAvatar GuestAvatar\n\ntype GuestAvatar struct {\n\tNormal string\n\tMicro  string\n}\n\nfunc buildNoavatar(uid, width int) string {\n\tif !Config.DisableNoavatarRange {\n\t\t// TODO: Find a faster algorithm\n\t\tl := func(max int) {\n\t\t\tfor uid > max {\n\t\t\t\tuid -= max\n\t\t\t}\n\t\t}\n\t\tl(50000)\n\t\tl(5000)\n\t\tl(500)\n\t\tl(50)\n\t\tl(10)\n\t}\n\tif !Config.DisableDefaultNoavatar && uid < 11 {\n\t\t/*if uid < 6 {\n\t\t\tif width == 200 {\n\t\t\t\treturn noavatarCache200Avif[uid]\n\t\t\t} else if width == 48 {\n\t\t\t\treturn noavatarCache48Avif[uid]\n\t\t\t}\n\t\t\treturn StaticFiles.Prefix + \"n\" + strconv.Itoa(uid) + \"-\" + strconv.Itoa(width) + \".avif?i=0\"\n\t\t} else */if width == 200 {\n\t\t\treturn noavatarCache200[uid]\n\t\t} else if width == 48 {\n\t\t\treturn noavatarCache48[uid]\n\t\t}\n\t\treturn StaticFiles.Prefix + \"n\" + strconv.Itoa(uid) + \"-\" + strconv.Itoa(width) + \".png?i=0\"\n\t}\n\t// ? - Add a prefix setting to make this faster?\n\treturn strings.Replace(strings.Replace(Config.Noavatar, \"{id}\", strconv.Itoa(uid), 1), \"{width}\", strconv.Itoa(width), 1)\n}\n\n// ? - Make this part of *User?\n// TODO: Write tests for this\nfunc BuildAvatar(uid int, avatar string) (normalAvatar, microAvatar string) {\n\tif avatar == \"\" {\n\t\tif uid == 0 {\n\t\t\treturn guestAvatar.Normal, guestAvatar.Micro\n\t\t}\n\t\treturn buildNoavatar(uid, 200), buildNoavatar(uid, 48)\n\t}\n\tif avatar[0] == '.' {\n\t\tif avatar[1] == '.' {\n\t\t\tnormalAvatar = Config.AvatarResBase + \"avatar_\" + strconv.Itoa(uid) + \"_tmp\" + avatar[1:]\n\t\t\treturn normalAvatar, normalAvatar\n\t\t}\n\t\tnormalAvatar = Config.AvatarResBase + \"avatar_\" + strconv.Itoa(uid) + avatar\n\t\treturn normalAvatar, normalAvatar\n\t}\n\treturn avatar, avatar\n}\n\n// TODO: Move this to *User\nfunc SetPassword(uid int, password string) error {\n\thashedPassword, salt, err := GeneratePassword(password)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = userStmts.setPassword.Exec(hashedPassword, salt, uid)\n\treturn err\n}\n\n// TODO: Write units tests for this\nfunc wordsToScore(wcount int, topic bool) (score int) {\n\tif topic {\n\t\tscore = 2\n\t} else {\n\t\tscore = 1\n\t}\n\tsettings := SettingBox.Load().(SettingMap)\n\tif wcount >= settings[\"megapost_min_words\"].(int) {\n\t\tscore += 4\n\t} else if wcount >= settings[\"bigpost_min_words\"].(int) {\n\t\tscore++\n\t}\n\treturn score\n}\n\n// For use in tests and to help generate dummy users for forums which don't have last posters\nfunc BlankUser() *User {\n\treturn new(User)\n}\n\n// TODO: Write unit tests for this\nfunc BuildProfileURL(slug string, uid int) string {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\treturn \"/user/\" + strconv.Itoa(uid)\n\t}\n\treturn \"/user/\" + slug + \".\" + strconv.Itoa(uid)\n}\n\nfunc BuildProfileURLSb(sb *strings.Builder, slug string, uid int) {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\tsb.Grow(6 + 1)\n\t\tsb.WriteString(\"/user/\")\n\t\tsb.WriteString(strconv.Itoa(uid))\n\t\treturn\n\t}\n\tsb.Grow(7 + 1 + len(slug))\n\tsb.WriteString(\"/user/\")\n\tsb.WriteString(slug)\n\tsb.WriteRune('.')\n\tsb.WriteString(strconv.Itoa(uid))\n}\n"
  },
  {
    "path": "common/user_cache.go",
    "content": "package common\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\n// UserCache is an interface which spits out users from a fast cache rather than the database, whether from memory or from an application like Redis. Users may not be present in the cache but may be in the database\ntype UserCache interface {\n\tDeallocOverflow(evictPriority bool) (evicted int) // May cause thread contention, looks for items to evict\n\tGet(id int) (*User, error)\n\tGetn(id int) *User\n\tGetUnsafe(id int) (*User, error)\n\tBulkGet(ids []int) (list []*User)\n\tSet(item *User) error\n\tAdd(item *User) error\n\tAddUnsafe(item *User) error\n\tRemove(id int) error\n\tRemoveUnsafe(id int) error\n\tFlush()\n\tLength() int\n\tSetCapacity(cap int)\n\tGetCapacity() int\n}\n\n// MemoryUserCache stores and pulls users out of the current process' memory\ntype MemoryUserCache struct {\n\titems    map[int]*User // TODO: Shard this into two?\n\tlength   int64\n\tcapacity int\n\n\tsync.RWMutex\n}\n\n// NewMemoryUserCache gives you a new instance of MemoryUserCache\nfunc NewMemoryUserCache(cap int) *MemoryUserCache {\n\treturn &MemoryUserCache{\n\t\titems:    make(map[int]*User),\n\t\tcapacity: cap,\n\t}\n}\n\n// TODO: Avoid deallocating topic list users\nfunc (s *MemoryUserCache) DeallocOverflow(evictPriority bool) (evicted int) {\n\ttoEvict := make([]int, 10)\n\tevIndex := 0\n\ts.RLock()\n\tfor _, user := range s.items {\n\t\tif /*user.LastActiveAt < lastActiveCutoff && */ user.Score == 0 && !user.IsMod {\n\t\t\tif EnableWebsockets && WsHub.HasUser(user.ID) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttoEvict[evIndex] = user.ID\n\t\t\tevIndex++\n\t\t\tif evIndex == 10 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\ts.RUnlock()\n\n\t// Clear some of the less active users now with a bit more aggressiveness\n\tif evIndex == 0 && evictPriority {\n\t\ttoEvict = make([]int, 20)\n\t\ts.RLock()\n\t\tfor _, user := range s.items {\n\t\t\tif user.Score < 100 && !user.IsMod {\n\t\t\t\tif EnableWebsockets && WsHub.HasUser(user.ID) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttoEvict[evIndex] = user.ID\n\t\t\t\tevIndex++\n\t\t\t\tif evIndex == 20 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\ts.RUnlock()\n\t}\n\n\t// Remove zero IDs from the evictable list, so we don't waste precious cycles locked for those\n\tlastZero := -1\n\tfor i, uid := range toEvict {\n\t\tif uid == 0 {\n\t\t\tlastZero = i\n\t\t}\n\t}\n\tif lastZero != -1 {\n\t\ttoEvict = toEvict[:lastZero]\n\t}\n\n\ts.BulkRemove(toEvict)\n\treturn len(toEvict)\n}\n\n// Get fetches a user by ID. Returns ErrNoRows if not present.\nfunc (s *MemoryUserCache) Get(id int) (*User, error) {\n\ts.RLock()\n\titem := s.items[id]\n\ts.RUnlock()\n\tif item == nil {\n\t\treturn item, ErrNoRows\n\t}\n\treturn item, nil\n}\n\nfunc (s *MemoryUserCache) Getn(id int) *User {\n\ts.RLock()\n\titem := s.items[id]\n\ts.RUnlock()\n\treturn item\n}\n\n// BulkGet fetches multiple users by their IDs. Indices without users will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing.\nfunc (s *MemoryUserCache) BulkGet(ids []int) (list []*User) {\n\tlist = make([]*User, len(ids))\n\ts.RLock()\n\tfor i, id := range ids {\n\t\tlist[i] = s.items[id]\n\t}\n\ts.RUnlock()\n\treturn list\n}\n\n// GetUnsafe fetches a user by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryUserCache) GetUnsafe(id int) (*User, error) {\n\titem, ok := s.items[id]\n\tif ok {\n\t\treturn item, nil\n\t}\n\treturn item, ErrNoRows\n}\n\n// Set overwrites the value of a user in the cache, whether it's present or not. May return a capacity overflow error.\nfunc (s *MemoryUserCache) Set(item *User) error {\n\ts.Lock()\n\tuser, ok := s.items[item.ID]\n\tif ok {\n\t\ts.Unlock()\n\t\t*user = *item\n\t} else if int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t} else {\n\t\ts.items[item.ID] = item\n\t\ts.Unlock()\n\t\tatomic.AddInt64(&s.length, 1)\n\t}\n\treturn nil\n}\n\n// Add adds a user to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error.\n// ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used?\nfunc (s *MemoryUserCache) Add(item *User) error {\n\ts.Lock()\n\tif int(s.length) >= s.capacity {\n\t\ts.Unlock()\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\ts.Unlock()\n\treturn nil\n}\n\n// AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryUserCache) AddUnsafe(item *User) error {\n\tif int(s.length) >= s.capacity {\n\t\treturn ErrStoreCapacityOverflow\n\t}\n\ts.items[item.ID] = item\n\ts.length = int64(len(s.items))\n\treturn nil\n}\n\n// Remove removes a user from the cache by ID, if they exist. Returns ErrNoRows if no items exist.\nfunc (s *MemoryUserCache) Remove(id int) error {\n\ts.Lock()\n\t_, ok := s.items[id]\n\tif !ok {\n\t\ts.Unlock()\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\n// RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE.\nfunc (s *MemoryUserCache) RemoveUnsafe(id int) error {\n\t_, ok := s.items[id]\n\tif !ok {\n\t\treturn ErrNoRows\n\t}\n\tdelete(s.items, id)\n\tatomic.AddInt64(&s.length, -1)\n\treturn nil\n}\n\nfunc (s *MemoryUserCache) BulkRemove(ids []int) {\n\tvar rCount int64\n\ts.Lock()\n\tfor _, id := range ids {\n\t\t_, ok := s.items[id]\n\t\tif ok {\n\t\t\tdelete(s.items, id)\n\t\t\trCount++\n\t\t}\n\t}\n\ts.Unlock()\n\tatomic.AddInt64(&s.length, -rCount)\n}\n\n// Flush removes all the users from the cache, useful for tests.\nfunc (s *MemoryUserCache) Flush() {\n\ts.Lock()\n\ts.items = make(map[int]*User)\n\ts.length = 0\n\ts.Unlock()\n}\n\n// ! Is this concurrent?\n// Length returns the number of users in the memory cache\nfunc (s *MemoryUserCache) Length() int {\n\treturn int(s.length)\n}\n\n// SetCapacity sets the maximum number of users which this cache can hold\nfunc (s *MemoryUserCache) SetCapacity(cap int) {\n\t// Ints are moved in a single instruction, so this should be thread-safe\n\ts.capacity = cap\n}\n\n// GetCapacity returns the maximum number of users this cache can hold\nfunc (s *MemoryUserCache) GetCapacity() int {\n\treturn s.capacity\n}\n"
  },
  {
    "path": "common/user_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// TODO: Add the watchdog goroutine\n// TODO: Add some sort of update method\nvar Users UserStore\nvar ErrAccountExists = errors.New(\"this username is already in use\")\nvar ErrLongUsername = errors.New(\"this username is too long\")\nvar ErrSomeUsersNotFound = errors.New(\"Unable to find some users\")\n\ntype UserStore interface {\n\tDirtyGet(id int) *User\n\tGet(id int) (*User, error)\n\tGetn(id int) *User\n\tGetByName(name string) (*User, error)\n\tBulkGetByName(names []string) (list []*User, err error)\n\tRawBulkGetByNameForConvo(f func(int, string, int, bool, int, int) error, names []string) error\n\tExists(id int) bool\n\tSearchOffset(name, email string, gid, offset, perPage int) (users []*User, err error)\n\tGetOffset(offset, perPage int) ([]*User, error)\n\tEach(f func(*User) error) error\n\t//BulkGet(ids []int) ([]*User, error)\n\tBulkGetMap(ids []int) (map[int]*User, error)\n\tBypassGet(id int) (*User, error)\n\tClearLastIPs() error\n\tCreate(name, password, email string, group int, active bool) (int, error)\n\tReload(id int) error\n\tCount() int\n\tCountSearch(name, email string, gid int) int\n\n\tSetCache(cache UserCache)\n\tGetCache() UserCache\n}\n\ntype DefaultUserStore struct {\n\tcache UserCache\n\n\tget          *sql.Stmt\n\tgetByName    *sql.Stmt\n\tsearchOffset *sql.Stmt\n\tgetOffset    *sql.Stmt\n\tgetAll       *sql.Stmt\n\texists       *sql.Stmt\n\tregister     *sql.Stmt\n\tnameExists   *sql.Stmt\n\n\tcount       *sql.Stmt\n\tcountSearch *sql.Stmt\n\n\tclearIPs *sql.Stmt\n}\n\n// NewDefaultUserStore gives you a new instance of DefaultUserStore\nfunc NewDefaultUserStore(cache UserCache) (*DefaultUserStore, error) {\n\tacc := qgen.NewAcc()\n\tif cache == nil {\n\t\tcache = NewNullUserCache()\n\t}\n\tu := \"users\"\n\tallCols := \"uid,name,group,active,is_super_admin,session,email,avatar,message,level,score,posts,liked,last_ip,temp_group,createdAt,enable_embeds,profile_comments,who_can_convo\"\n\t// TODO: Add an admin version of registerStmt with more flexibility?\n\treturn &DefaultUserStore{\n\t\tcache: cache,\n\n\t\tget:          acc.Select(u).Columns(\"name,group,active,is_super_admin,session,email,avatar,message,level,score,posts,liked,last_ip,temp_group,createdAt,enable_embeds,profile_comments,who_can_convo\").Where(\"uid=?\").Prepare(),\n\t\tgetByName:    acc.Select(u).Columns(allCols).Where(\"name=?\").Prepare(),\n\t\tsearchOffset: acc.Select(u).Columns(allCols).Where(\"(name=? OR ?='') AND (email=? OR ?='') AND (group=? OR ?=0)\").Orderby(\"uid ASC\").Limit(\"?,?\").Prepare(),\n\t\tgetOffset:    acc.Select(u).Columns(allCols).Orderby(\"uid ASC\").Limit(\"?,?\").Prepare(),\n\t\tgetAll:       acc.Select(u).Columns(allCols).Prepare(),\n\n\t\texists:     acc.Exists(u, \"uid\").Prepare(),\n\t\tregister:   acc.Insert(u).Columns(\"name,email,password,salt,group,is_super_admin,session,active,message,createdAt,lastActiveAt,lastLiked,oldestItemLikedCreatedAt\").Fields(\"?,?,?,?,?,0,'',?,'',UTC_TIMESTAMP(),UTC_TIMESTAMP(),UTC_TIMESTAMP(),UTC_TIMESTAMP()\").Prepare(), // TODO: Implement user_count on users_groups here\n\t\tnameExists: acc.Exists(u, \"name\").Prepare(),\n\n\t\tcount:       acc.Count(u).Prepare(),\n\t\tcountSearch: acc.Count(u).Where(\"(name=? OR ?='') AND (email=? OR ?='') AND (group=? OR ?=0)\").Prepare(),\n\n\t\tclearIPs: acc.Update(u).Set(\"last_ip=''\").Where(\"last_ip!=''\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *DefaultUserStore) DirtyGet(id int) *User {\n\tuser, err := s.Get(id)\n\tif err == nil {\n\t\treturn user\n\t}\n\t/*if s.OutOfBounds(id) {\n\t\treturn BlankUser()\n\t}*/\n\treturn BlankUser()\n}\n\nfunc (s *DefaultUserStore) scanUser(r *sql.Row, u *User) (embeds int, err error) {\n\te := r.Scan(&u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\treturn embeds, e\n}\n\n// TODO: Log weird cache errors? Not just here but in every *Cache?\nfunc (s *DefaultUserStore) Get(id int) (*User, error) {\n\tu, err := s.cache.Get(id)\n\tif err == nil {\n\t\t//log.Print(\"cached user\")\n\t\t//log.Print(string(debug.Stack()))\n\t\t//log.Println(\"\")\n\t\treturn u, nil\n\t}\n\t//log.Print(\"uncached user\")\n\n\tu = &User{ID: id, Loggedin: true}\n\tembeds, err := s.scanUser(s.get.QueryRow(id), u)\n\tif err == nil {\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\ts.cache.Set(u)\n\t}\n\treturn u, err\n}\n\nfunc (s *DefaultUserStore) Getn(id int) *User {\n\tu := s.cache.Getn(id)\n\tif u != nil {\n\t\treturn u\n\t}\n\n\tu = &User{ID: id, Loggedin: true}\n\tembeds, err := s.scanUser(s.get.QueryRow(id), u)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tif embeds != -1 {\n\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t}\n\tu.Init()\n\ts.cache.Set(u)\n\treturn u\n}\n\n// TODO: Log weird cache errors? Not just here but in every *Cache?\n// ! This bypasses the cache, use frugally\nfunc (s *DefaultUserStore) GetByName(name string) (*User, error) {\n\tu := &User{Loggedin: true}\n\tvar embeds int\n\terr := s.getByName.QueryRow(name).Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif embeds != -1 {\n\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t}\n\tu.Init()\n\ts.cache.Set(u)\n\treturn u, nil\n}\n\n// TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts?\n// ! This bypasses the cache, use frugally\nfunc (s *DefaultUserStore) BulkGetByName(names []string) (list []*User, err error) {\n\tif len(names) == 0 {\n\t\treturn list, nil\n\t} else if len(names) == 1 {\n\t\tuser, err := s.GetByName(names[0])\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\t\treturn []*User{user}, nil\n\t}\n\n\tidList, q := inqbuildstr(names)\n\trows, err := qgen.NewAcc().Select(\"users\").Columns(\"uid,name,group,active,is_super_admin,session,email,avatar,message,level,score,posts,liked,last_ip,temp_group,createdAt,enable_embeds,profile_comments,who_can_convo\").Where(\"name IN(\" + q + \")\").Query(idList...)\n\tif err != nil {\n\t\treturn list, err\n\t}\n\tdefer rows.Close()\n\n\tvar embeds int\n\tfor rows.Next() {\n\t\tu := &User{Loggedin: true}\n\t\terr := rows.Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\ts.cache.Set(u)\n\t\tlist = append(list, u)\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn list, err\n\t}\n\n\t// Did we miss any users?\n\tif len(names) > len(list) {\n\t\treturn list, ErrSomeUsersNotFound\n\t}\n\treturn list, err\n}\n\n// Special case function for efficiency\nfunc (s *DefaultUserStore) RawBulkGetByNameForConvo(f func(int, string, int, bool, int, int) error, names []string) error {\n\tidList, q := inqbuildstr(names)\n\trows, e := qgen.NewAcc().Select(\"users\").Columns(\"uid,name,group,is_super_admin,temp_group,who_can_convo\").Where(\"name IN(\" + q + \")\").Query(idList...)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar name string\n\t\tvar id, group, temp_group, who_can_convo int\n\t\tvar super_admin bool\n\t\tif e = rows.Scan(&id, &name, &group, &super_admin, &temp_group, &who_can_convo); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e = f(id, name, group, super_admin, temp_group, who_can_convo); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\n// TODO: Optimise this, so we don't wind up hitting the database every-time for small gaps\n// TODO: Make this a little more consistent with DefaultGroupStore's GetRange method\nfunc (s *DefaultUserStore) GetOffset(offset, perPage int) (users []*User, err error) {\n\trows, err := s.getOffset.Query(offset, perPage)\n\tif err != nil {\n\t\treturn users, err\n\t}\n\tdefer rows.Close()\n\n\tvar embeds int\n\tfor rows.Next() {\n\t\tu := &User{Loggedin: true}\n\t\terr := rows.Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\ts.cache.Set(u)\n\t\tusers = append(users, u)\n\t}\n\treturn users, rows.Err()\n}\nfunc (s *DefaultUserStore) SearchOffset(name, email string, gid, offset, perPage int) (users []*User, err error) {\n\trows, err := s.searchOffset.Query(name, name, email, email, gid, gid, offset, perPage)\n\tif err != nil {\n\t\treturn users, err\n\t}\n\tdefer rows.Close()\n\n\tvar embeds int\n\tfor rows.Next() {\n\t\tu := &User{Loggedin: true}\n\t\terr := rows.Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\ts.cache.Set(u)\n\t\tusers = append(users, u)\n\t}\n\treturn users, rows.Err()\n}\nfunc (s *DefaultUserStore) Each(f func(*User) error) error {\n\trows, e := s.getAll.Query()\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tvar embeds int\n\tfor rows.Next() {\n\t\tu := new(User)\n\t\tif e := rows.Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\tif e := f(u); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\n// TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts?\n// TODO: ID of 0 should always error?\nfunc (s *DefaultUserStore) BulkGetMap(ids []int) (list map[int]*User, err error) {\n\tidCount := len(ids)\n\tlist = make(map[int]*User)\n\tif idCount == 0 {\n\t\treturn list, nil\n\t}\n\n\tvar stillHere []int\n\tsliceList := s.cache.BulkGet(ids)\n\tif len(sliceList) > 0 {\n\t\tfor i, sliceItem := range sliceList {\n\t\t\tif sliceItem != nil {\n\t\t\t\tlist[sliceItem.ID] = sliceItem\n\t\t\t} else {\n\t\t\t\tstillHere = append(stillHere, ids[i])\n\t\t\t}\n\t\t}\n\t\tids = stillHere\n\t}\n\n\t// If every user is in the cache, then return immediately\n\tif len(ids) == 0 {\n\t\treturn list, nil\n\t} else if len(ids) == 1 {\n\t\tuser, err := s.Get(ids[0])\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\t\tlist[user.ID] = user\n\t\treturn list, nil\n\t}\n\n\tidList, q := inqbuild(ids)\n\trows, err := qgen.NewAcc().Select(\"users\").Columns(\"uid,name,group,active,is_super_admin,session,email,avatar,message,level,score,posts,liked,last_ip,temp_group,createdAt,enable_embeds,profile_comments,who_can_convo\").Where(\"uid IN(\" + q + \")\").Query(idList...)\n\tif err != nil {\n\t\treturn list, err\n\t}\n\tdefer rows.Close()\n\n\tvar embeds int\n\tfor rows.Next() {\n\t\tu := &User{Loggedin: true}\n\t\terr := rows.Scan(&u.ID, &u.Name, &u.Group, &u.Active, &u.IsSuperAdmin, &u.Session, &u.Email, &u.RawAvatar, &u.Message, &u.Level, &u.Score, &u.Posts, &u.Liked, &u.LastIP, &u.TempGroup, &u.CreatedAt, &embeds, &u.Privacy.ShowComments, &u.Privacy.AllowMessage)\n\t\tif err != nil {\n\t\t\treturn list, err\n\t\t}\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t\ts.cache.Set(u)\n\t\tlist[u.ID] = u\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn list, err\n\t}\n\n\t// Did we miss any users?\n\tif idCount > len(list) {\n\t\tvar sidList string\n\t\tfor _, id := range ids {\n\t\t\t_, ok := list[id]\n\t\t\tif !ok {\n\t\t\t\tsidList += strconv.Itoa(id) + \",\"\n\t\t\t}\n\t\t}\n\t\tif sidList != \"\" {\n\t\t\tsidList = sidList[0 : len(sidList)-1]\n\t\t\terr = errors.New(\"Unable to find users with the following IDs: \" + sidList)\n\t\t}\n\t}\n\n\treturn list, err\n}\n\nfunc (s *DefaultUserStore) BypassGet(id int) (*User, error) {\n\tu := &User{ID: id, Loggedin: true}\n\tembeds, err := s.scanUser(s.get.QueryRow(id), u)\n\tif err == nil {\n\t\tif embeds != -1 {\n\t\t\tu.ParseSettings = DefaultParseSettings.CopyPtr()\n\t\t\tu.ParseSettings.NoEmbed = embeds == 0\n\t\t}\n\t\tu.Init()\n\t}\n\treturn u, err\n}\n\nfunc (s *DefaultUserStore) Reload(id int) error {\n\tu, err := s.BypassGet(id)\n\tif err != nil {\n\t\ts.cache.Remove(id)\n\t\treturn err\n\t}\n\t_ = s.cache.Set(u)\n\tTopicListThaw.Thaw()\n\treturn nil\n}\n\nfunc (s *DefaultUserStore) Exists(id int) bool {\n\terr := s.exists.QueryRow(id).Scan(&id)\n\tif err != nil && err != ErrNoRows {\n\t\tLogError(err)\n\t}\n\treturn err != ErrNoRows\n}\n\nfunc (s *DefaultUserStore) ClearLastIPs() error {\n\t_, e := s.clearIPs.Exec()\n\treturn e\n}\n\n// TODO: Change active to a bool?\n// TODO: Use unique keys for the usernames\nfunc (s *DefaultUserStore) Create(name, password, email string, group int, active bool) (int, error) {\n\t// TODO: Strip spaces?\n\n\t// ? This number might be a little screwy with Unicode, but it's the only consistent thing we have, as Unicode characters can be any number of bytes in theory?\n\tif len(name) > Config.MaxUsernameLength {\n\t\treturn 0, ErrLongUsername\n\t}\n\n\t// Is this name already taken..?\n\terr := s.nameExists.QueryRow(name).Scan(&name)\n\tif err != ErrNoRows {\n\t\treturn 0, ErrAccountExists\n\t}\n\tsalt, err := GenerateSafeString(SaltLength)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tres, err := s.register.Exec(name, email, string(hashedPassword), salt, group, active)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n\n// Count returns the total number of users registered on the forums\nfunc (s *DefaultUserStore) Count() (count int) {\n\treturn Countf(s.count)\n}\n\nfunc (s *DefaultUserStore) CountSearch(name, email string, gid int) (count int) {\n\treturn Countf(s.countSearch, name, name, email, email, gid, gid)\n}\n\nfunc (s *DefaultUserStore) SetCache(cache UserCache) {\n\ts.cache = cache\n}\n\n// TODO: We're temporarily doing this so that you can do ucache != nil in getTopicUser. Refactor it.\nfunc (s *DefaultUserStore) GetCache() UserCache {\n\t_, ok := s.cache.(*NullUserCache)\n\tif ok {\n\t\treturn nil\n\t}\n\treturn s.cache\n}\n"
  },
  {
    "path": "common/utils.go",
    "content": "/*\n*\n*\tUtility Functions And Stuff\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage common\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/base32\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"html\"\n\t\"io/ioutil\"\n\t\"math\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode\"\n)\n\n// Version stores a Gosora version\ntype Version struct {\n\tMajor int\n\tMinor int\n\tPatch int\n\tTag   string\n\tTagID int\n}\n\n// TODO: Write a test for this\nfunc (ver *Version) String() (out string) {\n\tout = strconv.Itoa(ver.Major) + \".\" + strconv.Itoa(ver.Minor) + \".\" + strconv.Itoa(ver.Patch)\n\tif ver.Tag != \"\" {\n\t\tout += \"-\" + ver.Tag\n\t\tif ver.TagID != 0 {\n\t\t\tout += strconv.Itoa(ver.TagID)\n\t\t}\n\t}\n\treturn\n}\n\n// GenerateSafeString is for generating a cryptographically secure set of random bytes which is base64 encoded and safe for URLs\n// TODO: Write a test for this\nfunc GenerateSafeString(len int) (string, error) {\n\trb := make([]byte, len)\n\t_, err := rand.Read(rb)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.URLEncoding.EncodeToString(rb), nil\n}\n\n// GenerateStd32SafeString is for generating a cryptographically secure set of random bytes which is base32 encoded\n// ? - Safe for URLs? Mostly likely due to the small range of characters\nfunc GenerateStd32SafeString(len int) (string, error) {\n\trb := make([]byte, len)\n\t_, err := rand.Read(rb)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base32.StdEncoding.EncodeToString(rb), nil\n}\n\n// TODO: Write a test for this\nfunc RelativeTimeFromString(in string) (string, error) {\n\tif in == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tt, err := time.Parse(\"2006-01-02 15:04:05\", in)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn RelativeTime(t), nil\n}\n\n// TODO: Write a test for this\nfunc RelativeTime(t time.Time) string {\n\tdiff := time.Since(t)\n\thours := diff.Hours()\n\tsecs := diff.Seconds()\n\tweeks := int(hours / 24 / 7)\n\tmonths := int(hours / 24 / 31)\n\tswitch {\n\tcase months > 3:\n\t\tif t.Year() != time.Now().Year() {\n\t\t\t//return t.Format(\"Mon Jan 2 2006\")\n\t\t\treturn t.Format(\"Jan 2 2006\")\n\t\t}\n\t\treturn t.Format(\"Jan 2\")\n\tcase months > 1:\n\t\treturn fmt.Sprintf(\"%d months ago\", months)\n\tcase months == 1:\n\t\treturn \"a month ago\"\n\tcase weeks > 1:\n\t\treturn fmt.Sprintf(\"%d weeks ago\", weeks)\n\tcase int(hours/24) == 7:\n\t\treturn \"a week ago\"\n\tcase int(hours/24) == 1:\n\t\treturn \"1 day ago\"\n\tcase int(hours/24) > 1:\n\t\treturn fmt.Sprintf(\"%d days ago\", int(hours/24))\n\tcase secs <= 1:\n\t\treturn \"a moment ago\"\n\tcase secs < 60:\n\t\treturn fmt.Sprintf(\"%d seconds ago\", int(secs))\n\tcase secs < 120:\n\t\treturn \"a minute ago\"\n\tcase secs < 3600:\n\t\treturn fmt.Sprintf(\"%d minutes ago\", int(secs/60))\n\tcase secs < 7200:\n\t\treturn \"an hour ago\"\n\t}\n\treturn fmt.Sprintf(\"%d hours ago\", int(secs/60/60))\n}\n\n// TODO: Finish this faster and more localised version of RelativeTime\n/*\n// TODO: Write a test for this\n// ! Experimental\nfunc RelativeTimeBytes(t time.Time, lang int) []byte {\n\tdiff := time.Since(t)\n\thours := diff.Hours()\n\tsecs := diff.Seconds()\n\tweeks := int(hours / 24 / 7)\n\tmonths := int(hours / 24 / 31)\n\tswitch {\n\tcase months > 3:\n\t\tif t.Year() != time.Now().Year() {\n\t\t\treturn []byte(t.Format(phrases.RTime.MultiYear(lang)))\n\t\t}\n\t\treturn []byte(t.Format(phrases.RTime.SingleYear(lang)))\n\tcase months > 1:\n\t\treturn phrases.RTime.Months(lang, months)\n\tcase months == 1:\n\t\treturn phrases.RTime.Month(lang)\n\tcase weeks > 1:\n\t\treturn phrases.RTime.Weeks(lang, weeks)\n\tcase int(hours/24) == 7:\n\t\treturn phrases.RTime.Week(lang)\n\tcase int(hours/24) == 1:\n\t\treturn phrases.RTime.Day(lang)\n\tcase int(hours/24) > 1:\n\t\treturn phrases.RTime.Days(lang, int(hours/24))\n\tcase secs <= 1:\n\t\treturn phrases.RTime.Moment(lang)\n\tcase secs < 60:\n\t\treturn phrases.RTime.Seconds(lang, int(secs))\n\tcase secs < 120:\n\t\treturn phrases.RTime.Minute(lang)\n\tcase secs < 3600:\n\t\treturn phrases.RTime.Minutes(lang, int(secs/60))\n\tcase secs < 7200:\n\t\treturn phrases.RTime.Hour(lang)\n\t}\n\treturn phrases.RTime.Hours(lang, int(secs/60/60))\n}\n*/\n\nvar pMs = 1000\nvar pSec = pMs * 1000\nvar pMin = pSec * 60\nvar pHour = pMin * 60\nvar pDay = pHour * 24\n\nfunc ConvertPerfUnit(quan float64) (out float64, unit string) {\n\tf := func() (float64, string) {\n\t\tswitch {\n\t\tcase quan >= float64(pDay):\n\t\t\treturn quan / float64(pDay), \"d\"\n\t\tcase quan >= float64(pHour):\n\t\t\treturn quan / float64(pHour), \"h\"\n\t\tcase quan >= float64(pMin):\n\t\t\treturn quan / float64(pMin), \"m\"\n\t\tcase quan >= float64(pSec):\n\t\t\treturn quan / float64(pSec), \"s\"\n\t\tcase quan >= float64(pMs):\n\t\t\treturn quan / float64(pMs), \"ms\"\n\t\t}\n\t\treturn quan, \"μs\"\n\t}\n\tout, unit = f()\n\treturn math.Ceil(out), unit\n}\n\n// TODO: Write a test for this\nfunc ConvertByteUnit(bytes float64) (float64, string) {\n\tswitch {\n\tcase bytes >= float64(Petabyte):\n\t\treturn bytes / float64(Petabyte), \"PB\"\n\tcase bytes >= float64(Terabyte):\n\t\treturn bytes / float64(Terabyte), \"TB\"\n\tcase bytes >= float64(Gigabyte):\n\t\treturn bytes / float64(Gigabyte), \"GB\"\n\tcase bytes >= float64(Megabyte):\n\t\treturn bytes / float64(Megabyte), \"MB\"\n\tcase bytes >= float64(Kilobyte):\n\t\treturn bytes / float64(Kilobyte), \"KB\"\n\t}\n\treturn bytes, \" bytes\"\n}\n\n// TODO: Write a test for this\nfunc ConvertByteInUnit(bytes float64, unit string) (count float64) {\n\tswitch unit {\n\tcase \"PB\":\n\t\tcount = bytes / float64(Petabyte)\n\tcase \"TB\":\n\t\tcount = bytes / float64(Terabyte)\n\tcase \"GB\":\n\t\tcount = bytes / float64(Gigabyte)\n\tcase \"MB\":\n\t\tcount = bytes / float64(Megabyte)\n\tcase \"KB\":\n\t\tcount = bytes / float64(Kilobyte)\n\tdefault:\n\t\tcount = 0.1\n\t}\n\n\tif count < 0.1 {\n\t\tcount = 0.1\n\t}\n\treturn\n}\n\n// TODO: Write a test for this\n// TODO: Localise this?\nfunc FriendlyUnitToBytes(quantity int, unit string) (bytes int, err error) {\n\tswitch unit {\n\tcase \"PB\":\n\t\tbytes = quantity * Petabyte\n\tcase \"TB\":\n\t\tbytes = quantity * Terabyte\n\tcase \"GB\":\n\t\tbytes = quantity * Gigabyte\n\tcase \"MB\":\n\t\tbytes = quantity * Megabyte\n\tcase \"KB\":\n\t\tbytes = quantity * Kilobyte\n\tcase \"\":\n\t\t// Do nothing\n\tdefault:\n\t\treturn bytes, errors.New(\"Unknown unit\")\n\t}\n\treturn bytes, nil\n}\n\n// TODO: Write a test for this\n// TODO: Re-add T as int64\nfunc ConvertUnit(num int) (int, string) {\n\tswitch {\n\tcase num >= 1000000000000:\n\t\treturn num / 1000000000000, \"T\"\n\tcase num >= 1000000000:\n\t\treturn num / 1000000000, \"B\"\n\tcase num >= 1000000:\n\t\treturn num / 1000000, \"M\"\n\tcase num >= 1000:\n\t\treturn num / 1000, \"K\"\n\t}\n\treturn num, \"\"\n}\n\n// TODO: Write a test for this\n// TODO: Re-add quadrillion as int64\n// TODO: Re-add trillion as int64\nfunc ConvertFriendlyUnit(num int) (int, string) {\n\tswitch {\n\tcase num >= 1000000000000000:\n\t\treturn 0, \" quadrillion\"\n\tcase num >= 1000000000000:\n\t\treturn 0, \" trillion\"\n\tcase num >= 1000000000:\n\t\treturn num / 1000000000, \" billion\"\n\tcase num >= 1000000:\n\t\treturn num / 1000000, \" million\"\n\tcase num >= 1000:\n\t\treturn num / 1000, \" thousand\"\n\t}\n\treturn num, \"\"\n}\n\n// TODO: Make slugs optional for certain languages across the entirety of Gosora?\n// TODO: Let plugins replace NameToSlug and the URL building logic with their own\n/*func NameToSlug(name string) (slug string) {\n\t// TODO: Do we want this reliant on config file flags? This might complicate tests and oddball uses\n\tif !Config.BuildSlugs {\n\t\treturn \"\"\n\t}\n\tname = strings.TrimSpace(name)\n\tname = strings.Replace(name, \"  \", \" \", -1)\n\n\tfor _, char := range name {\n\t\tif unicode.IsLower(char) || unicode.IsNumber(char) {\n\t\t\tslug += string(char)\n\t\t} else if unicode.IsUpper(char) {\n\t\t\tslug += string(unicode.ToLower(char))\n\t\t} else if unicode.IsSpace(char) {\n\t\t\tslug += \"-\"\n\t\t}\n\t}\n\n\tif slug == \"\" {\n\t\tslug = \"untitled\"\n\t}\n\treturn slug\n}*/\n\n// TODO: Make slugs optional for certain languages across the entirety of Gosora?\n// TODO: Let plugins replace NameToSlug and the URL building logic with their own\nfunc NameToSlug(name string) (slug string) {\n\t// TODO: Do we want this reliant on config file flags? This might complicate tests and oddball uses\n\tif !Config.BuildSlugs {\n\t\treturn \"\"\n\t}\n\tname = strings.TrimSpace(name)\n\tname = strings.Replace(name, \"  \", \" \", -1)\n\n\tvar sb strings.Builder\n\tfor _, char := range name {\n\t\tif unicode.IsLower(char) || unicode.IsNumber(char) {\n\t\t\tsb.WriteRune(char)\n\t\t} else if unicode.IsUpper(char) {\n\t\t\tsb.WriteRune(unicode.ToLower(char))\n\t\t} else if unicode.IsSpace(char) {\n\t\t\tsb.WriteByte('-')\n\t\t}\n\t}\n\n\tif sb.Len() == 0 {\n\t\treturn \"untitled\"\n\t}\n\treturn sb.String()\n}\n\n// TODO: Write a test for this\nfunc HasSuspiciousEmail(email string) bool {\n\tif email == \"\" {\n\t\treturn false\n\t}\n\tlowEmail := strings.ToLower(email)\n\t// TODO: Use a more flexible blacklist, perhaps with a similar mechanism to the HTML tag registration system in PreparseMessage()\n\tif !strings.Contains(lowEmail, \"@\") || strings.Contains(lowEmail, \"casino\") || strings.Contains(lowEmail, \"viagra\") || strings.Contains(lowEmail, \"pharma\") || strings.Contains(lowEmail, \"pill\") {\n\t\treturn true\n\t}\n\n\tvar dotCount, shortBits, currentSegmentLength int\n\tfor _, char := range lowEmail {\n\t\tif char == '.' {\n\t\t\tdotCount++\n\t\t\tif currentSegmentLength < 3 {\n\t\t\t\tshortBits++\n\t\t\t}\n\t\t\tcurrentSegmentLength = 0\n\t\t} else {\n\t\t\tcurrentSegmentLength++\n\t\t}\n\t}\n\n\treturn dotCount > 7 || shortBits > 2\n}\n\nfunc unmarshalJsonFile(name string, in interface{}) error {\n\tdata, err := ioutil.ReadFile(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, in)\n}\n\nfunc unmarshalJsonFileIgnore404(name string, in interface{}) error {\n\tdata, err := ioutil.ReadFile(name)\n\tif err == os.ErrPermission || err == os.ErrClosed {\n\t\treturn err\n\t} else if err != nil {\n\t\treturn nil\n\t}\n\treturn json.Unmarshal(data, in)\n}\n\nfunc CanonEmail(email string) string {\n\temail = strings.ToLower(email)\n\n\t// Gmail emails are equivalent without the dots\n\tespl := strings.Split(email, \"@\")\n\tif len(espl) >= 2 && espl[1] == \"gmail.com\" {\n\t\treturn strings.Replace(espl[0], \".\", \"\", -1) + \"@\" + espl[1]\n\t}\n\n\treturn email\n}\n\n// TODO: Write a test for this\nfunc createFile(name string) error {\n\tf, err := os.Create(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn f.Close()\n}\n\n// TODO: Write a test for this\nfunc writeFile(name, content string) (err error) {\n\tf, err := os.Create(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.WriteString(content)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = f.Sync()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn f.Close()\n}\n\n// TODO: Write a test for this\nfunc Stripslashes(text string) string {\n\ttext = strings.Replace(text, \"/\", \"\", -1)\n\treturn strings.Replace(text, \"\\\\\", \"\", -1)\n}\n\n// The word counter might run into problems with some languages where words aren't as obviously demarcated, I would advise turning it off in those cases, or if it becomes annoying in general, really.\nfunc WordCount(input string) (count int) {\n\tinput = strings.TrimSpace(input)\n\tif input == \"\" {\n\t\treturn 0\n\t}\n\n\tvar inSpace bool\n\tfor _, value := range input {\n\t\tif unicode.IsSpace(value) || unicode.IsPunct(value) {\n\t\t\tif !inSpace {\n\t\t\t\tinSpace = true\n\t\t\t}\n\t\t} else if inSpace {\n\t\t\tcount++\n\t\t\tinSpace = false\n\t\t}\n\t}\n\n\treturn count + 1\n}\n\n// TODO: Write a test for this\nfunc GetLevel(score int) (level int) {\n\tvar base float64 = 25\n\tvar current, prev float64\n\tvar expFactor = 2.8\n\n\tfor i := 1; ; i++ {\n\t\t_, bit := math.Modf(float64(i) / 10)\n\t\tif bit == 0 {\n\t\t\texpFactor += 0.1\n\t\t}\n\t\tcurrent = base + math.Pow(float64(i), expFactor) + (prev / 3)\n\t\tprev = current\n\t\tif float64(score) < current {\n\t\t\tbreak\n\t\t}\n\t\tlevel++\n\t}\n\treturn level\n}\n\n// TODO: Write a test for this\nfunc GetLevelScore(getLevel int) (score int) {\n\tvar base float64 = 25\n\tvar current float64\n\tvar expFactor = 2.8\n\n\tfor i := 1; i <= getLevel; i++ {\n\t\t_, bit := math.Modf(float64(i) / 10)\n\t\tif bit == 0 {\n\t\t\texpFactor += 0.1\n\t\t}\n\t\tcurrent = base + math.Pow(float64(i), expFactor) + (current / 3)\n\t\t//fmt.Println(\"level: \", i)\n\t\t//fmt.Println(\"current: \", current)\n\t}\n\treturn int(math.Ceil(current))\n}\n\n// TODO: Write a test for this\nfunc GetLevels(maxLevel int) []float64 {\n\tvar base float64 = 25\n\tvar current, prev float64 // = 0\n\tvar expFactor = 2.8\n\tvar out []float64\n\tout = append(out, 0)\n\n\tfor i := 1; i <= maxLevel; i++ {\n\t\t_, bit := math.Modf(float64(i) / 10)\n\t\tif bit == 0 {\n\t\t\texpFactor += 0.1\n\t\t}\n\t\tcurrent = base + math.Pow(float64(i), expFactor) + (prev / 3)\n\t\tprev = current\n\t\tout = append(out, current)\n\t}\n\treturn out\n}\n\n// TODO: Write a test for this\n// SanitiseSingleLine is a generic function for escaping html entities and removing silly characters from usernames and topic titles. It also strips newline characters\nfunc SanitiseSingleLine(in string) string {\n\tin = strings.Replace(in, \"\\n\", \"\", -1)\n\tin = strings.Replace(in, \"\\r\", \"\", -1)\n\treturn SanitiseBody(in)\n}\n\n// TODO: Write a test for this\n// TODO: Add more strange characters\n// TODO: Strip all sub-32s minus \\r and \\n?\n// SanitiseBody is the same as SanitiseSingleLine, but it doesn't strip newline characters\nfunc SanitiseBody(in string) string {\n\tin = strings.Replace(in, \"​\", \"\", -1) // Strip Zero length space\n\tin = html.EscapeString(in)\n\treturn strings.TrimSpace(in)\n}\n\nfunc BuildSlug(slug string, id int) string {\n\tif slug == \"\" || !Config.BuildSlugs {\n\t\treturn strconv.Itoa(id)\n\t}\n\treturn slug + \".\" + strconv.Itoa(id)\n}\n"
  },
  {
    "path": "common/weak_passwords.go",
    "content": "package common\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode\"\n)\n\nvar weakPassStrings []string\nvar weakPassLit = make(map[string]struct{})\nvar ErrWeakPasswordNone = errors.New(\"You didn't put in a password.\")\nvar ErrWeakPasswordShort = errors.New(\"Your password needs to be at-least eight characters long\")\nvar ErrWeakPasswordNameInPass = errors.New(\"You can't use your name in your password.\")\nvar ErrWeakPasswordEmailInPass = errors.New(\"You can't use your email in your password.\")\nvar ErrWeakPasswordCommon = errors.New(\"You may not use a password that is in common use\")\nvar ErrWeakPasswordNoNumbers = errors.New(\"You don't have any numbers in your password\")\nvar ErrWeakPasswordNoUpper = errors.New(\"You don't have any uppercase characters in your password\")\nvar ErrWeakPasswordNoLower = errors.New(\"You don't have any lowercase characters in your password\")\nvar ErrWeakPasswordUniqueChars = errors.New(\"You don't have enough unique characters in your password\")\nvar ErrWeakPasswordContains error\n\ntype weakpassHolder struct {\n\tContains []string `json:\"contains\"`\n\tLiteral  []string `json:\"literal\"`\n}\n\nfunc InitWeakPasswords() error {\n\tvar weakpass weakpassHolder\n\te := unmarshalJsonFile(\"./config/weakpass_default.json\", &weakpass)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\twcon := make(map[string]struct{})\n\tfor _, item := range weakpass.Contains {\n\t\twcon[item] = struct{}{}\n\t}\n\tfor _, item := range weakpass.Literal {\n\t\tweakPassLit[item] = struct{}{}\n\t}\n\n\tweakpass = weakpassHolder{}\n\te = unmarshalJsonFileIgnore404(\"./config/weakpass.json\", &weakpass)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\tfor _, item := range weakpass.Contains {\n\t\twcon[item] = struct{}{}\n\t}\n\tfor _, item := range weakpass.Literal {\n\t\tweakPassLit[item] = struct{}{}\n\t}\n\tweakPassStrings = make([]string, len(wcon))\n\tvar i int\n\tfor pattern, _ := range wcon {\n\t\tweakPassStrings[i] = pattern\n\t\ti++\n\t}\n\n\ts := \"You may not have \"\n\tfor i, passBit := range weakPassStrings {\n\t\tif i > 0 {\n\t\t\tif i == len(weakPassStrings)-1 {\n\t\t\t\ts += \" or \"\n\t\t\t} else {\n\t\t\t\ts += \", \"\n\t\t\t}\n\t\t}\n\t\ts += \"'\" + passBit + \"'\"\n\t}\n\tErrWeakPasswordContains = errors.New(s + \" in your password\")\n\n\treturn nil\n}\n\nfunc WeakPassword(password, username, email string) error {\n\tlowPassword := strings.ToLower(password)\n\tswitch {\n\tcase password == \"\":\n\t\treturn ErrWeakPasswordNone\n\tcase len(password) < 8:\n\t\treturn ErrWeakPasswordShort\n\tcase len(username) > 3 && strings.Contains(lowPassword, strings.ToLower(username)):\n\t\treturn ErrWeakPasswordNameInPass\n\tcase len(email) > 2 && strings.Contains(lowPassword, strings.ToLower(email)):\n\t\treturn ErrWeakPasswordEmailInPass\n\t}\n\tif len(lowPassword) > 30 {\n\t\treturn nil\n\t}\n\n\tlitPass := lowPassword\n\tfor i := 0; i < 10; i++ {\n\t\tlitPass = strings.TrimSuffix(litPass, strconv.Itoa(i))\n\t}\n\t_, ok := weakPassLit[litPass]\n\tif ok {\n\t\treturn ErrWeakPasswordCommon\n\t}\n\tfor _, passBit := range weakPassStrings {\n\t\tif strings.Contains(lowPassword, passBit) {\n\t\t\treturn ErrWeakPasswordContains\n\t\t}\n\t}\n\n\tcharMap := make(map[rune]int)\n\tvar numbers, symbols, upper, lower int\n\tfor _, char := range password {\n\t\tcharItem, ok := charMap[char]\n\t\tif ok {\n\t\t\tcharItem++\n\t\t} else {\n\t\t\tcharItem = 1\n\t\t}\n\t\tcharMap[char] = charItem\n\n\t\tif unicode.IsLetter(char) {\n\t\t\tif unicode.IsUpper(char) {\n\t\t\t\tupper++\n\t\t\t} else {\n\t\t\t\tlower++\n\t\t\t}\n\t\t} else if unicode.IsNumber(char) {\n\t\t\tnumbers++\n\t\t} else {\n\t\t\tsymbols++\n\t\t}\n\t}\n\n\tif upper == 0 {\n\t\treturn ErrWeakPasswordNoUpper\n\t}\n\tif lower == 0 {\n\t\treturn ErrWeakPasswordNoLower\n\t}\n\tif len(password) < 18 {\n\t\tif numbers == 0 {\n\t\t\treturn ErrWeakPasswordNoNumbers\n\t\t}\n\t\tif (len(password) / 2) > len(charMap) {\n\t\t\treturn ErrWeakPasswordUniqueChars\n\t\t}\n\t} else if (len(password) / 3) > len(charMap) {\n\t\t// Be a little lenient on the number of unique characters for long passwords\n\t\treturn ErrWeakPasswordUniqueChars\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/websockets.go",
    "content": "// +build !no_ws\n\n/*\n*\n*\tGosora WebSocket Subsystem\n*\tCopyright Azareal 2017 - 2021\n*\n */\npackage common\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\t\"github.com/Azareal/gopsutil/cpu\"\n\t\"github.com/Azareal/gopsutil/mem\"\n\t\"github.com/gorilla/websocket\"\n)\n\n// TODO: Disable WebSockets on high load? Add a Control Panel interface for disabling it?\nvar EnableWebsockets = true // Put this in caps for consistency with the other constants?\n\nvar wsUpgrader = websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024}\nvar errWsNouser = errors.New(\"This user isn't connected via WebSockets\")\n\nfunc init() {\n\tadminStatsWatchers = make(map[*websocket.Conn]*WSUser)\n\ttopicListWatchers = make(map[*WSUser]struct{})\n\ttopicWatchers = make(map[int]map[*WSUser]struct{})\n}\n\n//easyjson:json\ntype WsTopicList struct {\n\tTopics     []*WsTopicsRow\n\tLastPage   int // Not for WebSockets, but for the JSON endpoint for /topics/ to keep the paginator functional\n\tLastUpdate int64\n}\n\n// TODO: How should we handle errors for this?\n// TODO: Move this out of common?\nfunc RouteWebsockets(w http.ResponseWriter, r *http.Request, user *User) RouteError {\n\t// TODO: Spit out a 500 instead of nil?\n\tconn, err := wsUpgrader.Upgrade(w, r, nil)\n\tif err != nil {\n\t\treturn LocalError(\"unable to upgrade\", w, r, user)\n\t}\n\tdefer conn.Close()\n\n\twsUser, err := WsHub.AddConn(user, conn)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\t//conn.SetReadLimit(/* put the max request size from earlier here? */)\n\t//conn.SetReadDeadline(time.Now().Add(60 * time.Second))\n\tvar currentPage string\n\tfor {\n\t\t_, message, err := conn.ReadMessage()\n\t\tif err != nil {\n\t\t\tif user.ID == 0 {\n\t\t\t\tWsHub.GuestLock.Lock()\n\t\t\t\tdelete(WsHub.OnlineGuests, wsUser)\n\t\t\t\tWsHub.GuestLock.Unlock()\n\t\t\t} else {\n\t\t\t\t// TODO: Make sure the admin is removed from the admin stats list in the case that an error happens\n\t\t\t\tWsHub.RemoveConn(wsUser, conn)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif conn == nil {\n\t\t\tpanic(\"conn must not be nil\")\n\t\t}\n\n\t\tfor _, msg := range bytes.Split(message, []byte(\"\\r\")) {\n\t\t\t//StoppedServer(\"Profile end\") // A bit of code for me to profile the software\n\t\t\tif bytes.HasPrefix(msg, []byte(\"page \")) {\n\t\t\t\tmsgblocks := bytes.SplitN(msg, []byte(\" \"), 2)\n\t\t\t\tif len(msgblocks) < 2 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !bytes.Equal(msgblocks[1], []byte(currentPage)) {\n\t\t\t\t\twsLeavePage(wsUser, conn, currentPage)\n\t\t\t\t\tcurrentPage = string(msgblocks[1])\n\t\t\t\t\twsPageResponses(wsUser, conn, currentPage)\n\t\t\t\t}\n\t\t\t} else if bytes.HasPrefix(msg, []byte(\"resume \")) {\n\t\t\t\tmsgblocks := bytes.SplitN(msg, []byte(\" \"), 3)\n\t\t\t\tif len(msgblocks) < 3 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t//log.Print(\"resuming on \" + string(msgblocks[1]) + \" at \" + string(msgblocks[2]))\n\n\t\t\t\tif !bytes.Equal(msgblocks[1], []byte(currentPage)) {\n\t\t\t\t\twsLeavePage(wsUser, conn, currentPage) // Avoid clients abusing late resumes\n\t\t\t\t\tcurrentPage = string(msgblocks[1])\n\t\t\t\t\t// TODO: Synchronise this better?\n\t\t\t\t\tresume, err := strconv.ParseInt(string(msgblocks[2]), 10, 64)\n\t\t\t\t\twsPageResponses(wsUser, conn, currentPage)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\twsPageResume(wsUser, conn, currentPage, resume)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t/*if bytes.Equal(message,[]byte(`start-view`)) {\n\t\t\t} else if bytes.Equal(message,[]byte(`end-view`)) {\n\t\t\t}*/\n\t\t}\n\t}\n\tDebugLog(\"Closing connection for user \" + strconv.Itoa(user.ID))\n\treturn nil\n}\n\n// TODO: Copied from routes package for use in wsPageResponse, find a more elegant solution.\nfunc ParseSEOURL(urlBit string) (slug string, id int, err error) {\n\thalves := strings.Split(urlBit, \".\")\n\tif len(halves) < 2 {\n\t\thalves = append(halves, halves[0])\n\t}\n\ttid, err := strconv.Atoi(halves[1])\n\treturn halves[0], tid, err\n}\n\n// TODO: Use a map instead of a switch to make this more modular?\nfunc wsPageResponses(wsUser *WSUser, conn *websocket.Conn, page string) {\n\tif page == \"/\" {\n\t\tpage = Config.DefaultPath\n\t}\n\n\tDebugLog(\"Entering page \" + page)\n\tswitch {\n\t// Live Topic List is an experimental feature\n\t// TODO: Optimise this to reduce the amount of contention\n\tcase page == \"/topics/\":\n\t\ttopicListMutex.Lock()\n\t\ttopicListWatchers[wsUser] = struct{}{}\n\t\ttopicListMutex.Unlock()\n\t\t// TODO: Evict from page when permissions change? Or check user perms every-time before sending data?\n\tcase strings.HasPrefix(page, \"/topic/\"):\n\t\t//fmt.Println(\"entering topic prefix websockets zone\")\n\t\tif wsUser.User.ID == 0 {\n\t\t\treturn\n\t\t}\n\t\t_, tid, e := ParseSEOURL(page)\n\t\tif e != nil {\n\t\t\treturn\n\t\t}\n\t\ttopic, e := Topics.Get(tid)\n\t\tif e != nil {\n\t\t\treturn\n\t\t}\n\t\tif !Forums.Exists(topic.ParentID) {\n\t\t\treturn\n\t\t}\n\t\tusercpy := BlankUser()\n\t\t*usercpy = *wsUser.User\n\t\tusercpy.Init()\n\n\t\t/*skip, rerr := header.Hooks.VhookSkippable(\"ws_topic_check_pre_perms\", w, r, usercpy, &fid, &header)\n\t\tif skip || rerr != nil {\n\t\t\treturn\n\t\t}*/\n\n\t\tfperms, e := FPStore.Get(topic.ParentID, usercpy.Group)\n\t\tif e == ErrNoRows {\n\t\t\tfperms = BlankForumPerms()\n\t\t} else if e != nil {\n\t\t\treturn\n\t\t}\n\t\tcascadeForumPerms(fperms, usercpy)\n\t\tif !usercpy.Perms.ViewTopic {\n\t\t\treturn\n\t\t}\n\n\t\ttopicMutex.Lock()\n\t\t_, ok := topicWatchers[topic.ID]\n\t\tif !ok {\n\t\t\ttopicWatchers[topic.ID] = make(map[*WSUser]struct{})\n\t\t}\n\t\ttopicWatchers[topic.ID][wsUser] = struct{}{}\n\t\ttopicMutex.Unlock()\n\tcase page == \"/panel/\":\n\t\tif !wsUser.User.IsSuperMod {\n\t\t\treturn\n\t\t}\n\t\t// Listen for changes and inform the admins...\n\t\tadminStatsMutex.Lock()\n\t\twatchers := len(adminStatsWatchers)\n\t\tadminStatsWatchers[conn] = wsUser\n\t\tif watchers == 0 {\n\t\t\tgo func() {\n\t\t\t\tdefer EatPanics()\n\t\t\t\tadminStatsTicker()\n\t\t\t}()\n\t\t}\n\t\tadminStatsMutex.Unlock()\n\tdefault:\n\t\treturn\n\t}\n\te := wsUser.SetPageForSocket(conn, page)\n\tif e != nil {\n\t\tLogError(e)\n\t}\n}\n\n// TODO: Use a map instead of a switch to make this more modular?\n// TODO: Implement this\nfunc wsPageResume(wsUser *WSUser, conn *websocket.Conn, page string, resume int64) {\n\tif page == \"/\" {\n\t\tpage = Config.DefaultPath\n\t}\n\n\tswitch {\n\t// TODO: Synchronise this bit of resume with tick updating lastTopicList?\n\tcase page == \"/topics/\":\n\t\t/*if resume >= hub.lastTick.Unix() {\n\t\t\tconn.Write([]byte(\"resume tooslow\"))\n\t\t} else {\n\t\t\tconn.Write([]byte(\"resume success\"))\n\t\t}*/\n\tdefault:\n\t\treturn\n\t}\n}\n\n// TODO: Use a map instead of a switch to make this more modular?\nfunc wsLeavePage(wsUser *WSUser, conn *websocket.Conn, page string) {\n\tif page == \"/\" {\n\t\tpage = Config.DefaultPath\n\t} else if page != \"\" {\n\t\tDebugLog(\"Leaving page \" + page)\n\t}\n\tswitch {\n\tcase page == \"/topics/\":\n\t\twsUser.FinalizePage(\"/topics/\", func() {\n\t\t\ttopicListMutex.Lock()\n\t\t\tdelete(topicListWatchers, wsUser)\n\t\t\ttopicListMutex.Unlock()\n\t\t})\n\tcase strings.HasPrefix(page, \"/topic/\"):\n\t\t//fmt.Println(\"leaving topic prefix websockets zone\")\n\t\tif wsUser.User.ID == 0 {\n\t\t\treturn\n\t\t}\n\t\twsUser.FinalizePage(page, func() {\n\t\t\t_, tid, e := ParseSEOURL(page)\n\t\t\tif e != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttopicMutex.Lock()\n\t\t\tdefer topicMutex.Unlock()\n\t\t\ttopic, ok := topicWatchers[tid]\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif _, ok = topic[wsUser]; !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdelete(topic, wsUser)\n\t\t\tif len(topic) == 0 {\n\t\t\t\tdelete(topicWatchers, tid)\n\t\t\t}\n\t\t})\n\tcase page == \"/panel/\":\n\t\tadminStatsMutex.Lock()\n\t\tdelete(adminStatsWatchers, conn)\n\t\tadminStatsMutex.Unlock()\n\t}\n\te := wsUser.SetPageForSocket(conn, \"\")\n\tif e != nil {\n\t\tLogError(e)\n\t}\n}\n\n// TODO: Abstract this\n// TODO: Use odd-even sharding\nvar topicListWatchers map[*WSUser]struct{}\nvar topicListMutex sync.RWMutex\nvar topicWatchers map[int]map[*WSUser]struct{} // map[tid]watchers\nvar topicMutex sync.RWMutex\nvar adminStatsWatchers map[*websocket.Conn]*WSUser\nvar adminStatsMutex sync.RWMutex\n\nfunc adminStatsTicker() {\n\ttime.Sleep(time.Second)\n\n\tlastUonline, lastGonline, lastTotonline := -1, -1, -1\n\tlastCPUPerc := -1\n\tvar lastAvailableRAM int64 = -1\n\tvar noStatUpdates, noRAMUpdates bool\n\n\tvar onlineColour, onlineGuestsColour, onlineUsersColour, cpustr, cpuColour, ramstr, ramColour string\n\tvar cpuerr, ramerr error\n\tvar memres *mem.VirtualMemoryStat\n\tvar cpuPerc []float64\n\n\tvar totunit, uunit, gunit string\n\n\tlessThanSwitch := func(number, lowerBound, midBound int) string {\n\t\tswitch {\n\t\tcase number < lowerBound:\n\t\t\treturn \"stat_green\"\n\t\tcase number < midBound:\n\t\t\treturn \"stat_orange\"\n\t\t}\n\t\treturn \"stat_red\"\n\t}\n\tgreaterThanSwitch := func(number, lowerBound, midBound int) string {\n\t\tswitch {\n\t\tcase number > midBound:\n\t\t\treturn \"stat_green\"\n\t\tcase number > lowerBound:\n\t\t\treturn \"stat_orange\"\n\t\t}\n\t\treturn \"stat_red\"\n\t}\n\nAdminStatLoop:\n\tfor {\n\t\tadminStatsMutex.RLock()\n\t\twatchCount := len(adminStatsWatchers)\n\t\tadminStatsMutex.RUnlock()\n\t\tif watchCount == 0 {\n\t\t\tbreak AdminStatLoop\n\t\t}\n\n\t\tcpuPerc, cpuerr = cpu.Percent(time.Second, true)\n\t\tmemres, ramerr = mem.VirtualMemory()\n\t\tuonline := WsHub.UserCount()\n\t\tgonline := WsHub.GuestCount()\n\t\ttotonline := uonline + gonline\n\t\treqCount := 0\n\n\t\t// It's far more likely that the CPU Usage will change than the other stats, so we'll optimise them separately...\n\t\tnoStatUpdates = (uonline == lastUonline && gonline == lastGonline && totonline == lastTotonline)\n\t\tnoRAMUpdates = (lastAvailableRAM == int64(memres.Available))\n\t\tif int(cpuPerc[0]) == lastCPUPerc && noStatUpdates && noRAMUpdates {\n\t\t\ttime.Sleep(time.Second)\n\t\t\tcontinue\n\t\t}\n\n\t\tif !noStatUpdates {\n\t\t\tonlineColour = greaterThanSwitch(totonline, 3, 10)\n\t\t\tonlineGuestsColour = greaterThanSwitch(gonline, 1, 10)\n\t\t\tonlineUsersColour = greaterThanSwitch(uonline, 1, 5)\n\n\t\t\ttotonline, totunit = ConvertFriendlyUnit(totonline)\n\t\t\tuonline, uunit = ConvertFriendlyUnit(uonline)\n\t\t\tgonline, gunit = ConvertFriendlyUnit(gonline)\n\t\t}\n\n\t\tif cpuerr != nil {\n\t\t\tcpustr = \"Unknown\"\n\t\t} else {\n\t\t\tcalcperc := int(cpuPerc[0]) / runtime.NumCPU()\n\t\t\tcpustr = strconv.Itoa(calcperc)\n\t\t\tswitch {\n\t\t\tcase calcperc < 30:\n\t\t\t\tcpuColour = \"stat_green\"\n\t\t\tcase calcperc < 75:\n\t\t\t\tcpuColour = \"stat_orange\"\n\t\t\tdefault:\n\t\t\t\tcpuColour = \"stat_red\"\n\t\t\t}\n\t\t}\n\n\t\tif !noRAMUpdates {\n\t\t\tif ramerr != nil {\n\t\t\t\tramstr = \"Unknown\"\n\t\t\t} else {\n\t\t\t\ttotalCount, totalUnit := ConvertByteUnit(float64(memres.Total))\n\t\t\t\tusedCount := ConvertByteInUnit(float64(memres.Total-memres.Available), totalUnit)\n\n\t\t\t\t// Round totals with .9s up, it's how most people see it anyway. Floats are notoriously imprecise, so do it off 0.85\n\t\t\t\tvar totstr string\n\t\t\t\tif (totalCount - float64(int(totalCount))) > 0.85 {\n\t\t\t\t\tusedCount += 1.0 - (totalCount - float64(int(totalCount)))\n\t\t\t\t\ttotstr = strconv.Itoa(int(totalCount) + 1)\n\t\t\t\t} else {\n\t\t\t\t\ttotstr = fmt.Sprintf(\"%.1f\", totalCount)\n\t\t\t\t}\n\n\t\t\t\tif usedCount > totalCount {\n\t\t\t\t\tusedCount = totalCount\n\t\t\t\t}\n\t\t\t\tramstr = fmt.Sprintf(\"%.1f\", usedCount) + \" / \" + totstr + totalUnit\n\n\t\t\t\tramperc := ((memres.Total - memres.Available) * 100) / memres.Total\n\t\t\t\tramColour = lessThanSwitch(int(ramperc), 50, 75)\n\t\t\t}\n\t\t}\n\n\t\t// Acquire a write lock for now, so we can handle the delete() case below and the read one simultaneously\n\t\t// TODO: Stop taking a write lock here if it isn't necessary\n\t\tadminStatsMutex.Lock()\n\t\tfor conn := range adminStatsWatchers {\n\t\t\tw, err := conn.NextWriter(websocket.TextMessage)\n\t\t\tif err != nil {\n\t\t\t\tdelete(adminStatsWatchers, conn)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// nolint\n\t\t\t// TODO: Use JSON for this to make things more portable and easier to convert to MessagePack, if need be?\n\t\t\twrite := func(msg string) {\n\t\t\t\tw.Write([]byte(msg + \"\\r\"))\n\t\t\t}\n\t\t\tpush := func(id, msg string) {\n\t\t\t\twrite(\"set #\" + id + \" <span>\" + msg + \"</span>\")\n\t\t\t}\n\t\t\tpushc := func(id, classes string) {\n\t\t\t\twrite(\"set-class #\" + id + \" \" + classes)\n\t\t\t}\n\t\t\tif !noStatUpdates {\n\t\t\t\tpush(\"dash-totonline\", p.GetTmplPhrasef(\"panel_dashboard_online\", totonline, totunit))\n\t\t\t\tpush(\"dash-gonline\", p.GetTmplPhrasef(\"panel_dashboard_guests_online\", gonline, gunit))\n\t\t\t\tpush(\"dash-uonline\", p.GetTmplPhrasef(\"panel_dashboard_users_online\", uonline, uunit))\n\t\t\t\tpush(\"dash-reqs\", strconv.Itoa(reqCount)+\" reqs / second\")\n\t\t\t\tpushc(\"dash-totonline\", \"grid_item grid_stat \"+onlineColour)\n\t\t\t\tpushc(\"dash-gonline\", \"grid_item grid_stat \"+onlineGuestsColour)\n\t\t\t\tpushc(\"dash-uonline\", \"grid_item grid_stat \"+onlineUsersColour)\n\t\t\t\t//pushc(\"dash-reqs\",\"grid_item grid_stat grid_end_group\")\n\t\t\t}\n\t\t\tpush(\"dash-cpu\", p.GetTmplPhrasef(\"panel_dashboard_cpu\", cpustr)+\"%\")\n\t\t\tpushc(\"dash-cpu\", \"grid_item grid_istat \"+cpuColour)\n\n\t\t\tif !noRAMUpdates {\n\t\t\t\tpush(\"dash-ram\", p.GetTmplPhrasef(\"panel_dashboard_ram\", ramstr))\n\t\t\t\tpushc(\"dash-ram\", \"grid_item grid_istat \"+ramColour)\n\t\t\t}\n\t\t\tw.Close()\n\t\t}\n\t\tadminStatsMutex.Unlock()\n\n\t\tlastUonline = uonline\n\t\tlastGonline = gonline\n\t\tlastTotonline = totonline\n\t\tlastCPUPerc = int(cpuPerc[0])\n\t\tlastAvailableRAM = int64(memres.Available)\n\t}\n}\n"
  },
  {
    "path": "common/widget.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype WidgetStmts struct {\n\t//getList *sql.Stmt\n\tgetDockList *sql.Stmt\n\tdelete      *sql.Stmt\n\tcreate      *sql.Stmt\n\tupdate      *sql.Stmt\n\n\t//qgen.SimpleModel\n}\n\nvar widgetStmts WidgetStmts\n\nfunc init() {\n\tDbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tw := \"widgets\"\n\t\twidgetStmts = WidgetStmts{\n\t\t\t//getList: acc.Select(w).Columns(\"wid,position,side,type,active,location,data\").Orderby(\"position ASC\").Prepare(),\n\t\t\tgetDockList: acc.Select(w).Columns(\"wid,position,type,active,location,data\").Where(\"side=?\").Orderby(\"position ASC\").Prepare(),\n\t\t\t//model: acc.SimpleModel(w,\"position,type,active,location,data\",\"wid\"),\n\t\t\tdelete: acc.Delete(w).Where(\"wid=?\").Prepare(),\n\t\t\tcreate: acc.Insert(w).Columns(\"position,side,type,active,location,data\").Fields(\"?,?,?,?,?,?\").Prepare(),\n\t\t\tupdate: acc.Update(w).Set(\"position=?,side=?,type=?,active=?,location=?,data=?\").Where(\"wid=?\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// TODO: Shrink this struct for common uses in the templates? Would that really make things go faster?\ntype Widget struct {\n\tID       int\n\tEnabled  bool\n\tLocation string // Coming Soon: overview, topics, topic / topic_view, forums, forum, global\n\tPosition int\n\tRawBody  string\n\tBody     string\n\tSide     string\n\tType     string\n\n\tLiteral      bool\n\tTickMask     atomic.Value\n\tInitFunc     func(w *Widget, sched *WidgetScheduler) error\n\tShutdownFunc func(w *Widget) error\n\tBuildFunc    func(w *Widget, hvars interface{}) (string, error)\n\tTickFunc     func(w *Widget) error\n}\n\nfunc (w *Widget) Delete() error {\n\t_, err := widgetStmts.delete.Exec(w.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Reload the dock\n\t// TODO: Better synchronisation\n\tWidgets.delete(w.ID)\n\twidgets, err := getDockWidgets(w.Side)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsetDock(w.Side, widgets)\n\treturn nil\n}\n\nfunc (w *Widget) Copy() (ow *Widget) {\n\tow = &Widget{}\n\t*ow = *w\n\treturn ow\n}\n\n// TODO: Test this\n// TODO: Add support for zone:id. Perhaps, carry a ZoneID property around in *Header? It might allow some weirdness like frontend[5] which matches any zone with an ID of 5 but it would be a tad faster than verifying each zone, although it might be problematic if users end up relying on this behaviour for areas which don't pass IDs to the widgets system but *probably* should\n// TODO: Add a selector which also matches topics inside a specific forum?\nfunc (w *Widget) Allowed(zone string, zoneid int) bool {\n\tfor _, loc := range strings.Split(w.Location, \"|\") {\n\t\tif len(loc) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsloc := strings.Split(\":\", loc)\n\t\tif len(sloc) > 1 {\n\t\t\tiloc, _ := strconv.Atoi(sloc[1])\n\t\t\tif zoneid != 0 && iloc != zoneid {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif loc == \"global\" || loc == zone {\n\t\t\treturn true\n\t\t} else if loc[0] == '!' {\n\t\t\tloc = loc[1:]\n\t\t\tif loc != \"global\" && loc != zone {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// TODO: Refactor\nfunc (w *Widget) Build(hvars interface{}) (string, error) {\n\tif w.Literal {\n\t\treturn w.Body, nil\n\t}\n\tif w.BuildFunc != nil {\n\t\treturn w.BuildFunc(w, hvars)\n\t}\n\theader := hvars.(*Header)\n\terr := header.Theme.RunTmpl(w.Body, hvars, header.Writer)\n\treturn \"\", err\n}\n\ntype WidgetEdit struct {\n\t*Widget\n\tData map[string]string\n}\n\nfunc (w *WidgetEdit) Create() (int, error) {\n\tdata, err := json.Marshal(w.Data)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tres, err := widgetStmts.create.Exec(w.Position, w.Side, w.Type, w.Enabled, w.Location, data)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\t// Reload the dock\n\twidgets, err := getDockWidgets(w.Side)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tsetDock(w.Side, widgets)\n\n\twid64, err := res.LastInsertId()\n\treturn int(wid64), err\n}\n\nfunc (w *WidgetEdit) Commit() error {\n\tdata, err := json.Marshal(w.Data)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = widgetStmts.update.Exec(w.Position, w.Side, w.Type, w.Enabled, w.Location, data, w.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Reload the dock\n\twidgets, err := getDockWidgets(w.Side)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsetDock(w.Side, widgets)\n\treturn nil\n}\n"
  },
  {
    "path": "common/widget_search_and_filter.go",
    "content": "package common\n\nimport \"errors\"\n\n// TODO: Move this into it's own package to make neater and tidier\ntype filterForum struct {\n\t*Forum\n\tSelected bool\n}\ntype searchAndFilter struct {\n\t*Header\n\tForums []filterForum\n}\n\nfunc widgetSearchAndFilter(widget *Widget, hvars interface{}) (out string, err error) {\n\theader := hvars.(*Header)\n\tu := header.CurrentUser\n\tvar forums []filterForum\n\tvar canSee []int\n\tif u.IsSuperAdmin {\n\t\tcanSee, err = Forums.GetAllVisibleIDs()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t} else {\n\t\tgroup, err := Groups.Get(u.Group)\n\t\tif err != nil {\n\t\t\t// TODO: Revisit this\n\t\t\treturn \"\", errors.New(\"Something weird happened\")\n\t\t}\n\t\tcanSee = group.CanSee\n\t}\n\n\tfor _, fid := range canSee {\n\t\tf := Forums.DirtyGet(fid)\n\t\tif f.ParentID == 0 && f.Name != \"\" && f.Active {\n\t\t\tforums = append(forums, filterForum{f, (header.Zone == \"view_forum\" || header.Zone == \"topics\") && header.ZoneID == f.ID})\n\t\t}\n\t}\n\n\tsaf := &searchAndFilter{header, forums}\n\terr = saf.Header.Theme.RunTmpl(\"widget_search_and_filter\", saf, saf.Header.Writer)\n\treturn \"\", err\n}\n"
  },
  {
    "path": "common/widget_store.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n)\n\nvar Widgets *DefaultWidgetStore\n\ntype DefaultWidgetStore struct {\n\twidgets map[int]*Widget\n\tsync.RWMutex\n}\n\nfunc NewDefaultWidgetStore() *DefaultWidgetStore {\n\treturn &DefaultWidgetStore{widgets: make(map[int]*Widget)}\n}\n\nfunc (s *DefaultWidgetStore) Get(id int) (*Widget, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tw, ok := s.widgets[id]\n\tif !ok {\n\t\treturn w, sql.ErrNoRows\n\t}\n\treturn w, nil\n}\n\nfunc (s *DefaultWidgetStore) set(w *Widget) {\n\ts.Lock()\n\tdefer s.Unlock()\n\ts.widgets[w.ID] = w\n}\n\nfunc (s *DefaultWidgetStore) delete(id int) {\n\ts.Lock()\n\tdefer s.Unlock()\n\tdelete(s.widgets, id)\n}\n"
  },
  {
    "path": "common/widget_wol.go",
    "content": "package common\n\nimport (\n\t\"bytes\"\n\t//\"log\"\n\t\"net/http/httptest\"\n\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tmin \"github.com/Azareal/Gosora/common/templates\"\n)\n\ntype wolUsers struct {\n\t*Header\n\tName      string\n\tUsers     []*User\n\tUserCount int\n}\n\nfunc wolInit(w *Widget, sched *WidgetScheduler) error {\n\tsched.Add(w)\n\treturn nil\n}\n\nfunc wolGetUsers() ([]*User, int) {\n\tucount := WsHub.UserCount()\n\t// We don't want a ridiculously long list, so we'll show the number if it's too high and only show staff individually\n\tvar users []*User\n\tif ucount < 30 {\n\t\tusers = WsHub.AllUsers()\n\t\tif len(users) >= 30 {\n\t\t\tusers = nil\n\t\t}\n\t}\n\treturn users, ucount\n}\n\nfunc wolBuild(w *Widget, hvars interface{}) (string, error) {\n\tusers, ucount := wolGetUsers()\n\twol := &wolUsers{hvars.(*Header), p.GetTmplPhrase(\"widget.online_name\"), users, ucount}\n\terr := wol.Header.Theme.RunTmpl(\"widget_online\", wol, wol.Header.Writer)\n\treturn \"\", err\n}\n\nfunc wolRender(w *Widget, hvars interface{}) (string, error) {\n\tiTickMask := w.TickMask.Load()\n\tif iTickMask != nil {\n\t\ttickMask := iTickMask.(*Widget)\n\t\tif tickMask != nil {\n\t\t\treturn tickMask.Body, nil\n\t\t}\n\t}\n\treturn wolBuild(w, hvars)\n}\n\nvar wolLastUsers []*User\n\nfunc wolTick(widget *Widget) error {\n\tw := httptest.NewRecorder()\n\tusers, ucount := wolGetUsers()\n\tinOld := func(id int) bool {\n\t\tfor _, user := range wolLastUsers {\n\t\t\tif id == user.ID {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\t// Avoid rebuilding the widget, if the users are exactly the same as on the last tick\n\tif len(users) == len(wolLastUsers) {\n\t\tdiff := false\n\t\tfor _, user := range users {\n\t\t\tif !inOld(user.ID) {\n\t\t\t\tdiff = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !diff {\n\t\t\tiTickMask := widget.TickMask.Load()\n\t\t\tif iTickMask != nil {\n\t\t\t\ttickMask := iTickMask.(*Widget)\n\t\t\t\tif tickMask != nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t//log.Printf(\"users: %+v\\n\", users)\n\t//log.Printf(\"wolLastUsers: %+v\\n\", wolLastUsers)\n\n\twol := &wolUsers{SimpleDefaultHeader(w), p.GetTmplPhrase(\"widget.online_name\"), users, ucount}\n\terr := wol.Header.Theme.RunTmpl(\"widget_online\", wol, wol.Header.Writer)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbuf := new(bytes.Buffer)\n\tbuf.ReadFrom(w.Result().Body)\n\tbs := buf.String()\n\tif Config.MinifyTemplates {\n\t\tbs = min.Minify(bs)\n\t}\n\n\ttwidget := &Widget{}\n\t*twidget = *widget\n\ttwidget.Body = bs\n\twidget.TickMask.Store(twidget)\n\twolLastUsers = users\n\n\thTbl := GetHookTable()\n\t_, _ = hTbl.VhookSkippable(\"tasks_tick_widget_wol\", widget, bs)\n\n\treturn nil\n}\n"
  },
  {
    "path": "common/widget_wol_context.go",
    "content": "package common\n\nimport \"github.com/Azareal/Gosora/common/phrases\"\n\nfunc wolContextRender(widget *Widget, hvars interface{}) (string, error) {\n\theader := hvars.(*Header)\n\tif header.Zone != \"view_topic\" {\n\t\treturn \"\", nil\n\t}\n\tvar ucount int\n\tvar users []*User\n\ttopicMutex.RLock()\n\ttopic, ok := topicWatchers[header.ZoneID]\n\tif ok {\n\t\tucount = len(topic)\n\t\tif ucount < 30 {\n\t\t\tusers = make([]*User, len(topic))\n\t\t\ti := 0\n\t\t\tfor wsUser, _ := range topic {\n\t\t\t\tusers[i] = wsUser.User\n\t\t\t\ti++\n\t\t\t}\n\t\t}\n\t}\n\ttopicMutex.RUnlock()\n\twol := &wolUsers{header, phrases.GetTmplPhrase(\"widget.online_view_topic_name\"), users, ucount}\n\te := header.Theme.RunTmpl(\"widget_online\", wol, header.Writer)\n\treturn \"\", e\n}\n"
  },
  {
    "path": "common/widgets.go",
    "content": "/* Copyright Azareal 2017 - 2020 */\npackage common\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\tmin \"github.com/Azareal/Gosora/common/templates\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/pkg/errors\"\n)\n\n// TODO: Clean this file up\nvar Docks WidgetDocks\nvar widgetUpdateMutex sync.RWMutex\n\ntype WidgetDock struct {\n\tItems     []*Widget\n\tScheduler *WidgetScheduler\n}\n\ntype WidgetDocks struct {\n\tLeftOfNav    []*Widget\n\tRightOfNav   []*Widget\n\tLeftSidebar  WidgetDock\n\tRightSidebar WidgetDock\n\t//PanelLeft []Menus\n\tFooter WidgetDock\n}\n\ntype WidgetMenu struct {\n\tName     string\n\tMenuList []WidgetMenuItem\n}\n\ntype WidgetMenuItem struct {\n\tText     string\n\tLocation string\n\tCompact  bool\n}\n\ntype NameTextPair struct {\n\tName string\n\tText template.HTML\n}\n\nfunc preparseWidget(w *Widget, wdata string) (e error) {\n\tprebuildWidget := func(name string, data interface{}) (string, error) {\n\t\tvar b bytes.Buffer\n\t\te := DefaultTemplates.ExecuteTemplate(&b, name+\".html\", data)\n\t\tcontent := b.String()\n\t\tif Config.MinifyTemplates {\n\t\t\tcontent = min.Minify(content)\n\t\t}\n\t\treturn content, e\n\t}\n\n\tsbytes := []byte(wdata)\n\tw.Literal = true\n\t// TODO: Split these hard-coded items out of this file and into the files for the individual widget types\n\tswitch w.Type {\n\tcase \"simple\", \"about\":\n\t\tvar tmp NameTextPair\n\t\te = json.Unmarshal(sbytes, &tmp)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tw.Body, e = prebuildWidget(\"widget_\"+w.Type, tmp)\n\tcase \"search_and_filter\":\n\t\tw.Literal = false\n\t\tw.BuildFunc = widgetSearchAndFilter\n\tcase \"wol\":\n\t\tw.Literal = false\n\t\tw.InitFunc = wolInit\n\t\tw.BuildFunc = wolRender\n\t\tw.TickFunc = wolTick\n\tcase \"wol_context\":\n\t\tw.Literal = false\n\t\tw.BuildFunc = wolContextRender\n\tdefault:\n\t\tw.Body = wdata\n\t}\n\n\t// TODO: Test this\n\t// TODO: Should we toss this through a proper parser rather than crudely replacing it?\n\trep := func(from, to string) {\n\t\tw.Location = strings.Replace(w.Location, from, to, -1)\n\t}\n\trep(\" \", \"\")\n\trep(\"frontend\", \"!panel\")\n\trep(\"!!\", \"\")\n\n\t// Skip blank zones\n\tlocs := strings.Split(w.Location, \"|\")\n\tif len(locs) > 0 {\n\t\tw.Location = \"\"\n\t\tfor _, loc := range locs {\n\t\t\tif loc == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tw.Location += loc + \"|\"\n\t\t}\n\t\tw.Location = w.Location[:len(w.Location)-1]\n\t}\n\n\treturn e\n}\n\nfunc GetDockList() []string {\n\treturn []string{\n\t\t\"leftOfNav\",\n\t\t\"rightOfNav\",\n\t\t\"rightSidebar\",\n\t\t\"footer\",\n\t}\n}\n\nfunc GetDock(dock string) []*Widget {\n\tswitch dock {\n\tcase \"leftOfNav\":\n\t\treturn Docks.LeftOfNav\n\tcase \"rightOfNav\":\n\t\treturn Docks.RightOfNav\n\tcase \"rightSidebar\":\n\t\treturn Docks.RightSidebar.Items\n\tcase \"footer\":\n\t\treturn Docks.Footer.Items\n\t}\n\treturn nil\n}\n\nfunc HasDock(dock string) bool {\n\tswitch dock {\n\tcase \"leftOfNav\", \"rightOfNav\", \"rightSidebar\", \"footer\":\n\t\treturn true\n\t}\n\treturn false\n}\n\n// TODO: Find a more optimimal way of doing this...\nfunc HasWidgets(dock string, h *Header) bool {\n\tif !h.Theme.HasDock(dock) {\n\t\treturn false\n\t}\n\n\t// Let themes forcibly override this slot\n\tsbody := h.Theme.BuildDock(dock)\n\tif sbody != \"\" {\n\t\treturn true\n\t}\n\n\tvar widgets []*Widget\n\tswitch dock {\n\tcase \"leftOfNav\":\n\t\twidgets = Docks.LeftOfNav\n\tcase \"rightOfNav\":\n\t\twidgets = Docks.RightOfNav\n\tcase \"rightSidebar\":\n\t\twidgets = Docks.RightSidebar.Items\n\tcase \"footer\":\n\t\twidgets = Docks.Footer.Items\n\t}\n\n\twcount := 0\n\tfor _, widget := range widgets {\n\t\tif !widget.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tif widget.Allowed(h.Zone, h.ZoneID) {\n\t\t\twcount++\n\t\t}\n\t}\n\treturn wcount > 0\n}\n\nfunc BuildWidget(dock string, h *Header) (sbody string) {\n\tif !h.Theme.HasDock(dock) {\n\t\treturn \"\"\n\t}\n\t// Let themes forcibly override this slot\n\tsbody = h.Theme.BuildDock(dock)\n\tif sbody != \"\" {\n\t\treturn sbody\n\t}\n\n\tvar widgets []*Widget\n\tswitch dock {\n\tcase \"leftOfNav\":\n\t\twidgets = Docks.LeftOfNav\n\tcase \"rightOfNav\":\n\t\twidgets = Docks.RightOfNav\n\tcase \"topMenu\":\n\t\t// 1 = id for the default menu\n\t\tmhold, e := Menus.Get(1)\n\t\tif e == nil {\n\t\t\te := mhold.Build(h.Writer, h.CurrentUser, h.Path)\n\t\t\tif e != nil {\n\t\t\t\tLogError(e)\n\t\t\t}\n\t\t}\n\t\treturn \"\"\n\tcase \"rightSidebar\":\n\t\twidgets = Docks.RightSidebar.Items\n\tcase \"footer\":\n\t\twidgets = Docks.Footer.Items\n\t}\n\n\tfor _, widget := range widgets {\n\t\tif !widget.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tif widget.Allowed(h.Zone, h.ZoneID) {\n\t\t\titem, e := widget.Build(h)\n\t\t\tif e != nil {\n\t\t\t\tLogError(e)\n\t\t\t}\n\t\t\tsbody += item\n\t\t}\n\t}\n\treturn sbody\n}\n\nvar DockToID = map[string]int{\n\t\"leftOfNav\":    0,\n\t\"rightOfNav\":   1,\n\t\"topMenu\":      2,\n\t\"rightSidebar\": 3,\n\t\"footer\":       4,\n}\n\nfunc BuildWidget2(dock int, h *Header) (sbody string) {\n\tif !h.Theme.HasDockByID(dock) {\n\t\treturn \"\"\n\t}\n\t// Let themes forcibly override this slot\n\tsbody = h.Theme.BuildDockByID(dock)\n\tif sbody != \"\" {\n\t\treturn sbody\n\t}\n\n\tvar widgets []*Widget\n\tswitch dock {\n\tcase 0:\n\t\twidgets = Docks.LeftOfNav\n\tcase 1:\n\t\twidgets = Docks.RightOfNav\n\tcase 2:\n\t\t// 1 = id for the default menu\n\t\tmhold, e := Menus.Get(1)\n\t\tif e == nil {\n\t\t\te := mhold.Build(h.Writer, h.CurrentUser, h.Path)\n\t\t\tif e != nil {\n\t\t\t\tLogError(e)\n\t\t\t}\n\t\t}\n\t\treturn \"\"\n\tcase 3:\n\t\twidgets = Docks.RightSidebar.Items\n\tcase 4:\n\t\twidgets = Docks.Footer.Items\n\t}\n\n\tfor _, w := range widgets {\n\t\tif !w.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tif w.Allowed(h.Zone, h.ZoneID) {\n\t\t\titem, e := w.Build(h)\n\t\t\tif e != nil {\n\t\t\t\tLogError(e)\n\t\t\t}\n\t\t\tsbody += item\n\t\t}\n\t}\n\treturn sbody\n}\n\nfunc BuildWidget3(dock int, h *Header) {\n\tif !h.Theme.HasDockByID(dock) {\n\t\treturn\n\t}\n\t// Let themes forcibly override this slot\n\tif sbody := h.Theme.BuildDockByID(dock); sbody != \"\" {\n\t\th.Writer.Write(uutils.StringToBytes(sbody))\n\t\treturn\n\t}\n\n\tvar widgets []*Widget\n\tswitch dock {\n\tcase 0:\n\t\twidgets = Docks.LeftOfNav\n\tcase 1:\n\t\twidgets = Docks.RightOfNav\n\tcase 2:\n\t\t// 1 = id for the default menu\n\t\tmhold, err := Menus.Get(1)\n\t\tif err == nil {\n\t\t\terr := mhold.Build(h.Writer, h.CurrentUser, h.Path)\n\t\t\tif err != nil {\n\t\t\t\tLogError(err)\n\t\t\t}\n\t\t}\n\t\treturn\n\tcase 3:\n\t\twidgets = Docks.RightSidebar.Items\n\tcase 4:\n\t\twidgets = Docks.Footer.Items\n\t}\n\n\tfor _, w := range widgets {\n\t\tif !w.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tif w.Allowed(h.Zone, h.ZoneID) {\n\t\t\titem, e := w.Build(h)\n\t\t\tif e != nil {\n\t\t\t\tLogError(e)\n\t\t\t}\n\t\t\tif item != \"\" {\n\t\t\t\th.Writer.Write(uutils.StringToBytes(item))\n\t\t\t}\n\t\t}\n\t}\n}\n\n// TODO: Find a more optimimal way of doing this...\nfunc HasWidgets2(dock int, h *Header) bool {\n\tif !h.Theme.HasDockByID(dock) {\n\t\treturn false\n\t}\n\n\t// Let themes forcibly override this slot\n\t// TODO: Optimise this bit\n\tsbody := h.Theme.BuildDockByID(dock)\n\tif sbody != \"\" {\n\t\treturn true\n\t}\n\n\tvar widgets []*Widget\n\tswitch dock {\n\tcase 0:\n\t\twidgets = Docks.LeftOfNav\n\tcase 1:\n\t\twidgets = Docks.RightOfNav\n\tcase 3:\n\t\twidgets = Docks.RightSidebar.Items\n\tcase 4:\n\t\twidgets = Docks.Footer.Items\n\t}\n\n\twcount := 0\n\tfor _, widget := range widgets {\n\t\tif !widget.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tif widget.Allowed(h.Zone, h.ZoneID) {\n\t\t\twcount++\n\t\t}\n\t}\n\treturn wcount > 0\n}\n\nfunc getDockWidgets(dock string) (widgets []*Widget, e error) {\n\trows, e := widgetStmts.getDockList.Query(dock)\n\tif e != nil {\n\t\treturn nil, e\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tw := &Widget{Position: 0, Side: dock}\n\t\te = rows.Scan(&w.ID, &w.Position, &w.Type, &w.Enabled, &w.Location, &w.RawBody)\n\t\tif e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\te = preparseWidget(w, w.RawBody)\n\t\tif e != nil {\n\t\t\treturn nil, e\n\t\t}\n\t\tWidgets.set(w)\n\t\twidgets = append(widgets, w)\n\t}\n\treturn widgets, rows.Err()\n}\n\n// TODO: Make a store for this?\nfunc InitWidgets() (fi error) {\n\t// TODO: Let themes set default values for widget docks, and let them lock in particular places with their stuff, e.g. leftOfNav and rightOfNav\n\tf := func(name string) {\n\t\tif fi != nil {\n\t\t\treturn\n\t\t}\n\t\tdock, e := getDockWidgets(name)\n\t\tif e != nil {\n\t\t\tfi = e\n\t\t\treturn\n\t\t}\n\t\tsetDock(name, dock)\n\t}\n\n\tf(\"leftOfNav\")\n\tf(\"rightOfNav\")\n\tf(\"leftSidebar\")\n\tf(\"rightSidebar\")\n\tf(\"footer\")\n\tif fi != nil {\n\t\treturn fi\n\t}\n\n\tTasks.Sec.Add(Docks.LeftSidebar.Scheduler.Tick)\n\tTasks.Sec.Add(Docks.RightSidebar.Scheduler.Tick)\n\tTasks.Sec.Add(Docks.Footer.Scheduler.Tick)\n\n\treturn nil\n}\n\nfunc releaseWidgets(ws []*Widget) {\n\tfor _, w := range ws {\n\t\tif w.ShutdownFunc != nil {\n\t\t\tw.ShutdownFunc(w)\n\t\t}\n\t}\n}\n\n// TODO: Use atomics\nfunc setDock(dock string, widgets []*Widget) {\n\tdockHandle := func(dockWidgets []*Widget) {\n\t\tDebugLog(dock, widgets)\n\t\treleaseWidgets(dockWidgets)\n\t}\n\tdockHandle2 := func(dockWidgets WidgetDock) WidgetDock {\n\t\tdockHandle(dockWidgets.Items)\n\t\tif dockWidgets.Scheduler == nil {\n\t\t\tdockWidgets.Scheduler = &WidgetScheduler{}\n\t\t}\n\t\tfor _, widget := range widgets {\n\t\t\tif widget.InitFunc != nil {\n\t\t\t\twidget.InitFunc(widget, dockWidgets.Scheduler)\n\t\t\t}\n\t\t}\n\t\tdockWidgets.Scheduler.Store()\n\t\treturn WidgetDock{widgets, dockWidgets.Scheduler}\n\t}\n\twidgetUpdateMutex.Lock()\n\tdefer widgetUpdateMutex.Unlock()\n\tswitch dock {\n\tcase \"leftOfNav\":\n\t\tdockHandle(Docks.LeftOfNav)\n\t\tDocks.LeftOfNav = widgets\n\tcase \"rightOfNav\":\n\t\tdockHandle(Docks.RightOfNav)\n\t\tDocks.RightOfNav = widgets\n\tcase \"leftSidebar\":\n\t\tDocks.LeftSidebar = dockHandle2(Docks.LeftSidebar)\n\tcase \"rightSidebar\":\n\t\tDocks.RightSidebar = dockHandle2(Docks.RightSidebar)\n\tcase \"footer\":\n\t\tDocks.Footer = dockHandle2(Docks.Footer)\n\tdefault:\n\t\tfmt.Printf(\"bad dock '%s'\\n\", dock)\n\t\treturn\n\t}\n}\n\ntype WidgetScheduler struct {\n\twidgets []*Widget\n\tstore   atomic.Value\n}\n\nfunc (s *WidgetScheduler) Add(w *Widget) {\n\ts.widgets = append(s.widgets, w)\n}\n\nfunc (s *WidgetScheduler) Store() {\n\ts.store.Store(s.widgets)\n}\n\nfunc (s *WidgetScheduler) Tick() error {\n\twidgets := s.store.Load().([]*Widget)\n\tfor _, widget := range widgets {\n\t\tif widget.TickFunc == nil {\n\t\t\tcontinue\n\t\t}\n\t\te := widget.TickFunc(widget)\n\t\tif e != nil {\n\t\t\treturn errors.WithStack(e)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/word_filters.go",
    "content": "package common\n\nimport (\n\t\"database/sql\"\n\t\"sync/atomic\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// TODO: Move some features into methods on this?\ntype WordFilter struct {\n\tID      int\n\tFind    string\n\tReplace string\n}\ntype WordFilterDiff struct {\n\tBeforeFind    string\n\tBeforeReplace string\n\tAfterFind     string\n\tAfterReplace  string\n}\n\nvar WordFilters WordFilterStore\n\ntype WordFilterStore interface {\n\tReloadAll() error\n\tGetAll() (filters map[int]*WordFilter, err error)\n\tGet(id int) (*WordFilter, error)\n\tCreate(find, replace string) (int, error)\n\tDelete(id int) error\n\tUpdate(id int, find, replace string) error\n\tLength() int\n\tEstCount() int\n\tCount() (count int)\n}\n\ntype DefaultWordFilterStore struct {\n\tbox atomic.Value // An atomic value holding a WordFilterMap\n\n\tgetAll *sql.Stmt\n\tget    *sql.Stmt\n\tcreate *sql.Stmt\n\tdelete *sql.Stmt\n\tupdate *sql.Stmt\n\tcount  *sql.Stmt\n}\n\nfunc NewDefaultWordFilterStore(acc *qgen.Accumulator) (*DefaultWordFilterStore, error) {\n\twf := \"word_filters\"\n\tstore := &DefaultWordFilterStore{\n\t\tgetAll: acc.Select(wf).Columns(\"wfid,find,replacement\").Prepare(),\n\t\tget:    acc.Select(wf).Columns(\"find,replacement\").Where(\"wfid=?\").Prepare(),\n\t\tcreate: acc.Insert(wf).Columns(\"find,replacement\").Fields(\"?,?\").Prepare(),\n\t\tdelete: acc.Delete(wf).Where(\"wfid=?\").Prepare(),\n\t\tupdate: acc.Update(wf).Set(\"find=?,replacement=?\").Where(\"wfid=?\").Prepare(),\n\t\tcount:  acc.Count(wf).Prepare(),\n\t}\n\t// TODO: Should we initialise this elsewhere?\n\tif acc.FirstError() == nil {\n\t\tacc.RecordError(store.ReloadAll())\n\t}\n\treturn store, acc.FirstError()\n}\n\n// ReloadAll drops all the items in the memory cache and replaces them with fresh copies from the database\nfunc (s *DefaultWordFilterStore) ReloadAll() error {\n\twordFilters := make(map[int]*WordFilter)\n\tfilters, err := s.bypassGetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, filter := range filters {\n\t\twordFilters[filter.ID] = filter\n\t}\n\n\ts.box.Store(wordFilters)\n\treturn nil\n}\n\n// ? - Return pointers to word filters intead to save memory? -- A map is a pointer.\nfunc (s *DefaultWordFilterStore) bypassGetAll() (filters []*WordFilter, err error) {\n\trows, err := s.getAll.Query()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\tf := &WordFilter{ID: 0}\n\t\terr := rows.Scan(&f.ID, &f.Find, &f.Replace)\n\t\tif err != nil {\n\t\t\treturn filters, err\n\t\t}\n\t\tfilters = append(filters, f)\n\t}\n\treturn filters, rows.Err()\n}\n\n// GetAll returns all of the word filters in a map. Do note mutate this map (or maps returned from any store not explicitly noted as copies) as multiple threads may be accessing it at once\nfunc (s *DefaultWordFilterStore) GetAll() (filters map[int]*WordFilter, err error) {\n\treturn s.box.Load().(map[int]*WordFilter), nil\n}\n\nfunc (s *DefaultWordFilterStore) Get(id int) (*WordFilter, error) {\n\twf := &WordFilter{ID: id}\n\terr := s.get.QueryRow(id).Scan(&wf.Find, &wf.Replace)\n\treturn wf, err\n}\n\n// Create adds a new word filter to the database and refreshes the memory cache\nfunc (s *DefaultWordFilterStore) Create(find, replace string) (int, error) {\n\tres, err := s.create.Exec(find, replace)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tid64, err := res.LastInsertId()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn int(id64), s.ReloadAll()\n}\n\n// Delete removes a word filter from the database and refreshes the memory cache\nfunc (s *DefaultWordFilterStore) Delete(id int) error {\n\t_, err := s.delete.Exec(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.ReloadAll()\n}\n\nfunc (s *DefaultWordFilterStore) Update(id int, find, replace string) error {\n\t_, err := s.update.Exec(find, replace, id)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.ReloadAll()\n}\n\n// Length gets the number of word filters currently in memory, for the DefaultWordFilterStore, this should be all of them\nfunc (s *DefaultWordFilterStore) Length() int {\n\treturn len(s.box.Load().(map[int]*WordFilter))\n}\n\n// EstCount provides the same result as Length(), intended for alternate implementations of WordFilterStore, so that Length is the number of items in cache, if only a subset is held there and EstCount is the total count\nfunc (s *DefaultWordFilterStore) EstCount() int {\n\treturn len(s.box.Load().(map[int]*WordFilter))\n}\n\n// Count gets the total number of word filters directly from the database\nfunc (s *DefaultWordFilterStore) Count() (count int) {\n\terr := s.count.QueryRow().Scan(&count)\n\tif err != nil {\n\t\tLogError(err)\n\t}\n\treturn count\n}\n"
  },
  {
    "path": "common/ws_hub.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\n// TODO: Rename this to WebSockets?\nvar WsHub WsHubImpl\n\n// TODO: Make this an interface?\n// TODO: Write tests for this\ntype WsHubImpl struct {\n\t// TODO: Implement some form of generics so we don't write as much odd-even sharding code\n\tevenOnlineUsers map[int]*WSUser\n\toddOnlineUsers  map[int]*WSUser\n\tevenUserLock    sync.RWMutex\n\toddUserLock     sync.RWMutex\n\n\t// TODO: Add sharding for this too?\n\tOnlineGuests map[*WSUser]bool\n\tGuestLock    sync.RWMutex\n\n\tlastTick      time.Time\n\tlastTopicList []*TopicsRow\n}\n\nfunc init() {\n\t// TODO: Do we really want to initialise this here instead of in main.go / general_test.go like the other things?\n\tWsHub = WsHubImpl{\n\t\tevenOnlineUsers: make(map[int]*WSUser),\n\t\toddOnlineUsers:  make(map[int]*WSUser),\n\t\tOnlineGuests:    make(map[*WSUser]bool),\n\t}\n}\n\nfunc (h *WsHubImpl) Start() {\n\tlog.Print(\"Setting up the WebSocket ticks\")\n\tticker := time.NewTicker(time.Minute * 5)\n\tdefer func() {\n\t\tticker.Stop()\n\t}()\n\n\tgo func() {\n\t\tdefer EatPanics()\n\t\tfor {\n\t\t\titem := func(l *sync.RWMutex, userMap map[int]*WSUser) {\n\t\t\t\tl.RLock()\n\t\t\t\tdefer l.RUnlock()\n\t\t\t\t// TODO: Copy to temporary slice for less contention?\n\t\t\t\tfor _, u := range userMap {\n\t\t\t\t\tu.Ping()\n\t\t\t\t}\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase <-ticker.C:\n\t\t\t\titem(&h.evenUserLock, h.evenOnlineUsers)\n\t\t\t\titem(&h.oddUserLock, h.oddOnlineUsers)\n\t\t\t}\n\t\t}\n\t}()\n\tif Config.DisableLiveTopicList {\n\t\treturn\n\t}\n\th.lastTick = time.Now()\n\tTasks.Sec.Add(h.Tick)\n}\n\n// This Tick is separate from the admin one, as we want to process that in parallel with this due to the blocking calls to gopsutil\nfunc (h *WsHubImpl) Tick() error {\n\treturn wsTopicListTick(h)\n}\n\nfunc wsTopicListTick(h *WsHubImpl) error {\n\t// Avoid hitting GetList when the topic list hasn't changed\n\tif !TopicListThaw.Thawed() && h.lastTopicList != nil {\n\t\treturn nil\n\t}\n\ttickStart := time.Now()\n\n\t// Don't waste CPU time if nothing has happened\n\t// TODO: Get a topic list method which strips stickies?\n\ttList, _, _, err := TopicList.GetList(1, 0, nil)\n\tif err != nil {\n\t\th.lastTick = tickStart\n\t\treturn err // TODO: Do we get ErrNoRows here?\n\t}\n\tdefer func() {\n\t\th.lastTick = tickStart\n\t\th.lastTopicList = tList\n\t}()\n\tif len(tList) == 0 {\n\t\treturn nil\n\t}\n\n\t// TODO: Optimise this by only sniffing the top non-sticky\n\t// TODO: Optimise this by getting back an unsorted list so we don't have to hop around the stickies\n\t// TODO: Add support for new stickies / replies to them\n\tif len(tList) == len(h.lastTopicList) {\n\t\thasItem := false\n\t\tfor j, tItem := range tList {\n\t\t\tif !tItem.Sticky {\n\t\t\t\tif tItem.ID != h.lastTopicList[j].ID || !tItem.LastReplyAt.Equal(h.lastTopicList[j].LastReplyAt) {\n\t\t\t\t\thasItem = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !hasItem {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\t// TODO: Implement this for guests too? Should be able to optimise it far better there due to them sharing the same permission set\n\t// TODO: Be less aggressive with the locking, maybe use an array of sorts instead of hitting the main map every-time\n\ttopicListMutex.RLock()\n\tif len(topicListWatchers) == 0 {\n\t\ttopicListMutex.RUnlock()\n\t\treturn nil\n\t}\n\n\t// Copy these over so we close this loop as fast as possible so we can release the read lock, especially if the group gets are backed by calls to the database\n\tgroupIDs := make(map[int]bool)\n\tcurrentWatchers := make([]*WSUser, len(topicListWatchers))\n\ti := 0\n\tfor wsUser, _ := range topicListWatchers {\n\t\tcurrentWatchers[i] = wsUser\n\t\tgroupIDs[wsUser.User.Group] = true\n\t\ti++\n\t}\n\ttopicListMutex.RUnlock()\n\n\tgroups := make(map[int]*Group)\n\tcanSeeMap := make(map[string][]int)\n\tfor gid, _ := range groupIDs {\n\t\tg, err := Groups.Get(gid)\n\t\tif err != nil {\n\t\t\t// TODO: Do we really want to halt all pushes for what is possibly just one user?\n\t\t\treturn err\n\t\t}\n\t\tgroups[g.ID] = g\n\n\t\tcanSee := make([]byte, len(g.CanSee))\n\t\tfor i, item := range g.CanSee {\n\t\t\tcanSee[i] = byte(item)\n\t\t}\n\t\tcanSeeMap[string(canSee)] = g.CanSee\n\t}\n\n\tcanSeeRenders := make(map[string][]byte)\n\tcanSeeLists := make(map[string][]*WsTopicsRow)\n\tfor name, canSee := range canSeeMap {\n\t\ttopicList, forumList, _, err := TopicList.GetListByCanSee(canSee, 1, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err // TODO: Do we get ErrNoRows here?\n\t\t}\n\t\tif len(topicList) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t_ = forumList // Might use this later after we get the base feature working\n\n\t\tif topicList[0].Sticky {\n\t\t\tlastSticky := 0\n\t\t\tfor i, row := range topicList {\n\t\t\t\tif !row.Sticky {\n\t\t\t\t\tlastSticky = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif lastSticky == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttopicList = topicList[lastSticky:]\n\t\t}\n\n\t\t// TODO: Compare to previous tick to eliminate unnecessary work and data\n\t\twsTopicList := make([]*WsTopicsRow, len(topicList))\n\t\tfor i, topicRow := range topicList {\n\t\t\twsTopicList[i] = topicRow.WebSockets()\n\t\t}\n\t\tcanSeeLists[name] = wsTopicList\n\n\t\toutBytes, err := json.Marshal(&WsTopicList{wsTopicList, 0, tickStart.Unix()})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tcanSeeRenders[name] = outBytes\n\t}\n\n\t// TODO: Use MessagePack for additional speed?\n\t//fmt.Println(\"writing to the clients\")\n\tfor _, wsUser := range currentWatchers {\n\t\tu := wsUser.User\n\t\tgroup := groups[u.Group]\n\t\tcanSee := make([]byte, len(group.CanSee))\n\t\tfor i, item := range group.CanSee {\n\t\t\tcanSee[i] = byte(item)\n\t\t}\n\t\tsCanSee := string(canSee)\n\t\tl := canSeeLists[sCanSee]\n\n\t\t// TODO: Optimise this away for guests?\n\t\tanyMod, anyLock, anyMove, allMod := false, false, false, true\n\t\tvar modSet map[int]int\n\t\tif u.IsSuperAdmin {\n\t\t\tanyMod = true\n\t\t\tanyLock = true\n\t\t\tanyMove = true\n\t\t} else {\n\t\t\tmodSet = make(map[int]int, len(l))\n\t\t\tfor i, t := range l {\n\t\t\t\t// TODO: Abstract this?\n\t\t\t\tfp, e := FPStore.Get(t.ParentID, u.Group)\n\t\t\t\tif e == ErrNoRows {\n\t\t\t\t\tfp = BlankForumPerms()\n\t\t\t\t} else if e != nil {\n\t\t\t\t\treturn e\n\t\t\t\t}\n\t\t\t\tvar ccanMod, ccanLock, ccanMove bool\n\t\t\t\tif fp.Overrides {\n\t\t\t\t\tccanLock = fp.CloseTopic\n\t\t\t\t\tccanMove = fp.MoveTopic\n\t\t\t\t\tccanMod = t.CreatedBy == u.ID || fp.DeleteTopic || ccanLock || ccanMove\n\t\t\t\t} else {\n\t\t\t\t\tccanLock = u.Perms.CloseTopic\n\t\t\t\t\tccanMove = u.Perms.MoveTopic\n\t\t\t\t\tccanMod = t.CreatedBy == u.ID || u.Perms.DeleteTopic || ccanLock || ccanMove\n\t\t\t\t}\n\t\t\t\tif ccanLock {\n\t\t\t\t\tanyLock = true\n\t\t\t\t}\n\t\t\t\tif ccanMove {\n\t\t\t\t\tanyMove = true\n\t\t\t\t}\n\t\t\t\tif ccanMod {\n\t\t\t\t\tanyMod = true\n\t\t\t\t} else {\n\t\t\t\t\tallMod = false\n\t\t\t\t}\n\t\t\t\tvar v int\n\t\t\t\tif ccanMod {\n\t\t\t\t\tv = 1\n\t\t\t\t}\n\t\t\t\tmodSet[i] = v\n\t\t\t}\n\t\t}\n\n\t\t//fmt.Println(\"writing to user #\", wsUser.User.ID)\n\t\toutBytes := canSeeRenders[sCanSee]\n\t\t//fmt.Println(\"outBytes: \", string(outBytes))\n\t\t//fmt.Println(\"outBytes[:len(outBytes)-1]: \", string(outBytes[:len(outBytes)-1]))\n\t\t//e := wsUser.WriteToPageBytes(outBytes, \"/topics/\")\n\t\t//e := wsUser.WriteToPageBytesMulti([][]byte{outBytes[:len(outBytes)-1], []byte(`,\"mod\":1}`)}, \"/topics/\")\n\t\tvar e error\n\t\tif !anyMod {\n\t\t\te = wsUser.WriteToPageBytes(outBytes, \"/topics/\")\n\t\t} else {\n\t\t\tvar lm []byte\n\t\t\tif anyLock && anyMove {\n\t\t\t\tlm = []byte(`,\"lock\":1,\"move\":1}`)\n\t\t\t} else if anyLock {\n\t\t\t\tlm = []byte(`,\"lock\":1}`)\n\t\t\t} else if anyMove {\n\t\t\t\tlm = []byte(`,\"move\":1}`)\n\t\t\t} else {\n\t\t\t\tlm = []byte(\"}\")\n\t\t\t}\n\t\t\tif allMod {\n\t\t\t\te = wsUser.WriteToPageBytesMulti([][]byte{outBytes[:len(outBytes)-1], []byte(`,\"mod\":1`), lm}, \"/topics/\")\n\t\t\t} else {\n\t\t\t\t// TODO: Temporary and inefficient\n\t\t\t\tmBytes, err := json.Marshal(modSet)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\te = wsUser.WriteToPageBytesMulti([][]byte{outBytes[:len(outBytes)-1], []byte(`,\"mod\":`), mBytes, lm}, \"/topics/\")\n\t\t\t}\n\t\t}\n\n\t\tif e == ErrNoneOnPage {\n\t\t\t//fmt.Printf(\"werr for #%d: %s\\n\", wsUser.User.ID, err)\n\t\t\twsUser.FinalizePage(\"/topics/\", func() {\n\t\t\t\ttopicListMutex.Lock()\n\t\t\t\tdelete(topicListWatchers, wsUser)\n\t\t\t\ttopicListMutex.Unlock()\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *WsHubImpl) GuestCount() int {\n\th.GuestLock.RLock()\n\tdefer h.GuestLock.RUnlock()\n\treturn len(h.OnlineGuests)\n}\n\nfunc (h *WsHubImpl) UserCount() (count int) {\n\th.evenUserLock.RLock()\n\tcount += len(h.evenOnlineUsers)\n\th.evenUserLock.RUnlock()\n\n\th.oddUserLock.RLock()\n\tcount += len(h.oddOnlineUsers)\n\th.oddUserLock.RUnlock()\n\treturn count\n}\n\nfunc (h *WsHubImpl) HasUser(uid int) (exists bool) {\n\th.evenUserLock.RLock()\n\t_, exists = h.evenOnlineUsers[uid]\n\th.evenUserLock.RUnlock()\n\tif exists {\n\t\treturn exists\n\t}\n\n\th.oddUserLock.RLock()\n\t_, exists = h.oddOnlineUsers[uid]\n\th.oddUserLock.RUnlock()\n\treturn exists\n}\n\nfunc (h *WsHubImpl) broadcastMessage(msg string) error {\n\tuserLoop := func(users map[int]*WSUser, m *sync.RWMutex) error {\n\t\tm.RLock()\n\t\tdefer m.RUnlock()\n\t\tfor _, wsUser := range users {\n\t\t\te := wsUser.WriteAll(msg)\n\t\t\tif e != nil {\n\t\t\t\treturn e\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\t// TODO: Can we move this RLock inside the closure safely?\n\te := userLoop(h.evenOnlineUsers, &h.evenUserLock)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn userLoop(h.oddOnlineUsers, &h.oddUserLock)\n}\n\nfunc (h *WsHubImpl) getUser(uid int) (wsUser *WSUser, err error) {\n\tvar ok bool\n\tif uid%2 == 0 {\n\t\th.evenUserLock.RLock()\n\t\twsUser, ok = h.evenOnlineUsers[uid]\n\t\th.evenUserLock.RUnlock()\n\t} else {\n\t\th.oddUserLock.RLock()\n\t\twsUser, ok = h.oddOnlineUsers[uid]\n\t\th.oddUserLock.RUnlock()\n\t}\n\tif !ok {\n\t\treturn nil, errWsNouser\n\t}\n\treturn wsUser, nil\n}\n\n// Warning: For efficiency, some of the *WSUsers may be nil pointers, DO NOT EXPORT\n// TODO: Write tests for this\nfunc (h *WsHubImpl) getUsers(uids []int) (wsUsers []*WSUser, err error) {\n\tif len(uids) == 0 {\n\t\treturn nil, errWsNouser\n\t}\n\t//wsUsers = make([]*WSUser, len(uids))\n\t//i := 0\n\tappender := func(l *sync.RWMutex, users map[int]*WSUser) {\n\t\tl.RLock()\n\t\tdefer l.RUnlock()\n\t\t// We don't want to keep a lock on this for too long, so we'll accept some nil pointers\n\t\tfor _, uid := range uids {\n\t\t\t//wsUsers[i] = users[uid]\n\t\t\twsUsers = append(wsUsers, users[uid])\n\t\t\t//i++\n\t\t}\n\t}\n\tappender(&h.evenUserLock, h.evenOnlineUsers)\n\tappender(&h.oddUserLock, h.oddOnlineUsers)\n\tif len(wsUsers) == 0 {\n\t\treturn nil, errWsNouser\n\t}\n\treturn wsUsers, nil\n}\n\n// For Widget WOL, please avoid using this as it might wind up being really long and slow without the right safeguards\nfunc (h *WsHubImpl) AllUsers() (users []*User) {\n\tappender := func(l *sync.RWMutex, userMap map[int]*WSUser) {\n\t\tl.RLock()\n\t\tdefer l.RUnlock()\n\t\tfor _, u := range userMap {\n\t\t\tusers = append(users, u.User)\n\t\t}\n\t}\n\tappender(&h.evenUserLock, h.evenOnlineUsers)\n\tappender(&h.oddUserLock, h.oddOnlineUsers)\n\treturn users\n}\n\nfunc (h *WsHubImpl) removeUser(uid int) {\n\tif uid%2 == 0 {\n\t\th.evenUserLock.Lock()\n\t\tdelete(h.evenOnlineUsers, uid)\n\t\th.evenUserLock.Unlock()\n\t} else {\n\t\th.oddUserLock.Lock()\n\t\tdelete(h.oddOnlineUsers, uid)\n\t\th.oddUserLock.Unlock()\n\t}\n}\n\nfunc (h *WsHubImpl) AddConn(user *User, conn *websocket.Conn) (*WSUser, error) {\n\tif user.ID == 0 {\n\t\twsUser := new(WSUser)\n\t\twsUser.User = new(User)\n\t\t*wsUser.User = *user\n\t\twsUser.AddSocket(conn, \"\")\n\t\tWsHub.GuestLock.Lock()\n\t\tWsHub.OnlineGuests[wsUser] = true\n\t\tWsHub.GuestLock.Unlock()\n\t\treturn wsUser, nil\n\t}\n\n\t// TODO: How should we handle user state changes if we're holding a pointer which never changes?\n\tuserptr, err := Users.Get(user.ID)\n\tif err != nil && err != ErrStoreCapacityOverflow {\n\t\treturn nil, err\n\t}\n\n\tvar mutex *sync.RWMutex\n\tvar theMap map[int]*WSUser\n\tif user.ID%2 == 0 {\n\t\tmutex = &h.evenUserLock\n\t\ttheMap = h.evenOnlineUsers\n\t} else {\n\t\tmutex = &h.oddUserLock\n\t\ttheMap = h.oddOnlineUsers\n\t}\n\n\tmutex.Lock()\n\twsUser, ok := theMap[user.ID]\n\tif !ok {\n\t\twsUser = new(WSUser)\n\t\twsUser.User = userptr\n\t\twsUser.Sockets = []*WSUserSocket{{conn, \"\"}}\n\t\ttheMap[user.ID] = wsUser\n\t\tmutex.Unlock()\n\t\treturn wsUser, nil\n\t}\n\tmutex.Unlock()\n\twsUser.AddSocket(conn, \"\")\n\treturn wsUser, nil\n}\n\nfunc (h *WsHubImpl) RemoveConn(wsUser *WSUser, conn *websocket.Conn) {\n\twsUser.RemoveSocket(conn)\n\twsUser.Lock()\n\tif len(wsUser.Sockets) == 0 {\n\t\th.removeUser(wsUser.User.ID)\n\t}\n\twsUser.Unlock()\n}\n\nfunc (h *WsHubImpl) PushMessage(targetUser int, msg string) error {\n\twsUser, e := h.getUser(targetUser)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn wsUser.WriteAll(msg)\n}\n\nfunc (h *WsHubImpl) pushAlert(targetUser int, a Alert) error {\n\twsUser, e := h.getUser(targetUser)\n\tif e != nil {\n\t\treturn e\n\t}\n\tastr, e := BuildAlert(a, *wsUser.User)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn wsUser.WriteAll(astr)\n}\n\nfunc (h *WsHubImpl) pushAlerts(users []int, a Alert) error {\n\twsUsers, err := h.getUsers(users)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar errs []error\n\tfor _, wsUser := range wsUsers {\n\t\tif wsUser == nil {\n\t\t\tcontinue\n\t\t}\n\t\talert, err := BuildAlert(a, *wsUser.User)\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t\terr = wsUser.WriteAll(alert)\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\t// Return the first error\n\tif len(errs) != 0 {\n\t\tfor _, e := range errs {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "common/ws_user.go",
    "content": "package common\n\nimport (\n\t\"errors\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\nvar ErrNoneOnPage = errors.New(\"This user isn't on that page\")\nvar ErrInvalidSocket = errors.New(\"That's not a valid WebSocket Connection\")\n\ntype WSUser struct {\n\tUser    *User\n\tSockets []*WSUserSocket\n\tsync.Mutex\n}\n\ntype WSUserSocket struct {\n\tconn *websocket.Conn\n\tPage string\n}\n\nfunc (u *WSUser) Ping() error {\n\tvar sockets []*WSUserSocket\n\tvar del int\n\tfunc() {\n\t\tu.Lock()\n\t\tdefer u.Unlock()\n\t\tfor i, s := range u.Sockets {\n\t\t\tif s == nil || s.conn == nil {\n\t\t\t\tdel++\n\t\t\t\tu.Sockets[i] = u.Sockets[len(u.Sockets)-del]\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsockets = append(sockets, s)\n\t\t}\n\t}()\n\tif del > 0 {\n\t\t// TODO: Resize the capacity to release memory more eagerly?\n\t\tu.Sockets = u.Sockets[:len(u.Sockets)-del]\n\t}\n\n\tfor _, s := range sockets {\n\t\t_ = s.conn.SetWriteDeadline(time.Now().Add(time.Minute))\n\t\te := s.conn.WriteMessage(websocket.PingMessage, nil)\n\t\tif e != nil {\n\t\t\ts.conn.Close()\n\t\t\tu.Lock()\n\t\t\ts.conn = nil\n\t\t\tu.Unlock()\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (u *WSUser) WriteAll(msg string) error {\n\tmsgbytes := []byte(msg)\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tw, e := socket.conn.NextWriter(websocket.TextMessage)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\t_, _ = w.Write(msgbytes)\n\t\tw.Close()\n\t}\n\treturn nil\n}\n\nfunc (u *WSUser) WriteToPage(msg, page string) error {\n\treturn u.WriteToPageBytes([]byte(msg), page)\n}\n\n// Inefficient as it looks for sockets for a page even if there are none\nfunc (u *WSUser) WriteToPageBytes(msg []byte, page string) error {\n\tvar success bool\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif socket.Page != page {\n\t\t\tcontinue\n\t\t}\n\t\tw, e := socket.conn.NextWriter(websocket.TextMessage)\n\t\tif e != nil {\n\t\t\tcontinue // Skip dead sockets, a dedicated goroutine handles those\n\t\t}\n\t\t_, _ = w.Write(msg)\n\t\tw.Close()\n\t\tsuccess = true\n\t}\n\tif !success {\n\t\treturn ErrNoneOnPage\n\t}\n\treturn nil\n}\n\n// Inefficient as it looks for sockets for a page even if there are none\nfunc (u *WSUser) WriteToPageBytesMulti(msgs [][]byte, page string) error {\n\tvar success bool\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif socket.Page != page {\n\t\t\tcontinue\n\t\t}\n\t\tw, e := socket.conn.NextWriter(websocket.TextMessage)\n\t\tif e != nil {\n\t\t\tcontinue // Skip dead sockets, a dedicated goroutine handles those\n\t\t}\n\t\tfor _, msg := range msgs {\n\t\t\t_, _ = w.Write(msg)\n\t\t}\n\t\tw.Close()\n\t\tsuccess = true\n\t}\n\tif !success {\n\t\treturn ErrNoneOnPage\n\t}\n\treturn nil\n}\n\nfunc (u *WSUser) CountSockets() int {\n\tu.Lock()\n\tdefer u.Unlock()\n\treturn len(u.Sockets)\n}\n\nfunc (u *WSUser) AddSocket(conn *websocket.Conn, page string) {\n\tu.Lock()\n\t// If the number of the sockets is small, then we can keep the size of the slice mostly static and just walk through it looking for empty slots\n\t/*if len(u.Sockets) < 6 {\n\t\tfor i, socket := range u.Sockets {\n\t\t\tif socket == nil {\n\t\t\t\tu.Sockets[i] = &WSUserSocket{conn, page}\n\t\t\t\tu.Unlock()\n\t\t\t\t//fmt.Printf(\"%+v\\n\", u.Sockets)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}*/\n\tu.Sockets = append(u.Sockets, &WSUserSocket{conn, page})\n\t//fmt.Printf(\"%+v\\n\", u.Sockets)\n\tu.Unlock()\n}\n\nfunc (u *WSUser) RemoveSocket(conn *websocket.Conn) {\n\tvar del int\n\tu.Lock()\n\tdefer u.Unlock()\n\tfor i, socket := range u.Sockets {\n\t\tif socket == nil || socket.conn == nil {\n\t\t\tdel++\n\t\t\tu.Sockets[i] = u.Sockets[len(u.Sockets)-del]\n\t\t} else if socket.conn == conn {\n\t\t\tdel++\n\t\t\tu.Sockets[i] = u.Sockets[len(u.Sockets)-del]\n\t\t\t//break\n\t\t}\n\t}\n\t//Logf(\"%+v\\n\", u.Sockets)\n\t//Log(\"del: \", del)\n\tif del > 0 {\n\t\t// TODO: Resize the capacity to release memory more eagerly?\n\t\tu.Sockets = u.Sockets[:len(u.Sockets)-del]\n\t}\n\t//Logf(\"%+v\\n\", u.Sockets)\n\treturn\n\n\tif len(u.Sockets) < 6 {\n\t\tfor i, socket := range u.Sockets {\n\t\t\tif socket == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif socket.conn == conn {\n\t\t\t\tu.Sockets[i] = nil\n\t\t\t\t//fmt.Printf(\"%+v\\n\", wsUser.Sockets)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tvar key int\n\tfor i, socket := range u.Sockets {\n\t\tif socket.conn == conn {\n\t\t\tkey = i\n\t\t\tbreak\n\t\t}\n\t}\n\tu.Sockets = append(u.Sockets[:key], u.Sockets[key+1:]...)\n\t//fmt.Printf(\"%+v\\n\", u.Sockets)\n}\n\nfunc (u *WSUser) SetPageForSocket(conn *websocket.Conn, page string) error {\n\tif conn == nil {\n\t\treturn ErrInvalidSocket\n\t}\n\n\tu.Lock()\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif socket.conn == conn {\n\t\t\tsocket.Page = page\n\t\t}\n\t}\n\tu.Unlock()\n\n\treturn nil\n}\n\nfunc (u *WSUser) InPage(page string) bool {\n\tu.Lock()\n\tdefer u.Unlock()\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif socket.Page == page {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (u *WSUser) FinalizePage(page string, h func()) {\n\tu.Lock()\n\tdefer u.Unlock()\n\tfor _, socket := range u.Sockets {\n\t\tif socket == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif socket.Page == page {\n\t\t\treturn\n\t\t}\n\t}\n\th()\n}\n"
  },
  {
    "path": "config/config_example.json",
    "content": "{\n\t\"Site\": {\n\t\t\"ShortName\":\"Exa\",\n\t\t\"Name\":\"Example\",\n\t\t\"URL\":\"localhost\",\n\t\t\"Port\":\"80\",\n\t\t\"EnableSsl\":false,\n\t\t\"EnableEmails\":false,\n\t\t\"HasProxy\":false,\n\t\t\"Language\": \"english\"\n\t},\n\t\"Config\": {\n\t\t\"SslPrivkey\": \"\",\n\t\t\"SslFullchain\": \"\",\n\t\t\"SMTPServer\": \"\",\n\t\t\"SMTPUsername\": \"\",\n\t\t\"SMTPPassword\": \"\",\n\t\t\"SMTPPort\": \"25\",\n\n\t\t\"MaxRequestSizeStr\":\"5MB\",\n\t\t\"UserCache\":\"static\",\n\t\t\"TopicCache\":\"static\",\n\t\t\"ReplyCache\":\"static\",\n\t\t\"UserCacheCapacity\":180,\n\t\t\"TopicCacheCapacity\":400,\n\t\t\"ReplyCacheCapacity\":20,\n\t\t\"DefaultPath\":\"/topics/\",\n\t\t\"DefaultGroup\":3,\n\t\t\"ActivationGroup\":5,\n\t\t\"StaffCSS\":\"staff_post\",\n\t\t\"DefaultForum\":2,\n\t\t\"MinifyTemplates\":true,\n\t\t\"BuildSlugs\":true,\n\t\t\"ServerCount\":1,\n\t\t\"Noavatar\":\"https://api.adorable.io/avatars/{width}/{id}.png\",\n\t\t\"ItemsPerPage\":25\n\t},\n\t\"Database\": {\n\t\t\"Adapter\": \"mysql\",\n\t\t\"Host\": \"localhost\",\n\t\t\"Username\": \"anything_but_root\",\n\t\t\"Password\": \"please_use_a_password_that_is_actually_secure\",\n\t\t\"Dbname\": \"gosora\",\n\t\t\"Port\": \"3306\", \n\n\t\t\"TestAdapter\": \"mysql\",\n\t\t\"TestHost\": \"localhost\",\n\t\t\"TestUsername\": \"root\",\n\t\t\"TestPassword\": \"\",\n\t\t\"TestDbname\": \"gosora_test\",\n\t\t\"TestPort\": \"3306\"\n\t},\n\t\"Dev\": {\n\t\t\"DebugMode\":true,\n\t\t\"SuperDebug\":false\n\t}\n}"
  },
  {
    "path": "config/emoji_default.json",
    "content": "{\n\t\"emojis\": [\n\t\t{\":grinning:\": \"😀\"},\n\t\t{\":grin:\": \"😁\"},\n\t\t{\":joy:\": \"😂\"},\n\t\t{\":rofl:\": \"🤣\"},\n\t\t{\":smiley:\": \"😃\"},\n\t\t{\":smile:\": \"😄\"},\n\t\t{\":sweat_smile:\": \"😅\"},\n\t\t{\":laughing:\": \"😆\"},\n\t\t{\":satisfied:\": \"😆\"},\n\t\t{\":wink:\": \"😉\"},\n\t\t{\":blush:\": \"😊\"},\n\t\t{\":yum:\": \"😋\"},\n\t\t{\":sunglasses:\": \"😎\"},\n\t\t{\":heart_eyes:\": \"😍\"},\n\t\t{\":kissing_heart:\": \"😘\"},\n\t\t{\":kissing:\": \"😗\"},\n\t\t{\":kissing_smiling_eyes:\": \"😙\"},\n\t\t{\":kissing_closed_eyes:\": \"😚\"},\n\t\t{\":relaxed:\": \"☺️\"},\n\t\t{\":slight_smile:\": \"🙂\"},\n\t\t{\":hugging:\": \"🤗\"},\n\t\t{\":thinking:\": \"🤔\"},\n\t\t{\":neutral_face:\": \"😐\"},\n\t\t{\":expressionless:\": \"😑\"},\n\t\t{\":no_mouth:\": \"😶\"},\n\t\t{\":rolling_eyes:\": \"🙄\"},\n\t\t{\":smirk:\": \"😏\"},\n\t\t{\":persevere:\": \"😣\"},\n\t\t{\":disappointed_relieved:\": \"😥\"},\n\t\t{\":open_mouth:\": \"😮\"},\n\t\t{\":zipper_mouth:\": \"🤐\"},\n\t\t{\":hushed:\": \"😯\"},\n\t\t{\":sleepy:\": \"😪\"},\n\t\t{\":tired_face:\": \"😫\"},\n\t\t{\":sleeping:\": \"😴\"},\n\t\t{\":relieved:\": \"😌\"},\n\t\t{\":nerd:\": \"🤓\"},\n\t\t{\":stuck_out_tongue:\": \"😛\"},\n\t\t{\":worried:\": \"😟\"},\n\t\t{\":drooling_face:\": \"🤤\"},\n\t\t{\":disappointed:\": \"😞\"},\n\t\t{\":astonished:\": \"😲\"},\n\t\t{\":slight_frown:\": \"🙁\"},\n\t\t{\":skull_crossbones:\": \"☠️\"},\n\t\t{\":skull:\": \"💀\"},\n\t\t{\":point_up:\": \"☝️\"},\n\t\t{\":v:\": \"✌️️\"},\n\t\t{\":writing_hand:\": \"✍️\"},\n\t\t{\":heart:\": \"❤️️\"},\n\t\t{\":heart_exclamation:\": \"❣️\"},\n\t\t{\":hotsprings:\": \"♨️\"},\n\t\t{\":airplane:\": \"✈️️\"},\n\t\t{\":hourglass:\": \"⌛\"},\n\t\t{\":watch:\": \"⌚\"},\n\t\t{\":comet:\": \"☄️\"},\n\t\t{\":snowflake:\": \"❄️\"},\n\t\t{\":cloud:\": \"☁️\"},\n\t\t{\":sunny:\": \"☀️\"},\n\t\t{\":spades:\": \"♠️\"},\n\t\t{\":hearts:\": \"♥️️\"},\n\t\t{\":diamonds:\": \"♦️\"},\n\t\t{\":clubs:\": \"♣️\"},\n\t\t{\":phone:\": \"☎️\"},\n\t\t{\":telephone:\": \"☎️\"},\n\t\t{\":biohazard:\": \"☣️\"},\n\t\t{\":radioactive:\": \"☢️\"},\n\t\t{\":scissors:\": \"✂️\"},\n\t\t{\":arrow_upper_right:\": \"↗️\"},\n\t\t{\":arrow_right:\": \"➡️\"},\n\t\t{\":arrow_lower_right:\": \"↘️\"},\n\t\t{\":arrow_lower_left:\": \"↙️\"},\n\t\t{\":arrow_upper_left:\": \"↖️\"},\n\t\t{\":arrow_up_down:\": \"↕️\"},\n\t\t{\":left_right_arrow:\": \"↔️\"},\n\t\t{\":leftwards_arrow_with_hook:\": \"↩️\"},\n\t\t{\":arrow_right_hook:\": \"↪️\"},\n\t\t{\":arrow_forward:\": \"▶️\"},\n\t\t{\":arrow_backward:\": \"◀️\"},\n\t\t{\":female:\": \"♀️\"},\n\t\t{\":male:\": \"♂️\"},\n\t\t{\":ballot_box_with_check:\": \"☑️\"},\n\t\t{\":heavy_check_mark:\": \"✔️️\"},\n\t\t{\":heavy_multiplication_x:\": \"✖️\"},\n\t\t{\":pisces:\": \"♓\"},\n\t\t{\":aquarius:\": \"♒\"},\n\t\t{\":capricorn:\": \"♑\"},\n\t\t{\":sagittarius:\": \"♐\"},\n\t\t{\":scorpius:\": \"♏\"},\n\t\t{\":libra:\": \"♎\"},\n\t\t{\":virgo:\": \"♍\"},\n\t\t{\":leo:\": \"♌\"},\n\t\t{\":cancer:\": \"♋\"},\n\t\t{\":gemini:\": \"♊\"},\n\t\t{\":taurus:\": \"♉\"},\n\t\t{\":aries:\": \"♈\"},\n\t\t{\":peace:\": \"☮️\"},\n\t\t{\":eight_spoked_asterisk:\": \"✳️\"},\n\t\t{\":eight_pointed_black_star:\": \"✴️\"},\n\t\t{\":snowman2:\": \"☃️\"},\n\t\t{\":umbrella2:\": \"☂️\"},\n\t\t{\":pencil2:\": \"✏️\"},\n\t\t{\":black_nib:\": \"✒️\"},\n\t\t{\":email:\": \"✉️\"},\n\t\t{\":envelope:\": \"✉️\"},\n\t\t{\":keyboard:\": \"⌨️\"},\n\t\t{\":white_small_square:\": \"▫️\"},\n\t\t{\":black_small_square:\": \"▪️\"},\n\t\t{\":secret:\": \"㊙️\"},\n\t\t{\":congratulations:\": \"㊗️\"},\n\t\t{\":m:\": \"Ⓜ️\"},\n\t\t{\":tm:\": \"™️️\"},\n\t\t{\":registered:\": \"®️\"},\n\t\t{\":copyright:\": \"©️\"},\n\t\t{\":wavy_dash:\": \"〰️\"},\n\t\t{\":bangbang:\": \"‼️\"},\n\t\t{\":sparkle:\": \"❇️\"},\n\t\t{\":star_of_david:\": \"✡️\"},\n\t\t{\":wheel_of_dharma:\": \"☸️\"},\n\t\t{\":yin_yang:\": \"☯️\"},\n\t\t{\":cross:\": \"✝️\"},\n\t\t{\":orthodox_cross:\": \"☦️\"},\n\t\t{\":star_and_crescent:\": \"☪️\"},\n\t\t{\":frowning2:\": \"☹️\"},\n\t\t{\":information_source:\": \"ℹ️\"},\n\t\t{\":interrobang:\": \"⁉️\"}\n\t]\n}"
  },
  {
    "path": "config/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "config/weakpass_default.json",
    "content": "{\n\t\"contains\":[\n\t\t\"test\", \"123\", \"6969\", \"password\", \"qwerty\", \"fuck\", \"love\",\"1 2 3 4 5\"\n\t],\n\t\"literal\":[\n\t\t\"superman\",\"football\",\"baseball\",\"starwars\",\"passw0rd\",\"whatever\",\"master's degree\",\"trustno1\",\"computer\",\"corvette\",\"mercedes\",\"letmein\",\"welcome\",\"freedom\",\"matthew\",\"asshole\",\"ferrari\",\"blahblah\",\"crystal\"\n\t]\n}"
  },
  {
    "path": "database.go",
    "content": "package main\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/pkg/errors\"\n)\n\nvar stmts *Stmts\n\nvar db *sql.DB\nvar dbAdapter string\n\n// ErrNoRows is an alias of sql.ErrNoRows, just in case we end up with non-database/sql datastores\nvar ErrNoRows = sql.ErrNoRows\n\nvar _initDatabase func() error\n\nfunc InitDatabase() (err error) {\n\tstmts = &Stmts{Mocks: false}\n\n\t// Engine specific code\n\terr = _initDatabase()\n\tif err != nil {\n\t\treturn err\n\t}\n\tglobs = &Globs{stmts}\n\tws := errors.WithStack\n\n\tlog.Print(\"Running the db handlers.\")\n\terr = c.DbInits.Run()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the usergroups.\")\n\tc.Groups, err = c.NewMemoryGroupStore()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\terr2 := c.Groups.LoadGroups()\n\tif err2 != nil {\n\t\treturn ws(err2)\n\t}\n\n\t// We have to put this here, otherwise LoadForums() won't be able to get the last poster data when building it's forums\n\tlog.Print(\"Initialising the user and topic stores\")\n\n\tvar ucache c.UserCache\n\tif c.Config.UserCache == \"static\" {\n\t\tucache = c.NewMemoryUserCache(c.Config.UserCacheCapacity)\n\t}\n\tvar tcache c.TopicCache\n\tif c.Config.TopicCache == \"static\" {\n\t\ttcache = c.NewMemoryTopicCache(c.Config.TopicCacheCapacity)\n\t}\n\n\tc.Users, err = c.NewDefaultUserStore(ucache)\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\tc.Topics, err = c.NewDefaultTopicStore(tcache)\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the forums.\")\n\tc.Forums, err = c.NewMemoryForumStore()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\terr = c.Forums.LoadForums()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the forum permissions.\")\n\tc.FPStore, err = c.NewMemoryForumPermsStore()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\terr = c.FPStore.Init()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the settings.\")\n\terr = c.LoadSettings()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the plugins.\")\n\terr = c.InitExtend()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\n\tlog.Print(\"Loading the themes.\")\n\terr = c.Themes.LoadActiveStatus()\n\tif err != nil {\n\t\treturn ws(err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "dev-update-linux",
    "content": "echo \"Updating the dependencies\"\n./update-deps-linux\n\necho \"Updating Gosora\"\ngit stash\ngit pull origin master\ngit stash apply\n\necho \"Patching Gosora\"\ngo generate\ngo build -ldflags=\"-s -w\" -o Patcher \"./patcher\"\n./Patcher"
  },
  {
    "path": "dev-update-travis",
    "content": "echo \"Building the patcher\"\ngo generate\n./update-deps-linux\ngo build -ldflags=\"-s -w\" -o Patcher \"./patcher\""
  },
  {
    "path": "dev-update.bat",
    "content": "@echo off\n\necho Updating the dependencies\ngo get\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\ngo get -u github.com/mailru/easyjson/...\n\necho Updating Gosora\ngit stash\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\ngit pull origin master\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\ngit stash apply\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Patching Gosora\ngo generate\ngo build -ldflags=\"-s -w\" ./patcher\npatcher.exe"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration\n\nFor configuring the system, Gosora has a file called `config/config.json` which you can tweak to change various behaviours, it also has a few settings in the Setting Manager in the Control Panel.\n\nThe configuration file has five categories you may be familiar with from poring through it's contents. Site, Config, Database, Dev and Plugin.\n\nSite is for critical settings.\n\nConfig is for lower priority yet still important settings.\n\nDatabase contains the credentials for the database (you will be able to pass these via parameters to the binary in a future version).\n\nDev is for a few flags which help out with the development of Gosora.\n\nPlugin which you may not have run into is a category in which plugins can define their own custom configuration settings.\n\nAn example of what the file might look like: https://github.com/Azareal/Gosora/blob/master/config/config_example.json\n\nOther configuration files: [config/weakpass.json](https://github.com/Azareal/Gosora/blob/master/docs/weak_passwords.md), [config/emoji.json](https://github.com/Azareal/Gosora/blob/master/docs/emoji.md) (WIP)\n\n# Site\n\nShortName - A two or three letter abbreviation of your site's name. Intended for compact spaces where the full name is too long to squeeze in.\n\nName - The name of your site, as appears in the title, some theme headers and search engine search results.\n\nEmail - The email address you want to show up in the From: field when Gosora sends emails. May be left blank, if emails are disabled.\n\nURL - The URL for your site. Please leave out the `http://` or `https://` and the `/` at the end.\n\nPort - The port you want Gosora to listen on. This will usually be 443 for HTTPS and 80 for HTTP. Gosora will try to bind to both, if you're on HTTPS to redirect users from the HTTP site to the HTTPS one.\n\nEnableSsl - Determines whether HTTPS is enabled.\n\nEnableEmails - Determines whether the SMTP mail subsystem is enabled. The experimental plugin sendmail also allows you to send emails without SMTP in a similar style to some languages like PHP, although it only works on Linux and has some issues.\n\nHasProxy - Brittle, but lets you set whether you're sitting behind a proxy like Cloudflare. Unknown effects with reverse-proxies like Nginx.\n\nLanguage - The language you want to use. Defaults to english. Please consult [internationalisation](https://github.com/Azareal/Gosora/blob/master/docs/internationalisation.md) for details.\n\n# Config\n\nSslPrivkey - The path to the SSL private key. Example: ./cert/test.key\n\nSslFullchain - The path to the fullchain SSL certificate. Example: ./cert/test.cert\n\nSMTPServer - The domain for the SMTP server. May be left blank if EnableEmails is false.\n\nSMTPUsername - The username for the SMTP server.\n\nSMTPPassword - The password for the SMTP server.\n\nSMTPPort - The port for the SMTP server, usually 25 or 465 for full TLS.\n\nSMTPEnableTLS - Enable TLS to fully encrypt the connection between Gosora and the SMTP server.\n\nSearch - The type of search system to use. Options: disabled, sql (default)\n\nMaxRequestSizeStr - The maximum size that a request made to Gosora can be. This includes uploads. Example: 5MB\n\nUserCache - The type of user cache you want to use. You can leave this blank to disable this feature or use `static` for a small in-memory cache.\n\nTopicCache - The type of topic cache you want to use. You can leave this blank to disable this feature or use `static` for a small in-memory cache.\n\nReplyCache - The type of reply cache you want to use. You can leave this blank to disable this feature or use `static` for a small in-memory cache.\n\nUserCacheCapacity - The maximum number of users you want in the in-memory user cache, if enabled in the UserCache setting.\n\nTopicCacheCapacity - The maximum number of topics you want in the in-memory topic cache, if enabled in the TopicCache setting.\n\nReplyCacheCapacity - The maximum number of replies you want in the in-memory reply cache, if enabled in the ReplyCache setting.\n\nDefaultPath - The route you want the homepage `/` to default to. Examples: `/topics/` or `/forums/`\n\nDefaultGroup - The group you want users to be moved to once they're activated. Example: 3\n\nActivationGroup - The group you want users to be placed in while they're awaiting activation. Example: 5\n\nStaffCSS - Classes you want applied to the postbits for staff posts. This setting is deprecated and will likely be replaced with a more generic mechanism in the near future.\n\nDefaultForum - The default forum for the drop-down in the quick topic creator. Please note that FID 1 is reserved for the default reports forum. Example: 2\n\nMinifyTemplates - Whether you want the HTML pages to be minified prior to being send to the client.\n\nBuildSlugs - Whether you want the title appear in the URL. For instance: `/topic/traffic-in-paris.5` versus `/topic/5`\n\nServerCount - The number of instances you're running. This setting is currently experimental.\n\nLastIPCutoff - The number of months which need to pass before the last IP stored for a user is automatically deleted. Capped at 12. 0 defaults to whatever the current default is, currently 3 and -1 disables this feature.\n\nPostIPCutoff - The number of days which need to pass before the IP data for a post is automatically deleted. 0 defaults to whatever the current default is, currently 90 and -1 disables this feature.\n\nPollIPCutoff - The number of days which need to pass before the IP data for a poll is automatically deleted. 0 defaults to whatever the current default is, currently 90 and -1 disables this feature.\n\nDisableIP - Master switch to disable tracking user IPs for any purpose. May not entirely clear already stored data, or data logged by an upstream like a reverse-proxy. Currently doesn't cover net/http ErrorLog. Default: false\n\nDisableLastIP - Disable storing last IPs for users and purge any existing user last IP data. Default: false\n\nDisablePostIP - Disable storing post IPs for users and purge any existing post IP data. Default: false\n\nDisablePollIP - Disable storing poll vote IPs and purge any existing poll vote IP data. Default: false\n\nDisableRegLog - Disable storing registration events and purge any existing registration event data. Default: false\n\nLogPruneCutoff - The number of days which need to pass before the login and registration logs are pruned. 0 defaults to whatever the current default is, currently 180 and -1 disables this feature.\n\nDisableLiveTopicList - This switch allows you to disable the live topic list. Default: false\n\nDisableJSAntispam - This switch lets you disable the JS anti-spam feature. It may be useful if you primarily get users who for one reason or another have decided to disable JavaScript. Default: false\n\nLooseHost - Disable host header checks in the router. This may be useful when using a reverse-proxy like Nginx / Apache to stop it white-screening. Default: false\n\nLoosePort - Disable port match checks in the router. This may be useful when using a reverse-proxy like Nginx / Apache to stop it white-screening. Default: false\n\nSslSchema - Allow for HTTPS URLs without necessarily using a HTTPS listener. This might be useful if a reverse-proxy or otherwise terminates the SSL / TLS connection instead of Gosora. Default: false\n\nDisableServerPush - This switch lets you disable the HTTP/2 server push feature. Default: false\n\nEnableCDNPush - This switch lets you enable the HTTP/2 CDN Server Push feature. This operates by sending a Link header on every request and may also work with reverse-proxies like Nginx for doing HTTP/2 server pushes.\n\nDisableNoavatarRange - This switch lets you disable the noavatar algorithm which maps IDs to a set ranging from 0 to 50 for better cacheability. Default: false\n\nDisableDefaultNoavatar - This switch lets you disable the default noavatar algorithm which may intercept noavatars for increased efficiency. Default: false\n\nDisableAnalytics - This switch lets you disable the analytics subsystem. Default: false\n\nRefNoTrack - This switch disables tracking the referrers of users who click from another site to your site and the referrers of any requests to resources from other sites as-well.\n\nRefNoRef - This switch makes it so that if a user clicks on a link, then the incoming site won't know which site they're coming from.\n\nNoEmbed - Don't expand links into videos or images. Default: false\n\nExtraCSPOrigins - Extra origins which may want whitelisted in the default Content Security Policy.\n\nStaticResBase - The default prefix for static resource files. May be a path or an external domain like a CDN domain. Default: /s/\n\nAvatarResBase - The default prefix for avatar files. May be a path or an external domain like a CDN domain. Default: /uploads/\n\nNoAvatar - The default avatar to use for users when they don't have their own. The default for this may change in the near future to better utilise HTTP/2. Example: https://api.adorable.io/avatars/{width}/{id}.png\n\nItemsPerPage - The number of posts, topics, etc. you want on each page.\n\nMaxTopicTitleLength - The maximum length that a topic can be. Please note that this measures the number of bytes and may differ from language to language with it being equal to a letter in English and being two bytes in others.\n\nMaxUsernameLength - The maximum length that a user's name can be. Please note that this measures the number of bytes and may differ from language to language with it being equal to a letter in English and being two bytes in others.\n\nReadTimeout - The number of seconds that we are allowed to take to fully read a request. Defaults to 8.\n\nWriteTimeout - The number of seconds that a route is allowed to run for before the request is automatically terminated. Defaults to 10.\n\nIdleTimeout - The number of seconds that a Keep-Alive connection will be kept open before being closed. You might to tweak this, if you use Cloudflare or similar. Defaults to 120.\n\nLogDir - The directory in which logs are stored, with the exception of ops log, until a related bug is resolved. Default: ./logs/\n\nDisableSuspLog - Whether suspicious requests are logged in the suspicious request logs. Enabling this may make a site faster. Defaults: false.\n\nDisableBadRouteLog - Whether requests referencing routes which don't exist should be individually logged. Enabling this may make a site faster. Default: false\n\nDisableStdout - Stop writing logs to stdout. Default: false.\n\nDisableStderr - Stop writing errors to stderr. Default: false\n\nRelated: https://support.cloudflare.com/hc/en-us/articles/212794707-General-Best-Practices-for-Load-Balancing-at-your-origin-with-Cloudflare\n\n\n# Database\n\nAdapter - The name of the database adapter. `mysql` and `mssql` are options, although mssql may not work properly in the latest version of Gosora. PgSQL support is in the works.\n\nHost - The host of the database you wish to connect to. Example: localhost\n\nUsername - The username for the database you wish to connect to. Example: root\n\nPassword - The password for the database you wish to connect to. Example: password\n\nDbname - The name of the database you want to use. Example: gosora\n\nPort - The port the database is listening on. Usually 3306 for MySQL.\n\nTestAdapter - A test version of Adapter. Only used for testing purposes.\n\nTestHost - A test version of Host. Only used for testing purposes.\n\nTestUsername - A test version of Username. Only used for testing purposes.\n\nTestPassword - A test version of Password. Only used for testing purposes.\n\nTestDbname - A test version of Dbname. Only used for testing purposes.\n\nTestPort - A test version of Port. Only used for testing purposes.\n\n# Dev\n\nDebugMode - Outputs a basic level of information about what Gosora is doing to help ease debugging.\n\nSuperDebug - Outputs a detailed level of information about what Gosora is doing to help ease debugging. Warning: This may cause severe slowdowns in Gosora.\n\nTemplateDebug - Output a detailed level of information about what Gosora is doing during the template transpilation step. Warning: Large amounts of information will be dumped into the logs.\n\nNoFsnotify - Whether you want to disable the file watcher which automatically reloads assets whenever they change."
  },
  {
    "path": "docs/custom_pages.md",
    "content": "# Custom Pages\n\nThere are two ways to create custom pages in Gosora, one which requires a lot more technical knowledge than the other. The first is to create the page via the Page Manager in the Control Panel where you'll be able to type whatever you want visible on the page into a little form.\n\nThe second is to create a template file in /pages/ which will be loaded by Gosora and parsed like any template will. You will require knowledge of HTML, and possibly even Go Templates for this option, but it's a lot more flexible than the first option.\n\nMore to come."
  },
  {
    "path": "docs/emoji.md",
    "content": "# Emoji\n\nEmojis are a work in progress. We plan to implement UIs to input and pick them easily in the future. We also plan to make them more visually appealing and consistent.\n\nRight now, you can input the emoji directly with an emoji keyboard, like those present on mobile devices or Windows 10 (WIN KEY + . OR WIN KEY + ;).\n\nYou can also input them if you know the shortcodes which are defined in the configuration files `config/emoji_default.json` or `config/emoji.json`. `emoji_default.json` should be left untouched as it gets updated with Gosora. Any additions should be made via `emoji.json`.\n\nThe file is a work in progress but it contains two sections: `no_defaults` and `emojis`.\n\n`no_defaults` wipes clear anything defined by `emoji_default.json` to start over with a clean slate. You can set this to true to enable it or leave it absent to disable it.\n\n`emojis` lets you create a list of emoji shortcodes which are substituted by the parser for Unicode emojis. You should only specify shortcodes which start and end with `:` here. We plan to add a section for literals like `:P` in the future."
  },
  {
    "path": "docs/installation.md",
    "content": "# Windows Installation\n\nRun `install.bat`, e.g. double-click on it. You will also have to start-up MySQL, which if you're using Wnmp or friends is just a matter of opening that program and starting the MySQL process via it.\n\nFollow the instructions shown on the screen.\n\nTo navigate to the folder the software is in at any time in the future, you can just type `cd` followed by the folder's name, e.g. `cd /home/gosora/src/` and then you can run your commands. cd stands for change directory.\n\n\n# Linux Simple Installation\n\nSimple installations are usually recommended for trying out the software rather than for deploying it in production as they're less hardened and have fewer service facilities.\n\nThis might also be fine, if you're using something else as a reverse-proxy (e.g. Nginx or Apache).\n\nFirst, we need somewhere for the software to live, if you're familiar with Linux, then you might have some ideas of your own, otherwise we may just go for `~/gosora`.\n\nFirst, we'll navigate to our home folder by typing: `cd ~`\n\nAnd then, we'll going to pull a copy of Gosora off the git server with: `git clone https://github.com/Azareal/Gosora gosora`\n\nWe can now hop into the newly created folder with the same command we used for getting to the home folder:\n\n`cd gosora`\n\nAnd now, we'll change the permissions on the installer script, otherwise we'll get an access denied error:\n\n`chmod 755 ./update-deps-linux`\n\n`chmod 755 ./install-linux`\n\nJust run this to run the installer:\n\n`./install-linux`\n\nFollow the instructions shown on the screen.\n\n\n# Linux Installation with Systemd Service\n\nYou will need administrator privileges on the machine (aka root) to add a service.\n\nFirst, you will need to jump to the place where you want to put the code, we will use `/home/gosora/src/` here, but if you want to use something else, you'll have to modify the service file with your own path (but *never* in a folder where the files are automatically served by a webserver).\n\nIf you place it in `/www/`, `/public_html/` or any similar folder, there's a chance that your server might be compromised.\n\nThe following commands will pull the latest copy of Gosora off the Git repository, will create a user account to run Gosora as, will set it as the owner of the files and will start the installation process.\n\nIf you're casually setting up an installation on your own machine which isn't exposed to the internet just to try out Gosora, you might not need to setup a seperate account for it or do `chmod 2775 logs`.\n\nPlease type the following commands into the console and hit enter:\n\n`cd /home/`\n\n`useradd gosora`\n\n`passwd gosora`\n\nType in a strong password for the `gosora` user, please oh please... Don't use \"password\", just... don't, okay? Also, you might want to note this down somewhere.\n\n```bash\nmkdir gosora\n\ncd gosora\n\ngit clone https://github.com/Azareal/Gosora src\n\nchown -R gosora ../gosora\n\nchgrp -R www-data ../gosora\n\ncd src\n\nchmod 2775 logs\n\nchmod 2775 attachs\n\nchmod 2775 uploads\n\nchmod 2775 tmp\n\nchmod 755 ./update-deps-linux\n\nchmod 755 ./install-linux\n\n./install-linux\n```\n\nFollow the instructions shown on the screen.\n\nWe will also want to setup a service:\n\n`chmod 755 ./pre-run-linux`\n\n`cp ./gosora_example.service /lib/systemd/system/gosora.service`\n\n`systemctl daemon-reload`\n\n\n# Additional Configuration\n\nFor things like HTTPS, you might also need to [modify your config.json](https://github.com/Azareal/Gosora/blob/master/docs/configuration.md) file after installing Gosora to get it working.\n\nYou can get a free private key and certificate pair from Let's Encrypt or Cloudflare.\n\nIf you're using Nginx or something else as a reverse-proxy in-front of Gosora, you will have to consult their documentation for advice on setting HTTPS. You may also need to enable LoosePort and LooseHost in `config/config.json`.\n\nIf you're behind a reverse-proxy that terminates the SSL / TLS connection, you may want to set the SslSchema config setting to true in `config/config.json` and leave EnableSsl disabled. An example of this is if the certificate is setup on the reverse-proxy rather than the instance.\n\n\nFor email, you will need a SMTP server (either provided by yourself or by a transactional mail provider who specialises in doing so).\nYou can setup it up via config.json with the Email setting and the ones starting with SMTP.\n\nIt is possible to send emails without SMTP with the experimental sendmail plugin, however there is a high chance of your emails ending up in the user's spam folder, if it arrives at all.\n\nYou may need to open a port in your firewall in order for the outside world to see your instance of Gosora.\n\n\n# Advanced Installation\n\nThis section explains how to set things up without running the batch or shell files. For Windows, you will likely have to open up cmd.exe (the app called Command Prompt in Win10) to run these commands inside or something similar, while with Linux you would likely use the Terminal or console.\n\nFor more info, you might want to take a gander inside the `./run-linux` and `./install-linux` shell files to see how they're implemented.\n\nLinux:\n\n```bash\ngit clone https://github.com/Azareal/Gosora gosora\n\ncd gosora\n\ngo get -u github.com/mailru/easyjson/...\n\neasyjson -pkg common\n\ngo get\n\nrm -f tmpl_*.go\n\nrm -f gen_*.go\n\nrm -f tmpl_client/tmpl_*.go\n\nrm -f ./Gosora\n\nrm -f ./common/gen_extend.go\n\ngo generate\n\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\n\n./RouterGen\n\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\n\n./HookStubGen\n\ngo build -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\n\n./HookGen\n\ngo build -ldflags=\"-s -w\" -o QGen \"./cmd/query_gen\"\n\n./QGen\n\ngo build -ldflags=\"-s -w\" -o Gosora\n\ngo build -ldflags=\"-s -w\" -o Install \"./cmd/install\"\n\n./Install\n\ngo get -u github.com/mailru/easyjson/...\n\neasyjson -pkg common\n\n./Gosora -build-templates\n\n./Gosora\n```\n\nWindows:\n\n```batch\ngit clone https://github.com/Azareal/Gosora gosora\n\ncd gosora\n\ngo get -u github.com/mailru/easyjson/...\n\neasyjson -pkg common\n\ngo get\n\ndel \"template_*.go\"\n\ndel \"tmpl_*.go\"\n\ndel \"gen_*.go\"\n\ndel \".\\tmpl_client\\template_*\"\n\ndel \".\\tmpl_client\\tmpl_*\"\n\ndel \".\\common\\gen_extend.go\"\n\ndel \"gosora.exe\"\n\ngo generate\n\ngo build -ldflags=\"-s -w\" \"./router_gen\"\n\nrouter_gen.exe\n\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\n\nhook_stub_gen.exe\n\ngo build -ldflags=\"-s -w\" \"./cmd/hook_gen\"\n\nhook_gen.exe\n\neasyjson -pkg common\n\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\n\nquery_gen.exe\n\ngo build -ldflags=\"-s -w\" -o gosora.exe\n\ngo build -ldflags=\"-s -w\" \"./cmd/install\"\n\ninstall.exe\n\ngosora.exe -build-templates\n\ngosora.exe\n```\n\nI'm looking into minimising the number of go gets for the advanced build and to maybe remove the platform and database engine specific dependencies if possible for those who don't need them.\n\nIf systemd gives you no permission errors, then make sure you `chown`, `chgrp` and `chmod` the files and folders appropriately.\n\nYou don't need `-ldflags=\"-s -w\"` in any of the commands, however it will make compilation times faster.\n\nBuilding and running HookGen is optional, but strips unneccesary hook indirects for plugins you don't use."
  },
  {
    "path": "docs/internationalisation.md",
    "content": "# Internationalisation\n\nInternationalisation is one of Gosora's top priorities, although not the only one. This means making it possible for administrators to translate the interface to their native language and to otherwise customise to fit their cultures.\n\nThis doesn't mean that the software will cover every language on Earth right off the bat, however although, it would be greatly appreciated if people were to help contribute towards making this a reality.\n\nQuite a large portion of the software has been internationalised, although some of the biggest exceptions are a small handful of complex phrases which are currently hard to localise, the error messages and a few phrases in the Control Panel.\n\nThese exceptions are going to be resolved one by one as I go along until everything is covered, although it should be noted that some languages, particularly right-to-left ones might be hard to localise without a custom theme, but we'll look into seeing what we can do about those.\n\n# Customising Phrases\n\nYou can add a custom language to Gosora by adding a new file to the `/langs/` folder with the name of the language followed by the extension `.json`, e.g. `spanish.json` or `espanol.json`, if that is what you prefer, although the first might be more obvious to a larger audience.\n\nYou can also customise the phrases by doing the same thing and naming the file `english_custom.json` or whatever you wish. The contents of the file should basically follow the same format as in `english.json` and it should be noted that new phrases may be added to and removed from that file from time to time.\n\nYou can then set the default language for your site by going into `/config/config.json` and changing `\"Language\": \"english\"` to `\"Language\": \"spanish\"` or whatever the name of the language is. This value takes the value of the Name field in the language file and not the file name, although I would advise using unique names there and perhaps the name of the file for consistency.\n"
  },
  {
    "path": "docs/landing_page.md",
    "content": "# Landing Page\n\nYou can change the landing page of your site (in other words, the page the user lands on by default, aka the index or `/`) by tweaking the DefaultPath configuration value. More on this later."
  },
  {
    "path": "docs/templates.md",
    "content": "# Templates\n\nGosora uses a subset of [Go Templates](https://golang.org/pkg/text/template/) which are run on both the server side and client side with custom transpiler to wring out the most performance. Some more obscure features may not be available (e.g. local variables), but I am adding them in here and there.\n\nThe base templates are stored in `/templates/` and you can shadow them by placing modified duplicates in `/templates/overrides/`. The default themes all share the same set of templates present there.\n\nYou can also override templates on a per-theme basis by navigating to `/themes/themeName/overrides` (replace themeName with the name of the theme) and placing the modified duplicates there.\n\n# Non-standard Extensions\n\nWe also have a few non-standard extensions only available on certain pages or areas, but these shouldn't be relied on in favour of more general mechanisms.\n\nMore to come soon."
  },
  {
    "path": "docs/updating.md",
    "content": "# Updating Gosora (Windows)\n\nThe update system is currently under development, but you can run `dev-update.bat` to update your instance to the latest commit and to update the associated database schema, etc.\n\nIf you run into any issues doing so, please open an issue: https://github.com/Azareal/Gosora/issues/new\n\nIf you want to manually patch Gosora rather than relying on the above scripts to do it, you'll first want to save your changes with `git stash`, and then, you'll overwrite the files with the new ones with `git pull origin master`, and then, you can re-apply your custom changes with `git stash apply`\n\nAfter that, you'll need to run `go build ./patcher`.\n\nOnce you've done that, you just need to run `patcher.exe` to apply the latest patches to the database, etc.\n\n# Updating a software with a simple installation (Linux)\n\nThe update system is currently under development, but you can run `dev-update-linux` to update your instance to the latest commit and to update the associated database schema, etc.\n\nIf you run into any issues doing so, please open an issue: https://github.com/Azareal/Gosora/issues/new\n\nIf you want to manually patch Gosora rather than relying on the above scripts to do it, you'll first want to save your changes with `git stash`, and then, you'll overwrite the files with the new ones with `git pull origin master`, and then, you'll re-apply your changes with `git stash apply`.\n\nAfter that, you'll need to run `go build -o Patcher \"./patcher\"`\n\nOnce you've done that, you just need to run `./Patcher` to apply the latest patches to the database, etc.\n\n\n# Updating a software using systemd (Linux)\n\nYou will first want to follow the instructions in the section for updating dependencies.\n\nThe update system is currently under development, but you can run `quick-update-linux` in `/home/gosora/src`to update your instance to the latest commit and to update the associated database schema, etc.\n\nIf you run into any issues doing so, please open an issue: https://github.com/Azareal/Gosora/issues/new\n\nIf you're using a systemd service, then you might want to switch to the `gosora` user with `su gosora` (you may be prompted for the password to the user), you can switch back by typing `exit`.\nIf this is the first time you've done an update as the `gosora` user, then you might have to configure Git, simply do:\n\n`git config --global user.name \"Lalala\"`\n`git config --global user.email \"lalala@example.com\"`\n\nReplace that name and email with whatever you like. This name and email only applies to the `gosora` user. If you see a zillion modified files pop-up, then that is due to you changing their permissions, don't worry about it.\n\nIf you get an access denied error, then you might need to run `chown -R gosora /home/gosora` and `chgrp -R www-data /home/gosora` (with the corresponding user you setup for your instance) to fix the ownership of the files.\n\nIf you want to manually patch Gosora rather than relying on the above scripts to do it, you'll first want to save your changes with `git stash`, and then, you'll overwrite the files with the new ones with `git pull origin master`, and then, you'll re-apply your changes with `git stash apply`.\n\nAfter that, you'll need to run `go build -o Patcher \"./patcher\"`\n\nOnce you've done that, you just need to run `./Patcher` to apply the latest patches to the database, etc.\n\n\n# Updating Dependencies\n\nDependencies are third party scripts and programs which Gosora relies on to function. The instructions here do not cover updating MySQL / MariaDB or Go.\n\nYou can update them by running the `go get` command.\n\nYou'll need to restart the server after you change a template or update Gosora, e.g. with `run.bat` or killing the process and running `./run-linux` or via `./pre-run-linux` followed by `service gosora restart`.\n"
  },
  {
    "path": "docs/weak_passwords.md",
    "content": "# Weak Passwords\n\nFor configuring the list of weak passwords and weak password detection rules, we have `config/weakpass.json` which overwrites the default values defined in `config/weakpass_default.json`\n\nThere are two sections: `contains` and `literal`. `contains` scans the password to see if a specified piece of text is in it and `literal` checks if the password matches the specified rule exactly (with some exceptions).\n\nAll passwords are converted to lowercase form before either scanner is ran on them to detect common tricks like capitalizing the first letter.\n\n`contains` is slower and may not scale with a large number of rules, but it is more effective at finding certain patterns which a password cracker could exploit to crack someone's password.\n\n`literal` is very inflexible and only matches rules literally. One exception is that it will remove numbers from the end of the password running the rule.\n"
  },
  {
    "path": "experimental/config.json",
    "content": "{\n\t\"config.DefaultGroup\": 3,\n\t\"config.ActivationGroup\": 5,\n\t\"staff_css\": \" background-color: #ffeaff;\",\n\t\"uncategorised_forum_visible\": true,\n\t\"site.EnableEmails\": false,\n\t\"config.SmtpServer\": \"\",\n\t\"config.ItemsPerPage\": 40,\n\t\n\t\"db\": {\n\t\t\"Host\": \"127.0.0.1\",\n\t\t\"Username\": \"root\",\n\t\t\"Password\": \"password\",\n\t\t\"Dbname\": \"gosora\",\n\t\t\"Port\": \"3306\"\n\t},\n\t\n\t\"site\":\n\t{\n\t\t\"Url\": \"localhost:8080\",\n\t\t\"Port\": \"8080\",\n\t\t\"EnableSsl\": false\n\t},\n\t\n\t\"config\":\n\t{\n\t\t\"SslPrivkey\": \"\",\n\t\t\"SslFullchain\": \"\"\n\t},\n\t\n\t\"dev\":\n\t{\n\t\t\"debug\": false\n\t},\n}"
  },
  {
    "path": "experimental/counterTree/tree.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"math/bits\"\n\t\"sync/atomic\"\n\t\"unsafe\"\n)\n\nconst debug = true\n\ntype TreeCounterNode struct {\n\tValue  uint64\n\tZero   *TreeCounterNode\n\tOne    *TreeCounterNode\n\tParent *TreeCounterNode\n}\n\n// MEGA EXPERIMENTAL. Start from the right-most bits in the integer and move leftwards\ntype TreeTopicViewCounter struct {\n\troot *TreeCounterNode\n}\n\nfunc newTreeTopicViewCounter() *TreeTopicViewCounter {\n\treturn &TreeTopicViewCounter{\n\t\t&TreeCounterNode{0, nil, nil, nil},\n\t}\n}\n\nfunc (counter *TreeTopicViewCounter) Bump(signTopicID int64) {\n\tvar topicID uint64 = uint64(signTopicID)\n\tvar zeroCount = bits.LeadingZeros64(topicID)\n\tif debug {\n\t\tfmt.Printf(\"topicID int64: %d\\n\", signTopicID)\n\t\tfmt.Printf(\"topicID int64: %x\\n\", signTopicID)\n\t\tfmt.Printf(\"topicID int64: %b\\n\", signTopicID)\n\t\tfmt.Printf(\"topicID uint64: %b\\n\", topicID)\n\t\tfmt.Printf(\"leading zeroes: %d\\n\", zeroCount)\n\n\t\tvar leadingZeroes = \"\"\n\t\tfor i := 0; i < zeroCount; i++ {\n\t\t\tleadingZeroes += \"0\"\n\t\t}\n\t\tfmt.Printf(\"topicID lead uint64: %s%b\\n\", leadingZeroes, topicID)\n\n\t\tfmt.Printf(\"---\\n\")\n\t}\n\n\tvar stopAt uint64 = 64 - uint64(zeroCount)\n\tvar spot uint64 = 1\n\tvar node = counter.root\n\tfor {\n\t\tif debug {\n\t\t\tfmt.Printf(\"spot: %d\\n\", spot)\n\t\t\tfmt.Printf(\"topicID&spot: %d\\n\", topicID&spot)\n\t\t}\n\t\tif topicID&spot == 1 {\n\t\t\tif node.One == nil {\n\t\t\t\tatomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(node.One)), nil, unsafe.Pointer(&TreeCounterNode{0, nil, nil, node}))\n\t\t\t}\n\t\t\tnode = node.One\n\t\t} else {\n\t\t\tif node.Zero == nil {\n\t\t\t\tatomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(node.Zero)), nil, unsafe.Pointer(&TreeCounterNode{0, nil, nil, node}))\n\t\t\t}\n\t\t\tnode = node.Zero\n\t\t}\n\n\t\tspot++\n\t\tif spot >= stopAt {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tatomic.AddUint64(&node.Value, 1)\n}\n"
  },
  {
    "path": "experimental/counterTree/tree_test.go",
    "content": "package main\n\nimport (\n\t\"log\"\n\t\"testing\"\n)\n\nfunc TestCounter(t *testing.T) {\n\tcounter := newTreeTopicViewCounter()\n\tcounter.Bump(1)\n\tcounter.Bump(57)\n\tcounter.Bump(58)\n\tcounter.Bump(59)\n\tcounter.Bump(9)\n}\n\nfunc TestScope(t *testing.T) {\n\tvar outVar int\n\tclosureHolder := func() {\n\t\toutVar = 2\n\t}\n\tclosureHolder()\n\tlog.Print(\"outVar: \", outVar)\n}\n"
  },
  {
    "path": "experimental/module_lua.go",
    "content": "/* Copyright Azareal 2016 - 2017 */\npackage main\n\n"
  },
  {
    "path": "experimental/module_v8js.go",
    "content": "/* Copyright Azareal 2016 - 2017 */\npackage main\n\n"
  },
  {
    "path": "experimental/new-replybit.html",
    "content": "<div class=\"rowitem passive deletable_block editable_parent post_item\" style=\"background-color: #eaeaea;padding-top: 3px;padding-left: 4px;clear: both;border-bottom: solid 1px #ccc;padding-right: 3px;padding-bottom: 6px;\">\n\t<div class=\"userinfo\" style=\"background: white;width: 132px;padding: 2px;margin-top: 2px;float: left;\">\n\t\t<div class=\"avatar_item\" style=\"background-image: url(/uploads/avatar_1.jpg), url(/s/white-dot.jpg);background-position:0px -10px;background-repeat:no-repeat, repeat-y;background-size:128px;width:128px;height:100%;min-height: 128px;border-style:solid;border-color:#eaeaea;border-width:1px;\">&nbsp;</div>\n\t\t<div class=\"the_name\" style=\"margin-top: 3px;text-align: center;color: #505050;\">Azareal</div>\n\t</div>\n\t<div class=\"content_container\" style=\"background:white;margin-left:137px;min-height:128px;margin-bottom:0;margin-right:3px;\">\n\t\t<div class=\"editable_block user_content\" style=\"padding: 5px;margin-top: 3px;margin-bottom: 0;background: white;min-height: 133px;padding-bottom: 0;width: 100%;\">boo</div>\n\t\t<div class=\"button_container\" style=\"border-top: solid 1px #eaeaea;border-spacing: 0px;border-collapse: collapse;padding: 0;margin: 0;display: block;\">\n\t\t\t<a style=\"border-right: solid 1px #eaeaea;color: #505050;font-size: 13px;padding-left: 5px;padding-right: 5px;\">Edit</a>\n\t\t\t<a style=\"border-right: solid 1px #eaeaea;color: #505050;font-size: 13px;padding-left: 0;padding-right: 5px;\">Delete</a>\n\t\t\t<a style=\"border: none;border-right: solid 1px #eaeaea;padding-right: 6px;color: #505050;font-size: 13px;\">Report</a>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "experimental/new-update.bat",
    "content": "@echo off\n\necho Updating the dependencies\ngo get\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the updater\ngo generate\ngo build -ldflags=\"-s -w\" ./updater\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\nupdater.exe\n"
  },
  {
    "path": "experimental/plugin_geoip.go",
    "content": "package main\n\nimport c \"github.com/Azareal/Gosora/common\"\nimport \"github.com/oschwald/geoip2-golang\"\n\nvar geoipDB *geoip.DB\nvar geoipDBLocation = \"geoip_db.mmdb\"\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"geoip\", Name: \"Geoip\", Author: \"Azareal\", Init: initGeoip, Deactivate: deactivateGeoip})\n}\n\nfunc initGeoip(plugin *c.Plugin) (err error) {\n\tgeoipDB, err = geoip2.Open(geoipDBLocation)\n\treturn err\n}\n\nfunc deactivateGeoip(plugin *c.Plugin) {\n\tgeoipDB.Close()\n}\n"
  },
  {
    "path": "experimental/plugin_sendmail.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os/exec\"\n\t\"runtime\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\n/*\n\tSending emails in a way you really shouldn't be sending them.\n\tThis method doesn't require a SMTP server, but has higher chances of an email being rejected or being seen as spam. Use at your own risk. Only for Linux as Windows doesn't have Sendmail.\n*/\nfunc init() {\n\t// Don't bother registering this plugin on platforms other than Linux\n\tif runtime.GOOS != \"linux\" {\n\t\treturn\n\t}\n\tc.Plugins.Add(&c.Plugin{UName: \"sendmail\", Name: \"Sendmail\", Author: \"Azareal\", URL: \"http://github.com/Azareal\", Tag: \"Linux Only\", Init: initSendmail, Activate: activateSendmail, Deactivate: deactivateSendmail})\n}\n\nfunc initSendmail(plugin *c.Plugin) error {\n\tplugin.AddHook(\"email_send_intercept\", sendSendmail)\n\treturn nil\n}\n\n// /usr/sbin/sendmail is only available on Linux\nfunc activateSendmail(plugin *c.Plugin) error {\n\tif !c.Site.EnableEmails {\n\t\treturn errors.New(\"You have emails disabled in your configuration file\")\n\t}\n\tif runtime.GOOS != \"linux\" {\n\t\treturn errors.New(\"This plugin only supports Linux\")\n\t}\n\treturn nil\n}\n\nfunc deactivateSendmail(plugin *c.Plugin) {\n\tplugin.RemoveHook(\"email_send_intercept\", sendSendmail)\n}\n\nfunc sendSendmail(data ...interface{}) interface{} {\n\tto := data[0].(string)\n\tsubject := data[1].(string)\n\tbody := data[2].(string)\n\n\tmsg := \"From: \" + c.Site.Email + \"\\n\"\n\tmsg += \"To: \" + to + \"\\n\"\n\tmsg += \"Subject: \" + subject + \"\\n\\n\"\n\tmsg += body + \"\\n\"\n\n\tsendmail := exec.Command(\"/usr/sbin/sendmail\", \"-t\", \"-i\")\n\tstdin, err := sendmail.StdinPipe()\n\tif err != nil {\n\t\treturn err // Possibly disable the plugin and show an error to the admin on the dashboard? Plugin log file?\n\t}\n\n\terr = sendmail.Start()\n\tif err != nil {\n\t\treturn err\n\t}\n\tio.WriteString(stdin, msg)\n\n\terr = stdin.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sendmail.Wait()\n}\n"
  },
  {
    "path": "experimental/theme-ext.json",
    "content": "{\n\t\"Name\": \"tempra-simple\",\n\t\"FriendlyName\": \"Tempra Simple\",\n\t\"Version\": \"0.0.1\",\n\t\"Creator\": \"Azareal\",\n\t\"Settings\": {\n\t\t\"PostLayout\": {\n\t\t\t\"FriendlyName\":\"Post Layout\",\n\t\t\t\"Options\": [\"Compact\",\"Alternate\"]\n\t\t}\n\t},\n\t\"Templates\": [\n\t\t{\n\t\t\t\"Name\": \"topic\",\n\t\t\t\"Source\": \"topic_alt\",\n\t\t\t\"When\": \"PostLayout=Alternate\"\n\t\t}\n\t]\n}"
  },
  {
    "path": "experimental/theme-ext.xml",
    "content": "<?xml version=\"1.0\" ?>\n<theme>\n\t<name>tempra-simple</name>\n\t<friendlyName>Tempra Simple</friendlyName>\n\t<version>0.0.1</version>\n\t<creator url=\"http://github.com/Azareal\">Azareal</creator>\n\t<settings>\n\t\t<name>PostLayout</name>\n\t\t<friendlyName>Post Layout</name>\n\t\t<options>\n\t\t\t<option>Compact</option>\n\t\t\t<option>Alternate</option>\n\t\t</options>\n\t</settings>\n\t<templates>\n\t\t<template name=\"topic\" src=\"topic_alt\" when=\"PostLayout=Alternate\"></template>\n\t</templates>\n</theme>"
  },
  {
    "path": "extend/adventure/lib/adventure.go",
    "content": "package adventure\n\n// We're experimenting with struct tags here atm\ntype Adventure struct {\n\tID        int    `schema:\"name=aid;primary;auto\"`\n\tName      string `schema:\"name=name;type=short_text\"`\n\tDesc      string `schema:\"name=desc;type=text\"`\n\tCreatedBy int    `schema:\"name=createdBy\"`\n\t//CreatedBy int `schema:\"name=createdBy;relatesTo=users.uid\"`\n}\n\n// TODO: Should we add a table interface?\nfunc (a *Adventure) GetTable() string {\n\treturn \"adventure\"\n}\n"
  },
  {
    "path": "extend/adventure/lib/adventure_store.go",
    "content": "package adventure\n\ntype AdventureStore interface {\n\tCreate() (int, error)\n}\n\ntype DefaultAdventureStore struct {\n}\n"
  },
  {
    "path": "extend/adventure/plugin.json",
    "content": "{\n\t\"UName\":\"adventure\",\n\t\"Name\":\"Adventure\",\n\t\"Author\":\"Azareal\",\n\t\"URL\":\"https://github.com/Azareal/Gosora\",\n\t\"Skip\":true\n}"
  },
  {
    "path": "extend/adventure/prebuild/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "extend/filler.go",
    "content": "package extend"
  },
  {
    "path": "extend/guilds/lib/guild_store.go",
    "content": "package guilds\n\nimport (\n\t\"database/sql\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar Gstore GuildStore\n\ntype GuildStore interface {\n\tGet(id int) (g *Guild, err error)\n\tCreate(name, desc string, active bool, privacy, uid, fid int) (int, error)\n}\n\ntype SQLGuildStore struct {\n\tget    *sql.Stmt\n\tcreate *sql.Stmt\n}\n\nfunc NewSQLGuildStore() (*SQLGuildStore, error) {\n\tacc := qgen.NewAcc()\n\treturn &SQLGuildStore{\n\t\tget:    acc.Select(\"guilds\").Columns(\"name, desc, active, privacy, joinable, owner, memberCount, mainForum, backdrop, createdAt, lastUpdateTime\").Where(\"guildID=?\").Prepare(),\n\t\tcreate: acc.Insert(\"guilds\").Columns(\"name, desc, active, privacy, joinable, owner, memberCount, mainForum, backdrop, createdAt, lastUpdateTime\").Fields(\"?,?,?,?,1,?,1,?,'',UTC_TIMESTAMP(),UTC_TIMESTAMP()\").Prepare(),\n\t}, acc.FirstError()\n}\n\nfunc (s *SQLGuildStore) Close() {\n\t_ = s.get.Close()\n\t_ = s.create.Close()\n}\n\nfunc (s *SQLGuildStore) Get(id int) (g *Guild, err error) {\n\tg = &Guild{ID: id}\n\terr = s.get.QueryRow(id).Scan(&g.Name, &g.Desc, &g.Active, &g.Privacy, &g.Joinable, &g.Owner, &g.MemberCount, &g.MainForumID, &g.Backdrop, &g.CreatedAt, &g.LastUpdateTime)\n\treturn g, err\n}\n\nfunc (s *SQLGuildStore) Create(name, desc string, active bool, privacy, uid, fid int) (int, error) {\n\tres, err := s.create.Exec(name, desc, active, privacy, uid, fid)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n"
  },
  {
    "path": "extend/guilds/lib/guilds.go",
    "content": "package guilds // import \"github.com/Azareal/Gosora/extend/guilds/lib\"\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/routes\"\n)\n\n// A blank list to fill out that parameter in Page for routes which don't use it\nvar tList []interface{}\n\nvar ListStmt *sql.Stmt\nvar MemberListStmt *sql.Stmt\nvar MemberListJoinStmt *sql.Stmt\nvar GetMemberStmt *sql.Stmt\nvar AttachForumStmt *sql.Stmt\nvar UnattachForumStmt *sql.Stmt\nvar AddMemberStmt *sql.Stmt\n\n// Guild is a struct representing a guild\ntype Guild struct {\n\tID      int\n\tLink    string\n\tName    string\n\tDesc    string\n\tActive  bool\n\tPrivacy int /* 0: Public, 1: Protected, 2: Private */\n\n\t// Who should be able to accept applications and create invites? Mods+ or just admins? Mods is a good start, we can ponder over whether we should make this more flexible in the future.\n\tJoinable int /* 0: Private, 1: Anyone can join, 2: Applications, 3: Invite-only */\n\n\tMemberCount    int\n\tOwner          int\n\tBackdrop       string\n\tCreatedAt      string\n\tLastUpdateTime string\n\n\tMainForumID int\n\tMainForum   *c.Forum\n\tForums      []*c.Forum\n\tExtData     c.ExtData\n}\n\ntype Page struct {\n\tTitle    string\n\tHeader   *c.Header\n\tItemList []*c.TopicsRow\n\tForum    *c.Forum\n\tGuild    *Guild\n\tPage     int\n\tLastPage int\n}\n\n// ListPage is a page struct for constructing a list of every guild\ntype ListPage struct {\n\tTitle     string\n\tHeader    *c.Header\n\tGuildList []*Guild\n}\n\ntype MemberListPage struct {\n\tTitle    string\n\tHeader   *c.Header\n\tItemList []Member\n\tGuild    *Guild\n\tPage     int\n\tLastPage int\n}\n\n// Member is a struct representing a specific member of a guild, not to be confused with the global User struct.\ntype Member struct {\n\tLink       string\n\tRank       int    /* 0: Member. 1: Mod. 2: Admin. */\n\tRankString string /* Member, Mod, Admin, Owner */\n\tPostCount  int\n\tJoinedAt   string\n\tOffline    bool // TODO: Need to track the online states of members when WebSockets are enabled\n\n\tUser c.User\n}\n\nfunc PrebuildTmplList(user *c.User, h *c.Header) c.CTmpl {\n\tguildList := []*Guild{\n\t\t&Guild{\n\t\t\tID:             1,\n\t\t\tName:           \"lol\",\n\t\t\tLink:           BuildGuildURL(c.NameToSlug(\"lol\"), 1),\n\t\t\tDesc:           \"A group for people who like to laugh\",\n\t\t\tActive:         true,\n\t\t\tMemberCount:    1,\n\t\t\tOwner:          1,\n\t\t\tCreatedAt:      \"date\",\n\t\t\tLastUpdateTime: \"date\",\n\t\t\tMainForumID:    1,\n\t\t\tMainForum:      c.Forums.DirtyGet(1),\n\t\t\tForums:         []*c.Forum{c.Forums.DirtyGet(1)},\n\t\t},\n\t}\n\tlistPage := ListPage{\"Guild List\", user, h, guildList}\n\treturn c.CTmpl{\"guilds_guild_list\", \"guilds_guild_list.html\", \"templates/\", \"guilds.ListPage\", listPage, []string{\"./extend/guilds/lib\"}}\n}\n\n// TODO: Do this properly via the widget system\n// TODO: REWRITE THIS\nfunc CommonAreaWidgets(header *c.Header) {\n\t// TODO: Hot Groups? Featured Groups? Official Groups?\n\tvar b bytes.Buffer\n\tmenu := c.WidgetMenu{\"Guilds\", []c.WidgetMenuItem{\n\t\tc.WidgetMenuItem{\"Create Guild\", \"/guild/create/\", false},\n\t}}\n\n\terr := header.Theme.RunTmpl(\"widget_menu\", pi, w)\n\tif err != nil {\n\t\tc.LogError(err)\n\t\treturn\n\t}\n\n\tif header.Theme.HasDock(\"leftSidebar\") {\n\t\theader.Widgets.LeftSidebar = template.HTML(string(b.Bytes()))\n\t} else if header.Theme.HasDock(\"rightSidebar\") {\n\t\theader.Widgets.RightSidebar = template.HTML(string(b.Bytes()))\n\t}\n}\n\n// TODO: Do this properly via the widget system\n// TODO: Make a better more customisable group widget system\nfunc GuildWidgets(header *c.Header, guildItem *Guild) (success bool) {\n\treturn false // Disabled until the next commit\n\n\t/*var b bytes.Buffer\n\tvar menu WidgetMenu = WidgetMenu{\"Guild Options\", []WidgetMenuItem{\n\t\tWidgetMenuItem{\"Join\", \"/guild/join/\" + strconv.Itoa(guildItem.ID), false},\n\t\tWidgetMenuItem{\"Members\", \"/guild/members/\" + strconv.Itoa(guildItem.ID), false},\n\t}}\n\n\terr := templates.ExecuteTemplate(&b, \"widget_menu.html\", menu)\n\tif err != nil {\n\t\tc.LogError(err)\n\t\treturn false\n\t}\n\n\tif themes[header.Theme.Name].Sidebars == \"left\" {\n\t\theader.Widgets.LeftSidebar = template.HTML(string(b.Bytes()))\n\t} else if themes[header.Theme.Name].Sidebars == \"right\" || themes[header.Theme.Name].Sidebars == \"both\" {\n\t\theader.Widgets.RightSidebar = template.HTML(string(b.Bytes()))\n\t} else {\n\t\treturn false\n\t}\n\treturn true*/\n}\n\n/*\n\tCustom Pages\n*/\n\nfunc RouteGuildList(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\th, ferr := c.UserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tCommonAreaWidgets(h)\n\n\trows, err := ListStmt.Query()\n\tif err != nil && err != c.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tdefer rows.Close()\n\n\tvar guildList []*Guild\n\tfor rows.Next() {\n\t\tg := &Guild{ID: 0}\n\t\terr := rows.Scan(&g.ID, &g.Name, &g.Desc, &g.Active, &g.Privacy, &g.Joinable, &g.Owner, &g.MemberCount, &g.CreatedAt, &g.LastUpdateTime)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tg.Link = BuildGuildURL(c.NameToSlug(g.Name), g.ID)\n\t\tguildList = append(guildList, g)\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpi := ListPage{\"Guild List\", user, h, guildList}\n\treturn routes.RenderTemplate(\"guilds_guild_list\", w, r, h, pi)\n}\n\nfunc MiddleViewGuild(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t_, guildID, err := routes.ParseSEOURL(r.URL.Path[len(\"/guild/\"):])\n\tif err != nil {\n\t\treturn c.PreError(\"Not a valid guild ID\", w, r)\n\t}\n\n\tguildItem, err := Gstore.Get(guildID)\n\tif err != nil {\n\t\treturn c.LocalError(\"Bad guild\", w, r, user)\n\t}\n\t// TODO: Build and pass header\n\tif !guildItem.Active {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\n\treturn nil\n\n\t// TODO: Re-implement this\n\t// Re-route the request to routeForums\n\t//var ctx = context.WithValue(r.Context(), \"guilds_current_guild\", guildItem)\n\t//return routeForum(w, r.WithContext(ctx), user, strconv.Itoa(guildItem.MainForumID))\n}\n\nfunc RouteCreateGuild(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\th, ferr := c.UserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\th.Title = \"Create Guild\"\n\t// TODO: Add an approval queue mode for group creation\n\tif !user.Loggedin || !user.PluginPerms[\"CreateGuild\"] {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tCommonAreaWidgets(h)\n\n\treturn routes.RenderTemplate(\"guilds_create_guild\", w, r, h, c.Page{h, tList, nil})\n}\n\nfunc RouteCreateGuildSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t// TODO: Add an approval queue mode for group creation\n\tif !user.Loggedin || !user.PluginPerms[\"CreateGuild\"] {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tguildActive := true\n\tguildName := c.SanitiseSingleLine(r.PostFormValue(\"group_name\"))\n\t// TODO: Allow Markdown / BBCode / Limited HTML in the description?\n\tguildDesc := c.SanitiseBody(r.PostFormValue(\"group_desc\"))\n\n\tvar guildPrivacy int\n\tswitch r.PostFormValue(\"group_privacy\") {\n\tcase \"0\":\n\t\tguildPrivacy = 0 // Public\n\tcase \"1\":\n\t\tguildPrivacy = 1 // Protected\n\tcase \"2\":\n\t\tguildPrivacy = 2 // private\n\tdefault:\n\t\tguildPrivacy = 0\n\t}\n\n\t// Create the backing forum\n\tfid, err := c.Forums.Create(guildName, \"\", true, \"\")\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tgid, err := Gstore.Create(guildName, guildDesc, guildActive, guildPrivacy, user.ID, fid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// Add the main backing forum to the forum list\n\terr = AttachForum(gid, fid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t_, err = AddMemberStmt.Exec(gid, user.ID, 2)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, BuildGuildURL(c.NameToSlug(guildName), gid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc RouteMemberList(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\theader, ferr := c.UserCheck(w, r, &user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\t_, guildID, err := routes.ParseSEOURL(r.URL.Path[len(\"/guild/members/\"):])\n\tif err != nil {\n\t\treturn c.PreError(\"Not a valid group ID\", w, r)\n\t}\n\n\tguild, err := Gstore.Get(guildID)\n\tif err != nil {\n\t\treturn c.LocalError(\"Bad group\", w, r, user)\n\t}\n\tguild.Link = BuildGuildURL(c.NameToSlug(guild.Name), guild.ID)\n\n\tGuildWidgets(header, guild)\n\n\trows, err := MemberListJoinStmt.Query(guildID)\n\tif err != nil && err != c.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar guildMembers []Member\n\tfor rows.Next() {\n\t\tgMember := Member{PostCount: 0}\n\t\terr := rows.Scan(&gMember.User.ID, &gMember.Rank, &gMember.PostCount, &gMember.JoinedAt, &gMember.User.Name, &gMember.User.RawAvatar)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tgMember.Link = c.BuildProfileURL(c.NameToSlug(gMember.User.Name), gMember.User.ID)\n\t\tgMember.User.Avatar, gMember.User.MicroAvatar = c.BuildAvatar(gMember.User.ID, gMember.User.RawAvatar)\n\t\tgMember.JoinedAt, _ = c.RelativeTimeFromString(gMember.JoinedAt)\n\t\tif guild.Owner == gMember.User.ID {\n\t\t\tgMember.RankString = \"Owner\"\n\t\t} else {\n\t\t\tswitch gMember.Rank {\n\t\t\tcase 0:\n\t\t\t\tgMember.RankString = \"Member\"\n\t\t\tcase 1:\n\t\t\t\tgMember.RankString = \"Mod\"\n\t\t\tcase 2:\n\t\t\t\tgMember.RankString = \"Admin\"\n\t\t\t}\n\t\t}\n\t\tguildMembers = append(guildMembers, gMember)\n\t}\n\tif err = rows.Err(); err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\trows.Close()\n\n\tpi := MemberListPage{\"Guild Member List\", user, header, gMembers, guild, 0, 0}\n\t// A plugin with plugins. Pluginception!\n\tif c.RunPreRenderHook(\"pre_render_guilds_member_list\", w, r, user, &pi) {\n\t\treturn nil\n\t}\n\terr = c.RunThemeTemplate(header.Theme.Name, \"guilds_member_list\", pi, w)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn nil\n}\n\nfunc AttachForum(guildID, fid int) error {\n\t_, err := AttachForumStmt.Exec(guildID, fid)\n\treturn err\n}\n\nfunc UnattachForum(fid int) error {\n\t_, err := AttachForumStmt.Exec(fid)\n\treturn err\n}\n\nfunc BuildGuildURL(slug string, id int) string {\n\tif slug == \"\" || !c.Config.BuildSlugs {\n\t\treturn \"/guild/\" + strconv.Itoa(id)\n\t}\n\treturn \"/guild/\" + slug + \".\" + strconv.Itoa(id)\n}\n\n/*\n\tHooks\n*/\n\n// TODO: Prebuild this template\nfunc PreRenderViewForum(w http.ResponseWriter, r *http.Request, user *c.User, data interface{}) (halt bool) {\n\tpi := data.(*c.ForumPage)\n\tif pi.Header.ExtData.Items != nil {\n\t\tif guildData, ok := pi.Header.ExtData.Items[\"guilds_current_group\"]; ok {\n\t\t\tguildItem := guildData.(*Guild)\n\n\t\t\tguildpi := Page{pi.Title, pi.Header, pi.ItemList, pi.Forum, guildItem, pi.Page, pi.LastPage}\n\t\t\terr := routes.RenderTemplate(\"guilds_view_guild\", w, r, pi.Header, guildpi)\n\t\t\tif err != nil {\n\t\t\t\tc.LogError(err)\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TrowAssign(args ...interface{}) interface{} {\n\tvar forum = args[1].(*c.Forum)\n\tif forum.ParentType == \"guild\" {\n\t\tvar topicItem = args[0].(*c.TopicsRow)\n\t\ttopicItem.ForumLink = \"/guild/\" + strings.TrimPrefix(topicItem.ForumLink, c.GetForumURLPrefix())\n\t}\n\treturn nil\n}\n\n// TODO: It would be nice, if you could select one of the boards in the group from that drop-down rather than just the one you got linked from\nfunc TopicCreatePreLoop(args ...interface{}) interface{} {\n\tvar fid = args[2].(int)\n\tif c.Forums.DirtyGet(fid).ParentType == \"guild\" {\n\t\tvar strictmode = args[5].(*bool)\n\t\t*strictmode = true\n\t}\n\treturn nil\n}\n\n// TODO: Add privacy options\n// TODO: Add support for multiple boards and add per-board simplified permissions\n// TODO: Take js into account for routes which expect JSON responses\nfunc ForumCheck(args ...interface{}) (skip bool, rerr c.RouteError) {\n\tr := args[1].(*http.Request)\n\tfid := args[3].(*int)\n\tforum := c.Forums.DirtyGet(*fid)\n\n\tif forum.ParentType == \"guild\" {\n\t\tvar err error\n\t\tw := args[0].(http.ResponseWriter)\n\t\tguildItem, ok := r.Context().Value(\"guilds_current_group\").(*Guild)\n\t\tif !ok {\n\t\t\tguildItem, err = Gstore.Get(forum.ParentID)\n\t\t\tif err != nil {\n\t\t\t\treturn true, c.InternalError(errors.New(\"Unable to find the parent group for a forum\"), w, r)\n\t\t\t}\n\t\t\tif !guildItem.Active {\n\t\t\t\treturn true, c.NotFound(w, r, nil) // TODO: Can we pull header out of args?\n\t\t\t}\n\t\t\tr = r.WithContext(context.WithValue(r.Context(), \"guilds_current_group\", guildItem))\n\t\t}\n\n\t\tuser := args[2].(*c.User)\n\t\tvar rank, posts int\n\t\tvar joinedAt string\n\n\t\t// TODO: Group privacy settings. For now, groups are all globally visible\n\n\t\t// Clear the default group permissions\n\t\t// TODO: Do this more efficiently, doing it quick and dirty for now to get this out quickly\n\t\tc.OverrideForumPerms(&user.Perms, false)\n\t\tuser.Perms.ViewTopic = true\n\n\t\terr = GetMemberStmt.QueryRow(guildItem.ID, user.ID).Scan(&rank, &posts, &joinedAt)\n\t\tif err != nil && err != c.ErrNoRows {\n\t\t\treturn true, c.InternalError(err, w, r)\n\t\t} else if err != nil {\n\t\t\t// TODO: Should we let admins / guests into public groups?\n\t\t\treturn true, c.LocalError(\"You're not part of this group!\", w, r, user)\n\t\t}\n\n\t\t// TODO: Implement bans properly by adding the Local Ban API in the next commit\n\t\t// TODO: How does this even work? Refactor it along with the rest of this plugin!\n\t\tif rank < 0 {\n\t\t\treturn true, c.LocalError(\"You've been banned from this group!\", w, r, user)\n\t\t}\n\n\t\t// Basic permissions for members, more complicated permissions coming in the next commit!\n\t\tif guildItem.Owner == user.ID {\n\t\t\tc.OverrideForumPerms(&user.Perms, true)\n\t\t} else if rank == 0 {\n\t\t\tuser.Perms.LikeItem = true\n\t\t\tuser.Perms.CreateTopic = true\n\t\t\tuser.Perms.CreateReply = true\n\t\t} else {\n\t\t\tc.OverrideForumPerms(&user.Perms, true)\n\t\t}\n\t\treturn true, nil\n\t}\n\n\treturn false, nil\n}\n\n// TODO: Override redirects? I don't think this is needed quite yet\n\nfunc Widgets(args ...interface{}) interface{} {\n\tzone := args[0].(string)\n\th := args[2].(*c.Header)\n\trequest := args[3].(*http.Request)\n\tif zone != \"view_forum\" {\n\t\treturn false\n\t}\n\n\tf := args[1].(*c.Forum)\n\tif f.ParentType == \"guild\" {\n\t\t// This is why I hate using contexts, all the daisy chains and interface casts x.x\n\t\tguild, ok := request.Context().Value(\"guilds_current_group\").(*Guild)\n\t\tif !ok {\n\t\t\tc.LogError(errors.New(\"Unable to find a parent group in the context data\"))\n\t\t\treturn false\n\t\t}\n\n\t\tif h.ExtData.Items == nil {\n\t\t\th.ExtData.Items = make(map[string]interface{})\n\t\t}\n\t\th.ExtData.Items[\"guilds_current_group\"] = guild\n\n\t\treturn GuildWidgets(h, guild)\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "extend/guilds/plugin.json",
    "content": "{\n\t\"UName\":\"guilds\",\n\t\"Name\":\"Guilds\",\n\t\"Author\":\"Azareal\",\n\t\"URL\":\"https://github.com/Azareal/Gosora\",\n\t\"Skip\":true\n}"
  },
  {
    "path": "extend/guilds/plugin_guilds.go",
    "content": "package main\n\nimport (\n\tc \"github.com/Azareal/Gosora/common\"\n\tguilds \"github.com/Azareal/Gosora/extend/guilds/lib\"\n)\n\n// TODO: Add a better way of splitting up giant plugins like this\n\n// TODO: Add a plugin interface instead of having a bunch of argument to AddPlugin?\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"guilds\", Name: \"Guilds\", Author: \"Azareal\", URL: \"https://github.com/Azareal\", Init: initGuilds, Deactivate: deactivateGuilds, Install: installGuilds})\n\n\t// TODO: Is it possible to avoid doing this when the plugin isn't activated?\n\tc.PrebuildTmplList = append(c.PrebuildTmplList, guilds.PrebuildTmplList)\n}\n\nfunc initGuilds(pl *c.Plugin) (err error) {\n\tpl.AddHook(\"intercept_build_widgets\", guilds.Widgets)\n\tpl.AddHook(\"trow_assign\", guilds.TrowAssign)\n\tpl.AddHook(\"topic_create_pre_loop\", guilds.TopicCreatePreLoop)\n\tpl.AddHook(\"pre_render_forum\", guilds.PreRenderViewForum)\n\tpl.AddHook(\"simple_forum_check_pre_perms\", guilds.ForumCheck)\n\tpl.AddHook(\"forum_check_pre_perms\", guilds.ForumCheck)\n\t// TODO: Auto-grant this perm to admins upon installation?\n\tc.RegisterPluginPerm(\"CreateGuild\")\n\trouter.HandleFunc(\"/guilds/\", guilds.RouteGuildList)\n\trouter.HandleFunc(\"/guild/\", guilds.MiddleViewGuild)\n\trouter.HandleFunc(\"/guild/create/\", guilds.RouteCreateGuild)\n\trouter.HandleFunc(\"/guild/create/submit/\", guilds.RouteCreateGuildSubmit)\n\trouter.HandleFunc(\"/guild/members/\", guilds.RouteMemberList)\n\n\tguilds.Gstore, err = guilds.NewSQLGuildStore()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tacc := qgen.NewAcc()\n\n\tguilds.ListStmt = acc.Select(\"guilds\").Columns(\"guildID, name, desc, active, privacy, joinable, owner, memberCount, createdAt, lastUpdateTime\").Prepare()\n\n\tguilds.MemberListStmt = acc.Select(\"guilds_members\").Columns(\"guildID, uid, rank, posts, joinedAt\").Prepare()\n\n\tguilds.MemberListJoinStmt = acc.SimpleLeftJoin(\"guilds_members\", \"users\", \"users.uid, guilds_members.rank, guilds_members.posts, guilds_members.joinedAt, users.name, users.avatar\", \"guilds_members.uid = users.uid\", \"guilds_members.guildID = ?\", \"guilds_members.rank DESC, guilds_members.joinedat ASC\", \"\")\n\n\tguilds.GetMemberStmt = acc.Select(\"guilds_members\").Columns(\"rank, posts, joinedAt\").Where(\"guildID = ? AND uid = ?\").Prepare()\n\n\tguilds.AttachForumStmt = acc.Update(\"forums\").Set(\"parentID = ?, parentType = 'guild'\").Where(\"fid = ?\").Prepare()\n\n\tguilds.UnattachForumStmt = acc.Update(\"forums\").Set(\"parentID = 0, parentType = ''\").Where(\"fid = ?\").Prepare()\n\n\tguilds.AddMemberStmt = acc.Insert(\"guilds_members\").Columns(\"guildID, uid, rank, posts, joinedAt\").Fields(\"?,?,?,0,UTC_TIMESTAMP()\").Prepare()\n\n\treturn acc.FirstError()\n}\n\nfunc deactivateGuilds(pl *c.Plugin) {\n\tpl.RemoveHook(\"intercept_build_widgets\", guilds.Widgets)\n\tpl.RemoveHook(\"trow_assign\", guilds.TrowAssign)\n\tpl.RemoveHook(\"topic_create_pre_loop\", guilds.TopicCreatePreLoop)\n\tpl.RemoveHook(\"pre_render_forum\", guilds.PreRenderViewForum)\n\tpl.RemoveHook(\"simple_forum_check_pre_perms\", guilds.ForumCheck)\n\tpl.RemoveHook(\"forum_check_pre_perms\", guilds.ForumCheck)\n\tc.DeregisterPluginPerm(\"CreateGuild\")\n\t_ = router.RemoveFunc(\"/guilds/\")\n\t_ = router.RemoveFunc(\"/guild/\")\n\t_ = router.RemoveFunc(\"/guild/create/\")\n\t_ = router.RemoveFunc(\"/guild/create/submit/\")\n\t_ = guilds.ListStmt.Close()\n\t_ = guilds.MemberListStmt.Close()\n\t_ = guilds.MemberListJoinStmt.Close()\n\t_ = guilds.GetMemberStmt.Close()\n\t_ = guilds.AttachForumStmt.Close()\n\t_ = guilds.UnattachForumStmt.Close()\n\t_ = guilds.AddMemberStmt.Close()\n}\n\n// TODO: Stop accessing the query builder directly and add a feature in Gosora which is more easily reversed, if an error comes up during the installation process\ntype tC = qgen.DBTableColumn\n\nfunc installGuilds(plugin *c.Plugin) error {\n\tguildTableStmt, err := qgen.Builder.CreateTable(\"guilds\", \"utf8mb4\", \"utf8mb4_general_ci\",\n\t\t[]tC{\n\t\t\ttC{\"guildID\", \"int\", 0, false, true, \"\"},\n\t\t\ttC{\"name\", \"varchar\", 100, false, false, \"\"},\n\t\t\ttC{\"desc\", \"varchar\", 200, false, false, \"\"},\n\t\t\ttC{\"active\", \"boolean\", 1, false, false, \"\"},\n\t\t\ttC{\"privacy\", \"smallint\", 0, false, false, \"\"},\n\t\t\ttC{\"joinable\", \"smallint\", 0, false, false, \"0\"},\n\t\t\ttC{\"owner\", \"int\", 0, false, false, \"\"},\n\t\t\ttC{\"memberCount\", \"int\", 0, false, false, \"\"},\n\t\t\ttC{\"mainForum\", \"int\", 0, false, false, \"0\"}, // The board the user lands on when they click on a group, we'll make it possible for group admins to change what users land on\n\t\t\t//tC{\"boards\",\"varchar\",255,false,false,\"\"}, // Cap the max number of boards at 8 to avoid overflowing the confines of a 64-bit integer?\n\t\t\ttC{\"backdrop\", \"varchar\", 200, false, false, \"\"}, // File extension for the uploaded file, or an external link\n\t\t\ttC{\"createdAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t\ttC{\"lastUpdateTime\", \"datetime\", 0, false, false, \"\"},\n\t\t},\n\t\t[]qgen.DBTableKey{\n\t\t\tqgen.DBTableKey{\"guildID\", \"primary\"},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = guildTableStmt.Exec()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tguildMembersTableStmt, err := qgen.Builder.CreateTable(\"guilds_members\", \"\", \"\",\n\t\t[]tC{\n\t\t\ttC{\"guildID\", \"int\", 0, false, false, \"\"},\n\t\t\ttC{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\ttC{\"rank\", \"int\", 0, false, false, \"0\"},  /* 0: Member. 1: Mod. 2: Admin. */\n\t\t\ttC{\"posts\", \"int\", 0, false, false, \"0\"}, /* Per-Group post count. Should we do some sort of score system? */\n\t\t\ttC{\"joinedAt\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = guildMembersTableStmt.Exec()\n\treturn err\n}\n\n// TO-DO; Implement an uninstallation system into Gosora. And a better installation system.\nfunc uninstallGuilds(plugin *c.Plugin) error {\n\treturn nil\n}\n"
  },
  {
    "path": "extend/guilds/prebuild/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "extend/heytherejs/main.js",
    "content": "current_page.test = true;\n\n// This shouldn't ever fail\nvar errmsg = \"gotcha\";\nerrmsg;"
  },
  {
    "path": "extend/heytherejs/plugin.json",
    "content": "{\n\t\"UName\":\"heytherejs\",\n\t\"Name\":\"HeythereJS\",\n\t\"Author\":\"Azareal\",\n\t\"URL\":\"https://github.com/Azareal/Gosora\",\n\t\"Main\":\"main.js\"\n}"
  },
  {
    "path": "extend/plugin_adventure.go",
    "content": "// WIP - Experimental adventure plugin, this might find a new home soon, but it's here to stress test Gosora's extensibility for now\npackage extend\n\nimport c \"github.com/Azareal/Gosora/common\"\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{\n\t\tUName:      \"adventure\",\n\t\tName:       \"Adventure\",\n\t\tTag:        \"WIP\",\n\t\tAuthor:     \"Azareal\",\n\t\tURL:        \"https://github.com/Azareal\",\n\t\tInit:       initAdventure,\n\t\tDeactivate: deactivateAdventure,\n\t\tInstall:    installAdventure,\n\t})\n}\n\nfunc initAdventure(pl *c.Plugin) error {\n\treturn nil\n}\n\n// TODO: Change the signature to return an error?\nfunc deactivateAdventure(pl *c.Plugin) {\n}\n\nfunc installAdventure(pl *c.Plugin) error {\n\treturn nil\n}\n"
  },
  {
    "path": "extend/plugin_bbcode.go",
    "content": "package extend\n\nimport (\n\t\"bytes\"\n\t\"math/rand\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nvar bbcodeRandom *rand.Rand\nvar bbcodeInvalidNumber []byte\nvar bbcodeNoNegative []byte\nvar bbcodeMissingTag []byte\n\nvar bbcodeBold *regexp.Regexp\nvar bbcodeItalic *regexp.Regexp\nvar bbcodeUnderline *regexp.Regexp\nvar bbcodeStrike *regexp.Regexp\nvar bbcodeH1 *regexp.Regexp\nvar bbcodeURL *regexp.Regexp\nvar bbcodeURLLabel *regexp.Regexp\nvar bbcodeQuotes *regexp.Regexp\nvar bbcodeCode *regexp.Regexp\nvar bbcodeSpoiler *regexp.Regexp\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"bbcode\", Name: \"BBCode\", Author: \"Azareal\", URL: \"https://github.com/Azareal\", Init: InitBbcode, Deactivate: deactivateBbcode})\n}\n\nfunc InitBbcode(pl *c.Plugin) error {\n\tbbcodeInvalidNumber = []byte(\"<red>[Invalid Number]</red>\")\n\tbbcodeNoNegative = []byte(\"<red>[No Negative Numbers]</red>\")\n\tbbcodeMissingTag = []byte(\"<red>[Missing Tag]</red>\")\n\n\tbbcodeBold = regexp.MustCompile(`(?s)\\[b\\](.*)\\[/b\\]`)\n\tbbcodeItalic = regexp.MustCompile(`(?s)\\[i\\](.*)\\[/i\\]`)\n\tbbcodeUnderline = regexp.MustCompile(`(?s)\\[u\\](.*)\\[/u\\]`)\n\tbbcodeStrike = regexp.MustCompile(`(?s)\\[s\\](.*)\\[/s\\]`)\n\tbbcodeH1 = regexp.MustCompile(`(?s)\\[h1\\](.*)\\[/h1\\]`)\n\turlpattern := `(http|https|ftp|mailto*)(:??)\\/\\/([\\.a-zA-Z\\/]+)`\n\tbbcodeURL = regexp.MustCompile(`\\[url\\]` + urlpattern + `\\[/url\\]`)\n\tbbcodeURLLabel = regexp.MustCompile(`(?s)\\[url=` + urlpattern + `\\](.*)\\[/url\\]`)\n\tbbcodeQuotes = regexp.MustCompile(`\\[quote\\](.*)\\[/quote\\]`)\n\tbbcodeCode = regexp.MustCompile(`\\[code\\](.*)\\[/code\\]`)\n\tbbcodeSpoiler = regexp.MustCompile(`\\[spoiler\\](.*)\\[/spoiler\\]`)\n\n\tbbcodeRandom = rand.New(rand.NewSource(time.Now().UnixNano()))\n\n\tpl.AddHook(\"parse_assign\", BbcodeFullParse)\n\tpl.AddHook(\"topic_ogdesc_assign\", BbcodeStripTags)\n\treturn nil\n}\n\nfunc deactivateBbcode(pl *c.Plugin) {\n\tpl.RemoveHook(\"parse_assign\", BbcodeFullParse)\n\tpl.RemoveHook(\"topic_ogdesc_assign\", BbcodeStripTags)\n}\n\nfunc BbcodeStripTags(msg string) string {\n\tmsg = bbcodeBold.ReplaceAllString(msg, \"$1\")\n\tmsg = bbcodeItalic.ReplaceAllString(msg, \"$1\")\n\tmsg = bbcodeUnderline.ReplaceAllString(msg, \"$1\")\n\tmsg = bbcodeStrike.ReplaceAllString(msg, \"$1\")\n\treturn msg\n}\n\nfunc BbcodeRegexParse(msg string) string {\n\tmsg = bbcodeBold.ReplaceAllString(msg, \"<b>$1</b>\")\n\tmsg = bbcodeItalic.ReplaceAllString(msg, \"<i>$1</i>\")\n\tmsg = bbcodeUnderline.ReplaceAllString(msg, \"<u>$1</u>\")\n\tmsg = bbcodeStrike.ReplaceAllString(msg, \"<s>$1</s>\")\n\tmsg = bbcodeURL.ReplaceAllString(msg, \"<a href=''$1$2//$3' rel='ugc'>$1$2//$3</i>\")\n\tmsg = bbcodeURLLabel.ReplaceAllString(msg, \"<a href=''$1$2//$3' rel='ugc'>$4</i>\")\n\tmsg = bbcodeQuotes.ReplaceAllString(msg, \"<blockquote>$1</blockquote>\")\n\tmsg = bbcodeSpoiler.ReplaceAllString(msg, \"<spoiler>$1</spoiler>\")\n\tmsg = bbcodeH1.ReplaceAllString(msg, \"<h2>$1</h2>\")\n\t//msg = bbcodeCode.ReplaceAllString(msg,\"<span class='codequotes'>$1</span>\")\n\treturn msg\n}\n\n// Only does the simple BBCode like [u], [b], [i] and [s]\nfunc bbcodeSimpleParse(msg string) string {\n\tvar hasU, hasB, hasI, hasS bool\n\tmbytes := []byte(msg)\n\tfor i := 0; (i + 2) < len(mbytes); i++ {\n\t\tif mbytes[i] == '[' && mbytes[i+2] == ']' {\n\t\t\tch := mbytes[i+1]\n\t\t\tif ch == 'b' && !hasB {\n\t\t\t\tmbytes[i] = '<'\n\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\thasB = true\n\t\t\t} else if ch == 'i' && !hasI {\n\t\t\t\tmbytes[i] = '<'\n\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\thasI = true\n\t\t\t} else if ch == 'u' && !hasU {\n\t\t\t\tmbytes[i] = '<'\n\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\thasU = true\n\t\t\t} else if ch == 's' && !hasS {\n\t\t\t\tmbytes[i] = '<'\n\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\thasS = true\n\t\t\t}\n\t\t\ti += 2\n\t\t}\n\t}\n\n\t// There's an unclosed tag in there x.x\n\tif hasI || hasU || hasB || hasS {\n\t\tcloseUnder := []byte(\"</u>\")\n\t\tcloseItalic := []byte(\"</i>\")\n\t\tcloseBold := []byte(\"</b>\")\n\t\tcloseStrike := []byte(\"</s>\")\n\t\tif hasI {\n\t\t\tmbytes = append(mbytes, closeItalic...)\n\t\t}\n\t\tif hasU {\n\t\t\tmbytes = append(mbytes, closeUnder...)\n\t\t}\n\t\tif hasB {\n\t\t\tmbytes = append(mbytes, closeBold...)\n\t\t}\n\t\tif hasS {\n\t\t\tmbytes = append(mbytes, closeStrike...)\n\t\t}\n\t}\n\treturn string(mbytes)\n}\n\n// Here for benchmarking purposes. Might add a plugin setting for disabling [code] as it has it's paws everywhere\nfunc BbcodeParseWithoutCode(msg string) string {\n\tvar hasU, hasB, hasI, hasS bool\n\tvar complexBbc bool\n\tmbytes := []byte(msg)\n\tfor i := 0; (i + 3) < len(mbytes); i++ {\n\t\tif mbytes[i] == '[' {\n\t\t\tif mbytes[i+2] != ']' {\n\t\t\t\tif mbytes[i+1] == '/' {\n\t\t\t\t\tif mbytes[i+3] == ']' {\n\t\t\t\t\t\tswitch mbytes[i+2] {\n\t\t\t\t\t\tcase 'b':\n\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\thasB = false\n\t\t\t\t\t\tcase 'i':\n\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\thasI = false\n\t\t\t\t\t\tcase 'u':\n\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\thasU = false\n\t\t\t\t\t\tcase 's':\n\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\thasS = false\n\t\t\t\t\t\t}\n\t\t\t\t\t\ti += 3\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcomplexBbc = true\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tcomplexBbc = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tch := mbytes[i+1]\n\t\t\t\tif ch == 'b' && !hasB {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasB = true\n\t\t\t\t} else if ch == 'i' && !hasI {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasI = true\n\t\t\t\t} else if ch == 'u' && !hasU {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasU = true\n\t\t\t\t} else if ch == 's' && !hasS {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasS = true\n\t\t\t\t}\n\t\t\t\ti += 2\n\t\t\t}\n\t\t}\n\t}\n\n\t// There's an unclosed tag in there x.x\n\tif hasI || hasU || hasB || hasS {\n\t\tcloseUnder := []byte(\"</u>\")\n\t\tcloseItalic := []byte(\"</i>\")\n\t\tcloseBold := []byte(\"</b>\")\n\t\tcloseStrike := []byte(\"</s>\")\n\t\tif hasI {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeItalic...)\n\t\t}\n\t\tif hasU {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeUnder...)\n\t\t}\n\t\tif hasB {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeBold...)\n\t\t}\n\t\tif hasS {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeStrike...)\n\t\t}\n\t}\n\n\t// Copy the new complex parser over once the rough edges have been smoothed over\n\tif complexBbc {\n\t\tmsg = string(mbytes)\n\t\tmsg = bbcodeURL.ReplaceAllString(msg, \"<a href='$1$2//$3' rel='ugc'>$1$2//$3</i>\")\n\t\tmsg = bbcodeURLLabel.ReplaceAllString(msg, \"<a href='$1$2//$3' rel='ugc'>$4</i>\")\n\t\tmsg = bbcodeSpoiler.ReplaceAllString(msg, \"<spoiler>$1</spoiler>\")\n\t\tmsg = bbcodeQuotes.ReplaceAllString(msg, \"<blockquote>$1</blockquote>\")\n\t\treturn bbcodeCode.ReplaceAllString(msg, \"<span class='codequotes'>$1</span>\")\n\t}\n\treturn string(mbytes)\n}\n\n// Does every type of BBCode\nfunc BbcodeFullParse(msg string) string {\n\tvar hasU, hasB, hasI, hasS, hasC bool\n\tvar complexBbc bool\n\n\tmbytes := []byte(msg)\n\tmbytes = append(mbytes, c.SpaceGap...)\n\tfor i := 0; i < len(mbytes); i++ {\n\t\tif mbytes[i] == '[' {\n\t\t\tif mbytes[i+2] != ']' {\n\t\t\t\tif mbytes[i+1] == '/' {\n\t\t\t\t\tif mbytes[i+3] == ']' {\n\t\t\t\t\t\tif !hasC {\n\t\t\t\t\t\t\tswitch mbytes[i+2] {\n\t\t\t\t\t\t\tcase 'b':\n\t\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\t\thasB = false\n\t\t\t\t\t\t\tcase 'i':\n\t\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\t\thasI = false\n\t\t\t\t\t\t\tcase 'u':\n\t\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\t\thasU = false\n\t\t\t\t\t\t\tcase 's':\n\t\t\t\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\t\t\t\tmbytes[i+3] = '>'\n\t\t\t\t\t\t\t\thasS = false\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ti += 3\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif mbytes[i+6] == ']' && mbytes[i+2] == 'c' && mbytes[i+3] == 'o' && mbytes[i+4] == 'd' && mbytes[i+5] == 'e' {\n\t\t\t\t\t\t\thasC = false\n\t\t\t\t\t\t\ti += 7\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcomplexBbc = true\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Put the biggest index first to avoid unnecessary bounds checks\n\t\t\t\t\tif mbytes[i+5] == ']' && mbytes[i+1] == 'c' && mbytes[i+2] == 'o' && mbytes[i+3] == 'd' && mbytes[i+4] == 'e' {\n\t\t\t\t\t\thasC = true\n\t\t\t\t\t\ti += 6\n\t\t\t\t\t}\n\t\t\t\t\tcomplexBbc = true\n\t\t\t\t}\n\t\t\t} else if !hasC {\n\t\t\t\tch := mbytes[i+1]\n\t\t\t\tif ch == 'b' && !hasB {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasB = true\n\t\t\t\t} else if ch == 'i' && !hasI {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasI = true\n\t\t\t\t} else if ch == 'u' && !hasU {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasU = true\n\t\t\t\t} else if ch == 's' && !hasS {\n\t\t\t\t\tmbytes[i] = '<'\n\t\t\t\t\tmbytes[i+2] = '>'\n\t\t\t\t\thasS = true\n\t\t\t\t}\n\t\t\t\ti += 2\n\t\t\t}\n\t\t}\n\t}\n\n\t// There's an unclosed tag in there somewhere x.x\n\tif hasI || hasU || hasB || hasS {\n\t\tcloseUnder := []byte(\"</u>\")\n\t\tcloseItalic := []byte(\"</i>\")\n\t\tcloseBold := []byte(\"</b>\")\n\t\tcloseStrike := []byte(\"</s>\")\n\t\tif hasI {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeItalic...)\n\t\t}\n\t\tif hasU {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeUnder...)\n\t\t}\n\t\tif hasB {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeBold...)\n\t\t}\n\t\tif hasS {\n\t\t\tmbytes = append(bytes.TrimSpace(mbytes), closeStrike...)\n\t\t}\n\t\tmbytes = append(mbytes, c.SpaceGap...)\n\t}\n\n\tif complexBbc {\n\t\ti := 0\n\t\tvar start, lastTag int\n\t\tvar outbytes []byte\n\t\tfor ; i < len(mbytes); i++ {\n\t\t\tif mbytes[i] == '[' {\n\t\t\t\tif mbytes[i+1] == 'u' {\n\t\t\t\t\tif mbytes[i+4] == ']' && mbytes[i+2] == 'r' && mbytes[i+3] == 'l' {\n\t\t\t\t\t\ti, start, lastTag, outbytes = bbcodeParseURL(i, start, lastTag, mbytes, outbytes)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t} else if mbytes[i+1] == 'r' {\n\t\t\t\t\tif bytes.Equal(mbytes[i+2:i+6], []byte(\"and]\")) {\n\t\t\t\t\t\ti, start, lastTag, outbytes = bbcodeParseRand(i, start, lastTag, mbytes, outbytes)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif lastTag != i {\n\t\t\toutbytes = append(outbytes, mbytes[lastTag:]...)\n\t\t}\n\n\t\tif len(outbytes) != 0 {\n\t\t\tmsg = string(outbytes[0 : len(outbytes)-10])\n\t\t} else {\n\t\t\tmsg = string(mbytes[0 : len(mbytes)-10])\n\t\t}\n\n\t\t// TODO: Optimise these\n\t\t//msg = bbcode_url.ReplaceAllString(msg,\"<a href=\\\"$1$2//$3\\\" rel=\\\"ugc\\\">$1$2//$3</i>\")\n\t\tmsg = bbcodeURLLabel.ReplaceAllString(msg, \"<a href='$1$2//$3' rel='ugc'>$4</i>\")\n\t\tmsg = bbcodeQuotes.ReplaceAllString(msg, \"<blockquote>$1</blockquote>\")\n\t\tmsg = bbcodeCode.ReplaceAllString(msg, \"<span class='codequotes'>$1</span>\")\n\t\tmsg = bbcodeSpoiler.ReplaceAllString(msg, \"<spoiler>$1</spoiler>\")\n\t\tmsg = bbcodeH1.ReplaceAllString(msg, \"<h2>$1</h2>\")\n\t} else {\n\t\tmsg = string(mbytes[0 : len(mbytes)-10])\n\t}\n\n\treturn msg\n}\n\n// TODO: Strip the containing [url] so the media parser can work it's magic instead? Or do we want to allow something like [url=]label[/url] here?\nfunc bbcodeParseURL(i int, start int, lastTag int, mbytes []byte, outbytes []byte) (int, int, int, []byte) {\n\tstart = i + 5\n\toutbytes = append(outbytes, mbytes[lastTag:i]...)\n\ti = start\n\ti += c.PartialURLStringLen2(string(mbytes[start:]))\n\tif !bytes.Equal(mbytes[i:i+6], []byte(\"[/url]\")) {\n\t\toutbytes = append(outbytes, c.InvalidURL...)\n\t\treturn i, start, lastTag, outbytes\n\t}\n\n\toutbytes = append(outbytes, c.URLOpen...)\n\toutbytes = append(outbytes, mbytes[start:i]...)\n\toutbytes = append(outbytes, c.URLOpen2...)\n\toutbytes = append(outbytes, mbytes[start:i]...)\n\toutbytes = append(outbytes, c.URLClose...)\n\ti += 6\n\tlastTag = i\n\n\treturn i, start, lastTag, outbytes\n}\n\nfunc bbcodeParseRand(i int, start int, lastTag int, msgbytes []byte, outbytes []byte) (int, int, int, []byte) {\n\toutbytes = append(outbytes, msgbytes[lastTag:i]...)\n\tstart = i + 6\n\ti = start\n\tfor ; ; i++ {\n\t\tif msgbytes[i] == '[' {\n\t\t\tif !bytes.Equal(msgbytes[i+1:i+7], []byte(\"/rand]\")) {\n\t\t\t\toutbytes = append(outbytes, bbcodeMissingTag...)\n\t\t\t\treturn i, start, lastTag, outbytes\n\t\t\t}\n\t\t\tbreak\n\t\t} else if (len(msgbytes) - 1) < (i + 10) {\n\t\t\toutbytes = append(outbytes, bbcodeMissingTag...)\n\t\t\treturn i, start, lastTag, outbytes\n\t\t}\n\t}\n\n\tnumber, err := strconv.ParseInt(string(msgbytes[start:i]), 10, 64)\n\tif err != nil {\n\t\toutbytes = append(outbytes, bbcodeInvalidNumber...)\n\t\treturn i, start, lastTag, outbytes\n\t}\n\n\t// TODO: Add support for negative numbers?\n\tif number < 0 {\n\t\toutbytes = append(outbytes, bbcodeNoNegative...)\n\t\treturn i, start, lastTag, outbytes\n\t}\n\n\tvar dat []byte\n\tif number == 0 {\n\t\tdat = []byte(\"0\")\n\t} else {\n\t\tdat = []byte(strconv.FormatInt((bbcodeRandom.Int63n(number)), 10))\n\t}\n\n\toutbytes = append(outbytes, dat...)\n\ti += 7\n\tlastTag = i\n\treturn i, start, lastTag, outbytes\n}\n"
  },
  {
    "path": "extend/plugin_heythere.go",
    "content": "package extend\n\nimport c \"github.com/Azareal/Gosora/common\"\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"heythere\", Name: \"Hey There\", Author: \"Azareal\", URL: \"https://github.com/Azareal\", Init: initHeythere, Deactivate: deactivateHeythere})\n}\n\n// initHeythere is separate from init() as we don't want the plugin to run if the plugin is disabled\nfunc initHeythere(plugin *c.Plugin) error {\n\tplugin.AddHook(\"topic_reply_row_assign\", heythereReply)\n\treturn nil\n}\n\nfunc deactivateHeythere(plugin *c.Plugin) {\n\tplugin.RemoveHook(\"topic_reply_row_assign\", heythereReply)\n}\n\nfunc heythereReply(data ...interface{}) interface{} {\n\tcurrentUser := data[0].(*c.TopicPage).Header.CurrentUser\n\treply := data[1].(*c.ReplyUser)\n\treply.Content = \"Hey there, \" + currentUser.Name + \"!\"\n\treply.ContentHtml = \"Hey there, \" + currentUser.Name + \"!\"\n\treply.Tag = \"Auto\"\n\treturn nil\n}\n"
  },
  {
    "path": "extend/plugin_hyperdrive.go",
    "content": "// Highly experimental plugin for caching rendered pages for guests\npackage extend\n\nimport (\n\t//\"log\"\n\t\"bytes\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/routes\"\n)\n\nvar hyperspace *Hyperspace\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"hyperdrive\", Name: \"Hyperdrive\", Author: \"Azareal\", Init: initHdrive, Deactivate: deactivateHdrive})\n}\n\nfunc initHdrive(pl *c.Plugin) error {\n\thyperspace = newHyperspace()\n\tpl.AddHook(\"tasks_tick_topic_list\", tickHdrive)\n\tpl.AddHook(\"tasks_tick_widget_wol\", tickHdriveWol)\n\tpl.AddHook(\"route_topic_list_start\", jumpHdriveTopicList)\n\tpl.AddHook(\"route_forum_list_start\", jumpHdriveForumList)\n\ttickHdrive()\n\treturn nil\n}\n\nfunc deactivateHdrive(pl *c.Plugin) {\n\tpl.RemoveHook(\"tasks_tick_topic_list\", tickHdrive)\n\tpl.RemoveHook(\"tasks_tick_widget_wol\", tickHdriveWol)\n\tpl.RemoveHook(\"route_topic_list_start\", jumpHdriveTopicList)\n\tpl.RemoveHook(\"route_forum_list_start\", jumpHdriveForumList)\n\thyperspace = nil\n}\n\ntype Hyperspace struct {\n\ttopicList           atomic.Value\n\tforumList           atomic.Value\n\tlastTopicListUpdate atomic.Value\n}\n\nfunc newHyperspace() *Hyperspace {\n\tpageCache := new(Hyperspace)\n\tblank := make(map[string][3][]byte, len(c.Themes))\n\tpageCache.topicList.Store(blank)\n\tpageCache.forumList.Store(blank)\n\tpageCache.lastTopicListUpdate.Store(int64(0))\n\treturn pageCache\n}\n\nfunc tickHdriveWol(args ...interface{}) (skip bool, rerr c.RouteError) {\n\tc.DebugLog(\"docking at wol\")\n\treturn tickHdrive(args)\n}\n\n// TODO: Find a better way of doing this\nfunc tickHdrive(args ...interface{}) (skip bool, rerr c.RouteError) {\n\tc.DebugLog(\"Refueling...\")\n\n\t// Avoid accidentally caching already cached content\n\tblank := make(map[string][3][]byte, len(c.Themes))\n\thyperspace.topicList.Store(blank)\n\thyperspace.forumList.Store(blank)\n\n\ttListMap := make(map[string][3][]byte)\n\tfListMap := make(map[string][3][]byte)\n\n\tcacheTheme := func(tname string) (skip, fail bool, rerr c.RouteError) {\n\n\t\tthemeCookie := http.Cookie{Name: \"current_theme\", Value: tname, Path: \"/\", MaxAge: c.Year}\n\n\t\tw := httptest.NewRecorder()\n\t\treq := httptest.NewRequest(\"get\", \"/topics/\", bytes.NewReader(nil))\n\t\treq.AddCookie(&themeCookie)\n\t\tuser := c.GuestUser\n\n\t\thead, rerr := c.UserCheck(w, req, &user)\n\t\tif rerr != nil {\n\t\t\treturn true, true, rerr\n\t\t}\n\t\trerr = routes.TopicList(w, req, &user, head)\n\t\tif rerr != nil {\n\t\t\treturn true, true, rerr\n\t\t}\n\t\tif w.Code != 200 {\n\t\t\tc.LogWarning(errors.New(\"not 200 for topic list in hyperdrive\"))\n\t\t\treturn false, true, nil\n\t\t}\n\n\t\tbuf := new(bytes.Buffer)\n\t\tbuf.ReadFrom(w.Result().Body)\n\n\t\tgbuf, err := c.CompressBytesGzip(buf.Bytes())\n\t\tif err != nil {\n\t\t\tc.LogWarning(err)\n\t\t\treturn false, true, nil\n\t\t}\n\n\t\tbbuf, err := c.CompressBytesBrotli(buf.Bytes())\n\t\tif err != nil {\n\t\t\tc.LogWarning(err)\n\t\t\treturn false, true, nil\n\t\t}\n\t\ttListMap[tname] = [3][]byte{buf.Bytes(), gbuf, bbuf}\n\n\t\tw = httptest.NewRecorder()\n\t\treq = httptest.NewRequest(\"get\", \"/forums/\", bytes.NewReader(nil))\n\t\tuser = c.GuestUser\n\n\t\thead, rerr = c.UserCheck(w, req, &user)\n\t\tif rerr != nil {\n\t\t\treturn true, true, rerr\n\t\t}\n\t\trerr = routes.ForumList(w, req, &user, head)\n\t\tif rerr != nil {\n\t\t\treturn true, true, rerr\n\t\t}\n\t\tif w.Code != 200 {\n\t\t\tc.LogWarning(errors.New(\"not 200 for forum list in hyperdrive\"))\n\t\t\treturn false, true, nil\n\t\t}\n\n\t\tbuf = new(bytes.Buffer)\n\t\tbuf.ReadFrom(w.Result().Body)\n\n\t\tgbuf, err = c.CompressBytesGzip(buf.Bytes())\n\t\tif err != nil {\n\t\t\tc.LogWarning(err)\n\t\t\treturn false, true, nil\n\t\t}\n\n\t\tbbuf, err = c.CompressBytesBrotli(buf.Bytes())\n\t\tif err != nil {\n\t\t\tc.LogWarning(err)\n\t\t\treturn false, true, nil\n\t\t}\n\t\tfListMap[tname] = [3][]byte{buf.Bytes(), gbuf, bbuf}\n\t\treturn false, false, nil\n\t}\n\n\tfor tname, _ := range c.Themes {\n\t\tskip, fail, rerr := cacheTheme(tname)\n\t\tif fail || rerr != nil {\n\t\t\treturn skip, rerr\n\t\t}\n\t}\n\n\thyperspace.topicList.Store(tListMap)\n\thyperspace.forumList.Store(fListMap)\n\thyperspace.lastTopicListUpdate.Store(time.Now().Unix())\n\n\treturn false, nil\n}\n\nfunc jumpHdriveTopicList(args ...interface{}) (skip bool, rerr c.RouteError) {\n\ttheme := c.GetThemeByReq(args[1].(*http.Request))\n\tp := hyperspace.topicList.Load().(map[string][3][]byte)\n\treturn jumpHdrive(p[theme.Name], args)\n}\n\nfunc jumpHdriveForumList(args ...interface{}) (skip bool, rerr c.RouteError) {\n\ttheme := c.GetThemeByReq(args[1].(*http.Request))\n\tp := hyperspace.forumList.Load().(map[string][3][]byte)\n\treturn jumpHdrive(p[theme.Name], args)\n}\n\nfunc jumpHdrive( /*pg, */ p [3][]byte, args []interface{}) (skip bool, rerr c.RouteError) {\n\tvar tList []byte\n\tw := args[0].(http.ResponseWriter)\n\tr := args[1].(*http.Request)\n\tvar iw http.ResponseWriter\n\tgzw, ok := w.(c.GzipResponseWriter)\n\t//bzw, ok2 := w.(c.BrResponseWriter)\n\t// !temp until global brotli\n\tbr := strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"br\")\n\tif br && ok {\n\t\ttList = p[2]\n\t\tiw = gzw.ResponseWriter\n\t} else if br {\n\t\ttList = p[2]\n\t\tiw = w\n\t} else if ok {\n\t\ttList = p[1]\n\t\tiw = gzw.ResponseWriter\n\t\t/*} else if ok2 {\n\t\ttList = p[2]\n\t\tiw = bzw.ResponseWriter\n\t\t*/\n\t} else {\n\t\ttList = p[0]\n\t\tiw = w\n\t}\n\tif len(tList) == 0 {\n\t\tc.DebugLog(\"no itemlist in hyperspace\")\n\t\treturn false, nil\n\t}\n\t//c.DebugLog(\"tList: \", tList)\n\n\t// Avoid intercepting user requests as we only have guests in cache right now\n\tuser := args[2].(*c.User)\n\tif user.ID != 0 {\n\t\tc.DebugLog(\"not guest\")\n\t\treturn false, nil\n\t}\n\n\t// Avoid intercepting search requests and filters as we don't have those in cache\n\t//c.DebugLog(\"r.URL.Path:\",r.URL.Path)\n\t//c.DebugLog(\"r.URL.RawQuery:\",r.URL.RawQuery)\n\tif r.URL.RawQuery != \"\" {\n\t\treturn false, nil\n\t}\n\tif r.FormValue(\"js\") == \"1\" || r.FormValue(\"i\") == \"1\" {\n\t\treturn false, nil\n\t}\n\tc.DebugLog(\"Successful jump\")\n\n\tvar etag string\n\tlastUpdate := hyperspace.lastTopicListUpdate.Load().(int64)\n\tc.DebugLog(\"lastUpdate:\", lastUpdate)\n\tif br {\n\t\th := iw.Header()\n\t\th.Set(\"X-I\", \"1\")\n\t\th.Set(\"Content-Encoding\", \"br\")\n\t\tetag = \"\\\"\" + strconv.FormatInt(lastUpdate, 10) + \"-b\\\"\"\n\t} else if ok {\n\t\tiw.Header().Set(\"X-I\", \"1\")\n\t\tetag = \"\\\"\" + strconv.FormatInt(lastUpdate, 10) + \"-g\\\"\"\n\t\t/*} else if ok2 {\n\t\tiw.Header().Set(\"X-I\", \"1\")\n\t\tetag = \"\\\"\" + strconv.FormatInt(lastUpdate, 10) + \"-b\\\"\"\n\t\t*/\n\t} else {\n\t\tetag = \"\\\"\" + strconv.FormatInt(lastUpdate, 10) + \"\\\"\"\n\t}\n\n\tif lastUpdate != 0 {\n\t\tiw.Header().Set(\"ETag\", etag)\n\t\tif match := r.Header.Get(\"If-None-Match\"); match != \"\" {\n\t\t\tif strings.Contains(match, etag) {\n\t\t\t\tiw.WriteHeader(http.StatusNotModified)\n\t\t\t\treturn true, nil\n\t\t\t}\n\t\t}\n\t}\n\n\theader := args[3].(*c.Header)\n\tif br || ok /*ok2*/ {\n\t\tiw.Header().Set(\"Content-Type\", \"text/html;charset=utf-8\")\n\t}\n\troutes.FootHeaders(w, header)\n\tiw.Write(tList)\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "extend/plugin_markdown.go",
    "content": "package extend\n\nimport (\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nvar markdownMaxDepth = 25 // How deep the parser will go when parsing Markdown strings\nvar markdownUnclosedElement []byte\n\nvar markdownBoldTagOpen []byte\nvar markdownBoldTagClose []byte\nvar markdownItalicTagOpen []byte\nvar markdownItalicTagClose []byte\nvar markdownUnderlineTagOpen []byte\nvar markdownUnderlineTagClose []byte\nvar markdownStrikeTagOpen []byte\nvar markdownStrikeTagClose []byte\nvar markdownQuoteTagOpen []byte\nvar markdownQuoteTagClose []byte\nvar markdownSpoilerTagOpen []byte\nvar markdownSpoilerTagClose []byte\nvar markdownH1TagOpen []byte\nvar markdownH1TagClose []byte\n\nfunc init() {\n\tc.Plugins.Add(&c.Plugin{UName: \"markdown\", Name: \"Markdown\", Author: \"Azareal\", URL: \"https://github.com/Azareal\", Init: InitMarkdown, Deactivate: deactivateMarkdown})\n}\n\nfunc InitMarkdown(pl *c.Plugin) error {\n\tmarkdownUnclosedElement = []byte(\"<red>[Unclosed Element]</red>\")\n\n\tmarkdownBoldTagOpen = []byte(\"<b>\")\n\tmarkdownBoldTagClose = []byte(\"</b>\")\n\tmarkdownItalicTagOpen = []byte(\"<i>\")\n\tmarkdownItalicTagClose = []byte(\"</i>\")\n\tmarkdownUnderlineTagOpen = []byte(\"<u>\")\n\tmarkdownUnderlineTagClose = []byte(\"</u>\")\n\tmarkdownStrikeTagOpen = []byte(\"<s>\")\n\tmarkdownStrikeTagClose = []byte(\"</s>\")\n\tmarkdownQuoteTagOpen = []byte(\"<blockquote>\")\n\tmarkdownQuoteTagClose = []byte(\"</blockquote>\")\n\tmarkdownSpoilerTagOpen = []byte(\"<spoiler>\")\n\tmarkdownSpoilerTagClose = []byte(\"</spoiler>\")\n\tmarkdownH1TagOpen = []byte(\"<h2>\")\n\tmarkdownH1TagClose = []byte(\"</h2>\")\n\n\tpl.AddHook(\"parse_assign\", MarkdownParse)\n\treturn nil\n}\n\nfunc deactivateMarkdown(pl *c.Plugin) {\n\tpl.RemoveHook(\"parse_assign\", MarkdownParse)\n}\n\n// An adapter for the parser, so that the parser can call itself recursively.\n// This is less for the simple Markdown elements like bold and italics and more for the really complicated ones I plan on adding at some point.\nfunc MarkdownParse(msg string) string {\n\tmsg = _markdownParse(msg+\" \", 0)\n\tif msg[len(msg)-1] == ' ' {\n\t\tmsg = msg[:len(msg)-1]\n\t}\n\treturn msg\n}\n\n// Under Construction!\nfunc _markdownParse(msg string, n int) string {\n\tif n > markdownMaxDepth {\n\t\treturn \"<red>[Markdown Error: Overflowed the max depth of 20]</red>\"\n\t}\n\n\tvar outbytes []byte\n\tvar lastElement int\n\tbreaking := false\n\t//c.DebugLogf(\"Initial Msg: %+v\\n\", strings.Replace(msg, \"\\r\", \"\\\\r\", -1))\n\n\tfor index := 0; index < len(msg); index++ {\n\t\tsimpleMatch := func(char byte, o []byte, c []byte) {\n\t\t\tstartIndex := index\n\t\t\tif (index + 1) >= len(msg) {\n\t\t\t\tbreaking = true\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tindex++\n\t\t\tindex = markdownSkipUntilChar(msg, index, char)\n\t\t\tif (index-(startIndex+1)) < 1 || index >= len(msg) {\n\t\t\t\tbreaking = true\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsIndex := startIndex + 1\n\t\t\tlIndex := index\n\t\t\tindex++\n\n\t\t\toutbytes = append(outbytes, msg[lastElement:startIndex]...)\n\t\t\toutbytes = append(outbytes, o...)\n\t\t\t// TODO: Implement this without as many type conversions\n\t\t\toutbytes = append(outbytes, []byte(_markdownParse(msg[sIndex:lIndex], n+1))...)\n\t\t\toutbytes = append(outbytes, c...)\n\n\t\t\tlastElement = index\n\t\t\tindex--\n\t\t}\n\n\t\tstartLine := func() {\n\t\t\tstartIndex := index\n\t\t\tif (index + 1) >= len(msg) /*|| (index + 2) >= len(msg)*/ {\n\t\t\t\tbreaking = true\n\t\t\t\treturn\n\t\t\t}\n\t\t\tindex++\n\n\t\t\tindex = markdownSkipUntilNotChar(msg, index, 32)\n\t\t\tif (index + 1) >= len(msg) {\n\t\t\t\tbreaking = true\n\t\t\t\treturn\n\t\t\t}\n\t\t\t//index++\n\n\t\t\tindex = markdownSkipUntilStrongSpace(msg, index)\n\t\t\tsIndex := startIndex + 1\n\t\t\tlIndex := index\n\t\t\tindex++\n\n\t\t\toutbytes = append(outbytes, msg[lastElement:startIndex]...)\n\t\t\toutbytes = append(outbytes, markdownH1TagOpen...)\n\t\t\t// TODO: Implement this without as many type conversions\n\t\t\t//fmt.Println(\"msg[sIndex:lIndex]:\", string(msg[sIndex:lIndex]))\n\t\t\t// TODO: Quick hack to eliminate trailing spaces...\n\t\t\toutbytes = append(outbytes, []byte(strings.TrimSpace(_markdownParse(msg[sIndex:lIndex], n+1)))...)\n\t\t\toutbytes = append(outbytes, markdownH1TagClose...)\n\n\t\t\tlastElement = index\n\t\t\tindex--\n\t\t}\n\n\t\tuniqueWord := func(i int) bool {\n\t\t\tif i == 0 {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn msg[i-1] <= 32\n\t\t}\n\n\t\tswitch msg[index] {\n\t\t// TODO: Do something slightly less hacky for skipping URLs\n\t\tcase '/':\n\t\t\tif len(msg) > (index+2) && msg[index+1] == '/' {\n\t\t\t\tfor ; index < len(msg) && msg[index] != ' '; index++ {\n\t\t\t\t}\n\t\t\t\tindex--\n\t\t\t\tcontinue\n\t\t\t}\n\t\tcase '_':\n\t\t\tif !uniqueWord(index) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tsimpleMatch('_', markdownUnderlineTagOpen, markdownUnderlineTagClose)\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\tcase '~':\n\t\t\tsimpleMatch('~', markdownStrikeTagOpen, markdownStrikeTagClose)\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\tcase '*':\n\t\t\tstartIndex := index\n\t\t\titalic := true\n\t\t\tbold := false\n\t\t\tif (index + 2) < len(msg) {\n\t\t\t\tif msg[index+1] == '*' {\n\t\t\t\t\tbold = true\n\t\t\t\t\tindex++\n\t\t\t\t\tif msg[index+1] != '*' {\n\t\t\t\t\t\titalic = false\n\t\t\t\t\t} else {\n\t\t\t\t\t\tindex++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Does the string terminate abruptly?\n\t\t\tif (index + 1) >= len(msg) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tindex++\n\n\t\t\tindex = markdownSkipUntilAsterisk(msg, index)\n\t\t\tif index >= len(msg) {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tpreBreak := func() {\n\t\t\t\toutbytes = append(outbytes, msg[lastElement:startIndex]...)\n\t\t\t\tlastElement = startIndex\n\t\t\t}\n\n\t\t\tsIndex := startIndex\n\t\t\tlIndex := index\n\t\t\tif bold && italic {\n\t\t\t\tif (index + 3) >= len(msg) {\n\t\t\t\t\tpreBreak()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tindex += 3\n\t\t\t\tsIndex += 3\n\t\t\t} else if bold {\n\t\t\t\tif (index + 2) >= len(msg) {\n\t\t\t\t\tpreBreak()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tindex += 2\n\t\t\t\tsIndex += 2\n\t\t\t} else {\n\t\t\t\tif (index + 1) >= len(msg) {\n\t\t\t\t\tpreBreak()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tindex++\n\t\t\t\tsIndex++\n\t\t\t}\n\n\t\t\tif lIndex <= sIndex {\n\t\t\t\tpreBreak()\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif sIndex < 0 || lIndex < 0 {\n\t\t\t\tpreBreak()\n\t\t\t\tbreak\n\t\t\t}\n\t\t\toutbytes = append(outbytes, msg[lastElement:startIndex]...)\n\n\t\t\tif bold {\n\t\t\t\toutbytes = append(outbytes, markdownBoldTagOpen...)\n\t\t\t}\n\t\t\tif italic {\n\t\t\t\toutbytes = append(outbytes, markdownItalicTagOpen...)\n\t\t\t}\n\n\t\t\t// TODO: Implement this without as many type conversions\n\t\t\toutbytes = append(outbytes, []byte(_markdownParse(msg[sIndex:lIndex], n+1))...)\n\n\t\t\tif italic {\n\t\t\t\toutbytes = append(outbytes, markdownItalicTagClose...)\n\t\t\t}\n\t\t\tif bold {\n\t\t\t\toutbytes = append(outbytes, markdownBoldTagClose...)\n\t\t\t}\n\n\t\t\tlastElement = index\n\t\t\tindex--\n\t\tcase '\\\\':\n\t\t\tif (index + 1) < len(msg) {\n\t\t\t\tif isMarkdownStartChar(msg[index+1]) && msg[index+1] != '\\\\' {\n\t\t\t\t\toutbytes = append(outbytes, msg[lastElement:index]...)\n\t\t\t\t\tindex++\n\t\t\t\t\tlastElement = index\n\t\t\t\t}\n\t\t\t}\n\t\t// TODO: Add a inline quote variant\n\t\tcase '`':\n\t\t\tsimpleMatch('`', markdownQuoteTagOpen, markdownQuoteTagClose)\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\t// TODO: Might need to be double pipe\n\t\tcase '|':\n\t\t\tsimpleMatch('|', markdownSpoilerTagOpen, markdownSpoilerTagClose)\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\tcase 10: // newline\n\t\t\tif (index + 1) >= len(msg) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tindex++\n\n\t\t\tif msg[index] != '#' {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstartLine()\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\tcase '#':\n\t\t\tif index != 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstartLine()\n\t\t\tif breaking {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(outbytes) == 0 {\n\t\treturn msg\n\t} else if lastElement < (len(msg) - 1) {\n\t\tmsg = string(outbytes) + msg[lastElement:]\n\t\treturn msg\n\t}\n\treturn string(outbytes)\n}\n\nfunc isMarkdownStartChar(ch byte) bool { // char\n\treturn ch == '\\\\' || ch == '~' || ch == '_' || ch == 10 || ch == '`' || ch == '*' || ch == '|'\n}\n\nfunc markdownFindChar(data string, index int, char byte) bool {\n\tfor ; index < len(data); index++ {\n\t\titem := data[index]\n\t\tif item > 32 {\n\t\t\treturn (item == char)\n\t\t}\n\t}\n\treturn false\n}\n\nfunc markdownSkipUntilChar(data string, index int, char byte) int {\n\tfor ; index < len(data); index++ {\n\t\tif data[index] == char {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn index\n}\n\nfunc markdownSkipUntilNotChar(data string, index int, char byte) int {\n\tfor ; index < len(data); index++ {\n\t\tif data[index] != char {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn index\n}\n\nfunc markdownSkipUntilStrongSpace(data string, index int) int {\n\tinSpace := false\n\tfor ; index < len(data); index++ {\n\t\tif inSpace && data[index] == 32 {\n\t\t\tindex--\n\t\t\tbreak\n\t\t} else if data[index] == 32 {\n\t\t\tinSpace = true\n\t\t} else if data[index] < 32 {\n\t\t\tbreak\n\t\t} else {\n\t\t\tinSpace = false\n\t\t}\n\t}\n\treturn index\n}\n\nfunc markdownSkipUntilAsterisk(data string, index int) int {\nSwitchLoop:\n\tfor ; index < len(data); index++ {\n\t\tswitch data[index] {\n\t\tcase 10:\n\t\t\tif ((index + 1) < len(data)) && markdownFindChar(data, index, '*') {\n\t\t\t\tindex = markdownSkipList(data, index)\n\t\t\t}\n\t\tcase '*':\n\t\t\tbreak SwitchLoop\n\t\t}\n\t}\n\treturn index\n}\n\n// plugin_markdown doesn't support lists yet, but I want it to be easy to have nested lists when we do have them\nfunc markdownSkipList(data string, index int) int {\n\tvar lastNewline int\n\tdatalen := len(data)\n\tfor ; index < datalen; index++ {\n\tSkipListInnerLoop:\n\t\tif data[index] == 10 {\n\t\t\tlastNewline = index\n\t\t\tfor ; index < datalen; index++ {\n\t\t\t\tif data[index] > 32 {\n\t\t\t\t\tbreak\n\t\t\t\t} else if data[index] == 10 {\n\t\t\t\t\tgoto SkipListInnerLoop\n\t\t\t\t}\n\t\t\t}\n\t\t\tif index >= datalen {\n\t\t\t\tif data[index] != '*' && data[index] != '-' {\n\t\t\t\t\tif (lastNewline + 1) < datalen {\n\t\t\t\t\t\treturn lastNewline + 1\n\t\t\t\t\t}\n\t\t\t\t\treturn lastNewline\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn index\n}\n"
  },
  {
    "path": "extend/plugin_skeleton.go",
    "content": "package extend\n\nimport c \"github.com/Azareal/Gosora/common\"\n\nfunc init() {\n\t/*\n\t\tThe UName field should match the name in the URL minus plugin_ and the file extension. The same name as the map index. Please choose a unique name which won't clash with any other plugins.\n\n\t\tThe Name field is for the friendly name of the plugin shown to the end-user.\n\n\t\tThe Author field is the author of this plugin. The one who created it.\n\n\t\tThe URL field is for the URL pointing to the location where you can download this plugin.\n\n\t\tThe Settings field points to the route for managing the settings for this plugin. Coming soon.\n\n\t\tThe Tag field is for providing a tiny snippet of information separate from the description.\n\n\t\tThe Type field is for the type of the plugin. This gets changed to \"go\" automatically and we would suggest leaving \"\".\n\n\t\tThe Init field is for the initialisation handler which is called by the software to run this plugin. This expects a function. You should add your hooks, init logic, initial queries, etc. in said function.\n\n\t\tThe Activate field is for the handler which is called by the software when the admin hits the Activate button in the control panel. This is separate from the Init handler which is called upon the start of the server and upon activation. Use nil if you don't have a handler for this.\n\n\t\tThe Deactivate field is for the handler which is called by the software when the admin hits the Deactivate button in the control panel. You should clean-up any resources you have allocated, remove any hooks, close any statements, etc. within this handler.\n\n\t\tThe Installation field is for one-off installation logic such as creating tables. You will need to run the separate uninstallation function for that.\n\n\t\tThat Uninstallation field which is currently unused is for not only deactivating this plugin, but for purging any data associated with it such a new tables or data produced by the end-user.\n\t*/\n\tc.Plugins.Add(&c.Plugin{UName: \"skeleton\", Name: \"Skeleton\", Author: \"Azareal\", Init: initSkeleton, Activate: activateSkeleton, Deactivate: deactivateSkeleton})\n}\n\nfunc initSkeleton(pl *c.Plugin) error { return nil }\n\n// Any errors encountered while trying to activate the plugin are reported back to the admin and the activation is aborted\nfunc activateSkeleton(pl *c.Plugin) error { return nil }\n\nfunc deactivateSkeleton(pl *c.Plugin) {}\n"
  },
  {
    "path": "gen_mssql.go",
    "content": "// +build mssql\n\n// This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time.\npackage main\n\nimport \"log\"\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/common\"\n\n// nolint\ntype Stmts struct {\n\tforumEntryExists *sql.Stmt\n\tgroupEntryExists *sql.Stmt\n\tgetForumTopics *sql.Stmt\n\taddForumPermsToForum *sql.Stmt\n\tupdateEmail *sql.Stmt\n\tsetTempGroup *sql.Stmt\n\tbumpSync *sql.Stmt\n\tdeleteActivityStreamMatch *sql.Stmt\n\n\tgetActivityFeedByWatcher *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_mssql() (err error) {\n\tcommon.DebugLog(\"Building the generated statements\")\n\t\n\tcommon.DebugLog(\"Preparing forumEntryExists statement.\")\n\tstmts.forumEntryExists, err = db.Prepare(\"SELECT [fid] FROM [forums] WHERE [name] = '' ORDER BY fid ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY\")\n\tif err != nil {\n\t\tlog.Print(\"Error in forumEntryExists statement.\")\n\t\tlog.Print(\"Bad Query: \",\"SELECT [fid] FROM [forums] WHERE [name] = '' ORDER BY fid ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing groupEntryExists statement.\")\n\tstmts.groupEntryExists, err = db.Prepare(\"SELECT [gid] FROM [users_groups] WHERE [name] = '' ORDER BY gid ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY\")\n\tif err != nil {\n\t\tlog.Print(\"Error in groupEntryExists statement.\")\n\t\tlog.Print(\"Bad Query: \",\"SELECT [gid] FROM [users_groups] WHERE [name] = '' ORDER BY gid ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing getForumTopics statement.\")\n\tstmts.getForumTopics, err = db.Prepare(\"SELECT [topics].[tid],[topics].[title],[topics].[content],[topics].[createdBy],[topics].[is_closed],[topics].[sticky],[topics].[createdAt],[topics].[lastReplyAt],[topics].[parentID],[users].[name],[users].[avatar] FROM [topics] LEFT JOIN [users] ON [topics].[createdBy]=[users].[uid]  WHERE [topics].[parentID] = ?1 ORDER BY topics.sticky DESC,topics.lastReplyAt DESC,topics.createdBy DESC\")\n\tif err != nil {\n\t\tlog.Print(\"Error in getForumTopics statement.\")\n\t\tlog.Print(\"Bad Query: \",\"SELECT [topics].[tid],[topics].[title],[topics].[content],[topics].[createdBy],[topics].[is_closed],[topics].[sticky],[topics].[createdAt],[topics].[lastReplyAt],[topics].[parentID],[users].[name],[users].[avatar] FROM [topics] LEFT JOIN [users] ON [topics].[createdBy]=[users].[uid]  WHERE [topics].[parentID] = ?1 ORDER BY topics.sticky DESC,topics.lastReplyAt DESC,topics.createdBy DESC\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing addForumPermsToForum statement.\")\n\tstmts.addForumPermsToForum, err = db.Prepare(\"INSERT INTO [forums_permissions] ([gid],[fid],[preset],[permissions]) VALUES (?,?,?,?)\")\n\tif err != nil {\n\t\tlog.Print(\"Error in addForumPermsToForum statement.\")\n\t\tlog.Print(\"Bad Query: \",\"INSERT INTO [forums_permissions] ([gid],[fid],[preset],[permissions]) VALUES (?,?,?,?)\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing updateEmail statement.\")\n\tstmts.updateEmail, err = db.Prepare(\"UPDATE [emails] SET [email]= ?,[uid]= ?,[validated]= ?,[token]= ? WHERE [email] = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in updateEmail statement.\")\n\t\tlog.Print(\"Bad Query: \",\"UPDATE [emails] SET [email]= ?,[uid]= ?,[validated]= ?,[token]= ? WHERE [email] = ?\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing setTempGroup statement.\")\n\tstmts.setTempGroup, err = db.Prepare(\"UPDATE [users] SET [temp_group]= ? WHERE [uid] = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in setTempGroup statement.\")\n\t\tlog.Print(\"Bad Query: \",\"UPDATE [users] SET [temp_group]= ? WHERE [uid] = ?\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing bumpSync statement.\")\n\tstmts.bumpSync, err = db.Prepare(\"UPDATE [sync] SET [last_update]= GETUTCDATE()\")\n\tif err != nil {\n\t\tlog.Print(\"Error in bumpSync statement.\")\n\t\tlog.Print(\"Bad Query: \",\"UPDATE [sync] SET [last_update]= GETUTCDATE()\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing deleteActivityStreamMatch statement.\")\n\tstmts.deleteActivityStreamMatch, err = db.Prepare(\"DELETE FROM [activity_stream_matches] WHERE [watcher] = ? AND [asid] = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in deleteActivityStreamMatch statement.\")\n\t\tlog.Print(\"Bad Query: \",\"DELETE FROM [activity_stream_matches] WHERE [watcher] = ? AND [asid] = ?\")\n\t\treturn err\n\t}\n\t\n\treturn nil\n}\n"
  },
  {
    "path": "gen_mysql.go",
    "content": "// +build !pgsql,!mssql\n\n/* This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time. */\n\npackage main\n\nimport \"log\"\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/common\"\n//import \"github.com/Azareal/Gosora/query_gen\"\n\n// nolint\ntype Stmts struct {\n\tforumEntryExists *sql.Stmt\n\tgroupEntryExists *sql.Stmt\n\tgetForumTopics *sql.Stmt\n\taddForumPermsToForum *sql.Stmt\n\tupdateEmail *sql.Stmt\n\tsetTempGroup *sql.Stmt\n\tbumpSync *sql.Stmt\n\tdeleteActivityStreamMatch *sql.Stmt\n\n\tgetActivityFeedByWatcher *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_mysql() (err error) {\n\tcommon.DebugLog(\"Building the generated statements\")\n\t\n\tcommon.DebugLog(\"Preparing forumEntryExists statement.\")\n\tstmts.forumEntryExists, err = db.Prepare(\"SELECT `fid` FROM `forums` WHERE `name` = '' ORDER BY `fid` ASC LIMIT 0,1\")\n\tif err != nil {\n\t\tlog.Print(\"Error in forumEntryExists statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing groupEntryExists statement.\")\n\tstmts.groupEntryExists, err = db.Prepare(\"SELECT `gid` FROM `users_groups` WHERE `name` = '' ORDER BY `gid` ASC LIMIT 0,1\")\n\tif err != nil {\n\t\tlog.Print(\"Error in groupEntryExists statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing getForumTopics statement.\")\n\tstmts.getForumTopics, err = db.Prepare(\"SELECT `topics`.`tid`, `topics`.`title`, `topics`.`content`, `topics`.`createdBy`, `topics`.`is_closed`, `topics`.`sticky`, `topics`.`createdAt`, `topics`.`lastReplyAt`, `topics`.`parentID`, `users`.`name`, `users`.`avatar` FROM `topics` LEFT JOIN `users` ON `topics`.`createdBy` = `users`.`uid`  WHERE `topics`.`parentID` = ? ORDER BY `topics`.`sticky` DESC,`topics`.`lastReplyAt` DESC,`topics`.`createdBy` DESC\")\n\tif err != nil {\n\t\tlog.Print(\"Error in getForumTopics statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing addForumPermsToForum statement.\")\n\tstmts.addForumPermsToForum, err = db.Prepare(\"INSERT INTO `forums_permissions`(`gid`,`fid`,`preset`,`permissions`) VALUES (?,?,?,?)\")\n\tif err != nil {\n\t\tlog.Print(\"Error in addForumPermsToForum statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing updateEmail statement.\")\n\tstmts.updateEmail, err = db.Prepare(\"UPDATE `emails` SET `email`= ?,`uid`= ?,`validated`= ?,`token`= ? WHERE `email` = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in updateEmail statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing setTempGroup statement.\")\n\tstmts.setTempGroup, err = db.Prepare(\"UPDATE `users` SET `temp_group`= ? WHERE `uid` = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in setTempGroup statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing bumpSync statement.\")\n\tstmts.bumpSync, err = db.Prepare(\"UPDATE `sync` SET `last_update`= UTC_TIMESTAMP()\")\n\tif err != nil {\n\t\tlog.Print(\"Error in bumpSync statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing deleteActivityStreamMatch statement.\")\n\tstmts.deleteActivityStreamMatch, err = db.Prepare(\"DELETE FROM `activity_stream_matches` WHERE `watcher` = ? AND `asid` = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in deleteActivityStreamMatch statement.\")\n\t\treturn err\n\t}\n\t\n\treturn nil\n}\n"
  },
  {
    "path": "gen_pgsql.go",
    "content": "// +build pgsql\n\n// This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time.\npackage main\n\nimport \"log\"\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/common\"\n\n// nolint\ntype Stmts struct {\n\taddForumPermsToForum *sql.Stmt\n\tupdateEmail *sql.Stmt\n\tsetTempGroup *sql.Stmt\n\tbumpSync *sql.Stmt\n\n\tgetActivityFeedByWatcher *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_pgsql() (err error) {\n\tcommon.DebugLog(\"Building the generated statements\")\n\t\n\tcommon.DebugLog(\"Preparing addForumPermsToForum statement.\")\n\tstmts.addForumPermsToForum, err = db.Prepare(\"INSERT INTO \\\"forums_permissions\\\"(\\\"gid\\\",\\\"fid\\\",\\\"preset\\\",\\\"permissions\\\") VALUES (?,?,?,?)\")\n\tif err != nil {\n\t\tlog.Print(\"Error in addForumPermsToForum statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing updateEmail statement.\")\n\tstmts.updateEmail, err = db.Prepare(\"UPDATE \\\"emails\\\" SET `email`= ?,`uid`= ?,`validated`= ?,`token`= ? WHERE `email` = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in updateEmail statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing setTempGroup statement.\")\n\tstmts.setTempGroup, err = db.Prepare(\"UPDATE \\\"users\\\" SET `temp_group`= ? WHERE `uid` = ?\")\n\tif err != nil {\n\t\tlog.Print(\"Error in setTempGroup statement.\")\n\t\treturn err\n\t}\n\t\t\n\tcommon.DebugLog(\"Preparing bumpSync statement.\")\n\tstmts.bumpSync, err = db.Prepare(\"UPDATE \\\"sync\\\" SET `last_update`= LOCALTIMESTAMP()\")\n\tif err != nil {\n\t\tlog.Print(\"Error in bumpSync statement.\")\n\t\treturn err\n\t}\n\t\n\treturn nil\n}\n"
  },
  {
    "path": "gen_router.go",
    "content": "// Code generated by Gosora's Router Generator. DO NOT EDIT.\n/* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */\npackage main\n\nimport (\n\t\"strings\"\n\t//\"bytes\"\n\t\"strconv\"\n\t\"compress/gzip\"\n\t\"sync/atomic\"\n\t\"errors\"\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/Azareal/Gosora/routes\"\n\t\"github.com/Azareal/Gosora/routes/panel\"\n\n\t//\"github.com/andybalholm/brotli\"\n)\n\nvar ErrNoRoute = errors.New(\"That route doesn't exist.\")\n// TODO: What about the /uploads/ route? x.x\nvar RouteMap = map[string]interface{}{ \n\t\"routes.Error\": routes.Error,\n\t\"routes.Overview\": routes.Overview,\n\t\"routes.CustomPage\": routes.CustomPage,\n\t\"routes.ForumList\": routes.ForumList,\n\t\"routes.ViewForum\": routes.ViewForum,\n\t\"routes.ChangeTheme\": routes.ChangeTheme,\n\t\"routes.ShowAttachment\": routes.ShowAttachment,\n\t\"common.RouteWebsockets\": c.RouteWebsockets,\n\t\"routeAPIPhrases\": routeAPIPhrases,\n\t\"routes.APIMe\": routes.APIMe,\n\t\"routeJSAntispam\": routeJSAntispam,\n\t\"routeAPI\": routeAPI,\n\t\"routes.ReportSubmit\": routes.ReportSubmit,\n\t\"routes.TopicListMostViewed\": routes.TopicListMostViewed,\n\t\"routes.TopicListWeekViews\": routes.TopicListWeekViews,\n\t\"routes.CreateTopic\": routes.CreateTopic,\n\t\"routes.TopicList\": routes.TopicList,\n\t\"panel.Forums\": panel.Forums,\n\t\"panel.ForumsCreateSubmit\": panel.ForumsCreateSubmit,\n\t\"panel.ForumsDelete\": panel.ForumsDelete,\n\t\"panel.ForumsDeleteSubmit\": panel.ForumsDeleteSubmit,\n\t\"panel.ForumsOrderSubmit\": panel.ForumsOrderSubmit,\n\t\"panel.ForumsEdit\": panel.ForumsEdit,\n\t\"panel.ForumsEditSubmit\": panel.ForumsEditSubmit,\n\t\"panel.ForumsEditPermsSubmit\": panel.ForumsEditPermsSubmit,\n\t\"panel.ForumsEditPermsAdvance\": panel.ForumsEditPermsAdvance,\n\t\"panel.ForumsEditPermsAdvanceSubmit\": panel.ForumsEditPermsAdvanceSubmit,\n\t\"panel.ForumsEditActionCreateSubmit\": panel.ForumsEditActionCreateSubmit,\n\t\"panel.ForumsEditActionDeleteSubmit\": panel.ForumsEditActionDeleteSubmit,\n\t\"panel.Settings\": panel.Settings,\n\t\"panel.SettingEdit\": panel.SettingEdit,\n\t\"panel.SettingEditSubmit\": panel.SettingEditSubmit,\n\t\"panel.WordFilters\": panel.WordFilters,\n\t\"panel.WordFiltersCreateSubmit\": panel.WordFiltersCreateSubmit,\n\t\"panel.WordFiltersEdit\": panel.WordFiltersEdit,\n\t\"panel.WordFiltersEditSubmit\": panel.WordFiltersEditSubmit,\n\t\"panel.WordFiltersDeleteSubmit\": panel.WordFiltersDeleteSubmit,\n\t\"panel.Pages\": panel.Pages,\n\t\"panel.PagesCreateSubmit\": panel.PagesCreateSubmit,\n\t\"panel.PagesEdit\": panel.PagesEdit,\n\t\"panel.PagesEditSubmit\": panel.PagesEditSubmit,\n\t\"panel.PagesDeleteSubmit\": panel.PagesDeleteSubmit,\n\t\"panel.Themes\": panel.Themes,\n\t\"panel.ThemesSetDefault\": panel.ThemesSetDefault,\n\t\"panel.ThemesMenus\": panel.ThemesMenus,\n\t\"panel.ThemesMenusEdit\": panel.ThemesMenusEdit,\n\t\"panel.ThemesMenuItemEdit\": panel.ThemesMenuItemEdit,\n\t\"panel.ThemesMenuItemEditSubmit\": panel.ThemesMenuItemEditSubmit,\n\t\"panel.ThemesMenuItemCreateSubmit\": panel.ThemesMenuItemCreateSubmit,\n\t\"panel.ThemesMenuItemDeleteSubmit\": panel.ThemesMenuItemDeleteSubmit,\n\t\"panel.ThemesMenuItemOrderSubmit\": panel.ThemesMenuItemOrderSubmit,\n\t\"panel.ThemesWidgets\": panel.ThemesWidgets,\n\t\"panel.ThemesWidgetsEditSubmit\": panel.ThemesWidgetsEditSubmit,\n\t\"panel.ThemesWidgetsCreateSubmit\": panel.ThemesWidgetsCreateSubmit,\n\t\"panel.ThemesWidgetsDeleteSubmit\": panel.ThemesWidgetsDeleteSubmit,\n\t\"panel.Plugins\": panel.Plugins,\n\t\"panel.PluginsActivate\": panel.PluginsActivate,\n\t\"panel.PluginsDeactivate\": panel.PluginsDeactivate,\n\t\"panel.PluginsInstall\": panel.PluginsInstall,\n\t\"panel.Users\": panel.Users,\n\t\"panel.UsersEdit\": panel.UsersEdit,\n\t\"panel.UsersEditSubmit\": panel.UsersEditSubmit,\n\t\"panel.UsersAvatarSubmit\": panel.UsersAvatarSubmit,\n\t\"panel.UsersAvatarRemoveSubmit\": panel.UsersAvatarRemoveSubmit,\n\t\"panel.AnalyticsViews\": panel.AnalyticsViews,\n\t\"panel.AnalyticsRoutes\": panel.AnalyticsRoutes,\n\t\"panel.AnalyticsRoutesPerf\": panel.AnalyticsRoutesPerf,\n\t\"panel.AnalyticsAgents\": panel.AnalyticsAgents,\n\t\"panel.AnalyticsSystems\": panel.AnalyticsSystems,\n\t\"panel.AnalyticsLanguages\": panel.AnalyticsLanguages,\n\t\"panel.AnalyticsReferrers\": panel.AnalyticsReferrers,\n\t\"panel.AnalyticsRouteViews\": panel.AnalyticsRouteViews,\n\t\"panel.AnalyticsAgentViews\": panel.AnalyticsAgentViews,\n\t\"panel.AnalyticsForumViews\": panel.AnalyticsForumViews,\n\t\"panel.AnalyticsSystemViews\": panel.AnalyticsSystemViews,\n\t\"panel.AnalyticsLanguageViews\": panel.AnalyticsLanguageViews,\n\t\"panel.AnalyticsReferrerViews\": panel.AnalyticsReferrerViews,\n\t\"panel.AnalyticsPosts\": panel.AnalyticsPosts,\n\t\"panel.AnalyticsMemory\": panel.AnalyticsMemory,\n\t\"panel.AnalyticsActiveMemory\": panel.AnalyticsActiveMemory,\n\t\"panel.AnalyticsTopics\": panel.AnalyticsTopics,\n\t\"panel.AnalyticsForums\": panel.AnalyticsForums,\n\t\"panel.AnalyticsPerf\": panel.AnalyticsPerf,\n\t\"panel.Groups\": panel.Groups,\n\t\"panel.GroupsEdit\": panel.GroupsEdit,\n\t\"panel.GroupsEditPromotions\": panel.GroupsEditPromotions,\n\t\"panel.GroupsPromotionsCreateSubmit\": panel.GroupsPromotionsCreateSubmit,\n\t\"panel.GroupsPromotionsDeleteSubmit\": panel.GroupsPromotionsDeleteSubmit,\n\t\"panel.GroupsEditPerms\": panel.GroupsEditPerms,\n\t\"panel.GroupsEditSubmit\": panel.GroupsEditSubmit,\n\t\"panel.GroupsEditPermsSubmit\": panel.GroupsEditPermsSubmit,\n\t\"panel.GroupsCreateSubmit\": panel.GroupsCreateSubmit,\n\t\"panel.Backups\": panel.Backups,\n\t\"panel.LogsRegs\": panel.LogsRegs,\n\t\"panel.LogsMod\": panel.LogsMod,\n\t\"panel.LogsAdmin\": panel.LogsAdmin,\n\t\"panel.Debug\": panel.Debug,\n\t\"panel.DebugTasks\": panel.DebugTasks,\n\t\"panel.Dashboard\": panel.Dashboard,\n\t\"routes.AccountEdit\": routes.AccountEdit,\n\t\"routes.AccountEditPassword\": routes.AccountEditPassword,\n\t\"routes.AccountEditPasswordSubmit\": routes.AccountEditPasswordSubmit,\n\t\"routes.AccountEditAvatarSubmit\": routes.AccountEditAvatarSubmit,\n\t\"routes.AccountEditRevokeAvatarSubmit\": routes.AccountEditRevokeAvatarSubmit,\n\t\"routes.AccountEditUsernameSubmit\": routes.AccountEditUsernameSubmit,\n\t\"routes.AccountEditPrivacy\": routes.AccountEditPrivacy,\n\t\"routes.AccountEditPrivacySubmit\": routes.AccountEditPrivacySubmit,\n\t\"routes.AccountEditMFA\": routes.AccountEditMFA,\n\t\"routes.AccountEditMFASetup\": routes.AccountEditMFASetup,\n\t\"routes.AccountEditMFASetupSubmit\": routes.AccountEditMFASetupSubmit,\n\t\"routes.AccountEditMFADisableSubmit\": routes.AccountEditMFADisableSubmit,\n\t\"routes.AccountEditEmail\": routes.AccountEditEmail,\n\t\"routes.AccountEditEmailTokenSubmit\": routes.AccountEditEmailTokenSubmit,\n\t\"routes.AccountLogins\": routes.AccountLogins,\n\t\"routes.AccountBlocked\": routes.AccountBlocked,\n\t\"routes.LevelList\": routes.LevelList,\n\t\"routes.Convos\": routes.Convos,\n\t\"routes.ConvosCreate\": routes.ConvosCreate,\n\t\"routes.Convo\": routes.Convo,\n\t\"routes.ConvosCreateSubmit\": routes.ConvosCreateSubmit,\n\t\"routes.ConvosCreateReplySubmit\": routes.ConvosCreateReplySubmit,\n\t\"routes.ConvosDeleteReplySubmit\": routes.ConvosDeleteReplySubmit,\n\t\"routes.ConvosEditReplySubmit\": routes.ConvosEditReplySubmit,\n\t\"routes.RelationsBlockCreate\": routes.RelationsBlockCreate,\n\t\"routes.RelationsBlockCreateSubmit\": routes.RelationsBlockCreateSubmit,\n\t\"routes.RelationsBlockRemove\": routes.RelationsBlockRemove,\n\t\"routes.RelationsBlockRemoveSubmit\": routes.RelationsBlockRemoveSubmit,\n\t\"routes.ViewProfile\": routes.ViewProfile,\n\t\"routes.BanUserSubmit\": routes.BanUserSubmit,\n\t\"routes.UnbanUser\": routes.UnbanUser,\n\t\"routes.ActivateUser\": routes.ActivateUser,\n\t\"routes.IPSearch\": routes.IPSearch,\n\t\"routes.DeletePostsSubmit\": routes.DeletePostsSubmit,\n\t\"routes.CreateTopicSubmit\": routes.CreateTopicSubmit,\n\t\"routes.EditTopicSubmit\": routes.EditTopicSubmit,\n\t\"routes.DeleteTopicSubmit\": routes.DeleteTopicSubmit,\n\t\"routes.StickTopicSubmit\": routes.StickTopicSubmit,\n\t\"routes.UnstickTopicSubmit\": routes.UnstickTopicSubmit,\n\t\"routes.LockTopicSubmit\": routes.LockTopicSubmit,\n\t\"routes.UnlockTopicSubmit\": routes.UnlockTopicSubmit,\n\t\"routes.MoveTopicSubmit\": routes.MoveTopicSubmit,\n\t\"routes.LikeTopicSubmit\": routes.LikeTopicSubmit,\n\t\"routes.UnlikeTopicSubmit\": routes.UnlikeTopicSubmit,\n\t\"routes.AddAttachToTopicSubmit\": routes.AddAttachToTopicSubmit,\n\t\"routes.RemoveAttachFromTopicSubmit\": routes.RemoveAttachFromTopicSubmit,\n\t\"routes.ViewTopic\": routes.ViewTopic,\n\t\"routes.CreateReplySubmit\": routes.CreateReplySubmit,\n\t\"routes.ReplyEditSubmit\": routes.ReplyEditSubmit,\n\t\"routes.ReplyDeleteSubmit\": routes.ReplyDeleteSubmit,\n\t\"routes.ReplyLikeSubmit\": routes.ReplyLikeSubmit,\n\t\"routes.ReplyUnlikeSubmit\": routes.ReplyUnlikeSubmit,\n\t\"routes.AddAttachToReplySubmit\": routes.AddAttachToReplySubmit,\n\t\"routes.RemoveAttachFromReplySubmit\": routes.RemoveAttachFromReplySubmit,\n\t\"routes.ProfileReplyCreateSubmit\": routes.ProfileReplyCreateSubmit,\n\t\"routes.ProfileReplyEditSubmit\": routes.ProfileReplyEditSubmit,\n\t\"routes.ProfileReplyDeleteSubmit\": routes.ProfileReplyDeleteSubmit,\n\t\"routes.PollVote\": routes.PollVote,\n\t\"routes.PollResults\": routes.PollResults,\n\t\"routes.AccountLogin\": routes.AccountLogin,\n\t\"routes.AccountRegister\": routes.AccountRegister,\n\t\"routes.AccountLogout\": routes.AccountLogout,\n\t\"routes.AccountLoginSubmit\": routes.AccountLoginSubmit,\n\t\"routes.AccountLoginMFAVerify\": routes.AccountLoginMFAVerify,\n\t\"routes.AccountLoginMFAVerifySubmit\": routes.AccountLoginMFAVerifySubmit,\n\t\"routes.AccountRegisterSubmit\": routes.AccountRegisterSubmit,\n\t\"routes.AccountPasswordReset\": routes.AccountPasswordReset,\n\t\"routes.AccountPasswordResetSubmit\": routes.AccountPasswordResetSubmit,\n\t\"routes.AccountPasswordResetToken\": routes.AccountPasswordResetToken,\n\t\"routes.AccountPasswordResetTokenSubmit\": routes.AccountPasswordResetTokenSubmit,\n\t\"routes.DynamicRoute\": routes.DynamicRoute,\n\t\"routes.UploadedFile\": routes.UploadedFile,\n\t\"routes.StaticFile\": routes.StaticFile,\n\t\"routes.RobotsTxt\": routes.RobotsTxt,\n\t\"routes.SitemapXml\": routes.SitemapXml,\n\t\"routes.OpenSearchXml\": routes.OpenSearchXml,\n\t\"routes.Favicon\": routes.Favicon,\n\t\"routes.BadRoute\": routes.BadRoute,\n\t\"routes.HTTPSRedirect\": routes.HTTPSRedirect,\n}\n\n// ! NEVER RELY ON THESE REMAINING THE SAME BETWEEN COMMITS\nvar routeMapEnum = map[string]int{ \n\t\"routes.Error\": 0,\n\t\"routes.Overview\": 1,\n\t\"routes.CustomPage\": 2,\n\t\"routes.ForumList\": 3,\n\t\"routes.ViewForum\": 4,\n\t\"routes.ChangeTheme\": 5,\n\t\"routes.ShowAttachment\": 6,\n\t\"common.RouteWebsockets\": 7,\n\t\"routeAPIPhrases\": 8,\n\t\"routes.APIMe\": 9,\n\t\"routeJSAntispam\": 10,\n\t\"routeAPI\": 11,\n\t\"routes.ReportSubmit\": 12,\n\t\"routes.TopicListMostViewed\": 13,\n\t\"routes.TopicListWeekViews\": 14,\n\t\"routes.CreateTopic\": 15,\n\t\"routes.TopicList\": 16,\n\t\"panel.Forums\": 17,\n\t\"panel.ForumsCreateSubmit\": 18,\n\t\"panel.ForumsDelete\": 19,\n\t\"panel.ForumsDeleteSubmit\": 20,\n\t\"panel.ForumsOrderSubmit\": 21,\n\t\"panel.ForumsEdit\": 22,\n\t\"panel.ForumsEditSubmit\": 23,\n\t\"panel.ForumsEditPermsSubmit\": 24,\n\t\"panel.ForumsEditPermsAdvance\": 25,\n\t\"panel.ForumsEditPermsAdvanceSubmit\": 26,\n\t\"panel.ForumsEditActionCreateSubmit\": 27,\n\t\"panel.ForumsEditActionDeleteSubmit\": 28,\n\t\"panel.Settings\": 29,\n\t\"panel.SettingEdit\": 30,\n\t\"panel.SettingEditSubmit\": 31,\n\t\"panel.WordFilters\": 32,\n\t\"panel.WordFiltersCreateSubmit\": 33,\n\t\"panel.WordFiltersEdit\": 34,\n\t\"panel.WordFiltersEditSubmit\": 35,\n\t\"panel.WordFiltersDeleteSubmit\": 36,\n\t\"panel.Pages\": 37,\n\t\"panel.PagesCreateSubmit\": 38,\n\t\"panel.PagesEdit\": 39,\n\t\"panel.PagesEditSubmit\": 40,\n\t\"panel.PagesDeleteSubmit\": 41,\n\t\"panel.Themes\": 42,\n\t\"panel.ThemesSetDefault\": 43,\n\t\"panel.ThemesMenus\": 44,\n\t\"panel.ThemesMenusEdit\": 45,\n\t\"panel.ThemesMenuItemEdit\": 46,\n\t\"panel.ThemesMenuItemEditSubmit\": 47,\n\t\"panel.ThemesMenuItemCreateSubmit\": 48,\n\t\"panel.ThemesMenuItemDeleteSubmit\": 49,\n\t\"panel.ThemesMenuItemOrderSubmit\": 50,\n\t\"panel.ThemesWidgets\": 51,\n\t\"panel.ThemesWidgetsEditSubmit\": 52,\n\t\"panel.ThemesWidgetsCreateSubmit\": 53,\n\t\"panel.ThemesWidgetsDeleteSubmit\": 54,\n\t\"panel.Plugins\": 55,\n\t\"panel.PluginsActivate\": 56,\n\t\"panel.PluginsDeactivate\": 57,\n\t\"panel.PluginsInstall\": 58,\n\t\"panel.Users\": 59,\n\t\"panel.UsersEdit\": 60,\n\t\"panel.UsersEditSubmit\": 61,\n\t\"panel.UsersAvatarSubmit\": 62,\n\t\"panel.UsersAvatarRemoveSubmit\": 63,\n\t\"panel.AnalyticsViews\": 64,\n\t\"panel.AnalyticsRoutes\": 65,\n\t\"panel.AnalyticsRoutesPerf\": 66,\n\t\"panel.AnalyticsAgents\": 67,\n\t\"panel.AnalyticsSystems\": 68,\n\t\"panel.AnalyticsLanguages\": 69,\n\t\"panel.AnalyticsReferrers\": 70,\n\t\"panel.AnalyticsRouteViews\": 71,\n\t\"panel.AnalyticsAgentViews\": 72,\n\t\"panel.AnalyticsForumViews\": 73,\n\t\"panel.AnalyticsSystemViews\": 74,\n\t\"panel.AnalyticsLanguageViews\": 75,\n\t\"panel.AnalyticsReferrerViews\": 76,\n\t\"panel.AnalyticsPosts\": 77,\n\t\"panel.AnalyticsMemory\": 78,\n\t\"panel.AnalyticsActiveMemory\": 79,\n\t\"panel.AnalyticsTopics\": 80,\n\t\"panel.AnalyticsForums\": 81,\n\t\"panel.AnalyticsPerf\": 82,\n\t\"panel.Groups\": 83,\n\t\"panel.GroupsEdit\": 84,\n\t\"panel.GroupsEditPromotions\": 85,\n\t\"panel.GroupsPromotionsCreateSubmit\": 86,\n\t\"panel.GroupsPromotionsDeleteSubmit\": 87,\n\t\"panel.GroupsEditPerms\": 88,\n\t\"panel.GroupsEditSubmit\": 89,\n\t\"panel.GroupsEditPermsSubmit\": 90,\n\t\"panel.GroupsCreateSubmit\": 91,\n\t\"panel.Backups\": 92,\n\t\"panel.LogsRegs\": 93,\n\t\"panel.LogsMod\": 94,\n\t\"panel.LogsAdmin\": 95,\n\t\"panel.Debug\": 96,\n\t\"panel.DebugTasks\": 97,\n\t\"panel.Dashboard\": 98,\n\t\"routes.AccountEdit\": 99,\n\t\"routes.AccountEditPassword\": 100,\n\t\"routes.AccountEditPasswordSubmit\": 101,\n\t\"routes.AccountEditAvatarSubmit\": 102,\n\t\"routes.AccountEditRevokeAvatarSubmit\": 103,\n\t\"routes.AccountEditUsernameSubmit\": 104,\n\t\"routes.AccountEditPrivacy\": 105,\n\t\"routes.AccountEditPrivacySubmit\": 106,\n\t\"routes.AccountEditMFA\": 107,\n\t\"routes.AccountEditMFASetup\": 108,\n\t\"routes.AccountEditMFASetupSubmit\": 109,\n\t\"routes.AccountEditMFADisableSubmit\": 110,\n\t\"routes.AccountEditEmail\": 111,\n\t\"routes.AccountEditEmailTokenSubmit\": 112,\n\t\"routes.AccountLogins\": 113,\n\t\"routes.AccountBlocked\": 114,\n\t\"routes.LevelList\": 115,\n\t\"routes.Convos\": 116,\n\t\"routes.ConvosCreate\": 117,\n\t\"routes.Convo\": 118,\n\t\"routes.ConvosCreateSubmit\": 119,\n\t\"routes.ConvosCreateReplySubmit\": 120,\n\t\"routes.ConvosDeleteReplySubmit\": 121,\n\t\"routes.ConvosEditReplySubmit\": 122,\n\t\"routes.RelationsBlockCreate\": 123,\n\t\"routes.RelationsBlockCreateSubmit\": 124,\n\t\"routes.RelationsBlockRemove\": 125,\n\t\"routes.RelationsBlockRemoveSubmit\": 126,\n\t\"routes.ViewProfile\": 127,\n\t\"routes.BanUserSubmit\": 128,\n\t\"routes.UnbanUser\": 129,\n\t\"routes.ActivateUser\": 130,\n\t\"routes.IPSearch\": 131,\n\t\"routes.DeletePostsSubmit\": 132,\n\t\"routes.CreateTopicSubmit\": 133,\n\t\"routes.EditTopicSubmit\": 134,\n\t\"routes.DeleteTopicSubmit\": 135,\n\t\"routes.StickTopicSubmit\": 136,\n\t\"routes.UnstickTopicSubmit\": 137,\n\t\"routes.LockTopicSubmit\": 138,\n\t\"routes.UnlockTopicSubmit\": 139,\n\t\"routes.MoveTopicSubmit\": 140,\n\t\"routes.LikeTopicSubmit\": 141,\n\t\"routes.UnlikeTopicSubmit\": 142,\n\t\"routes.AddAttachToTopicSubmit\": 143,\n\t\"routes.RemoveAttachFromTopicSubmit\": 144,\n\t\"routes.ViewTopic\": 145,\n\t\"routes.CreateReplySubmit\": 146,\n\t\"routes.ReplyEditSubmit\": 147,\n\t\"routes.ReplyDeleteSubmit\": 148,\n\t\"routes.ReplyLikeSubmit\": 149,\n\t\"routes.ReplyUnlikeSubmit\": 150,\n\t\"routes.AddAttachToReplySubmit\": 151,\n\t\"routes.RemoveAttachFromReplySubmit\": 152,\n\t\"routes.ProfileReplyCreateSubmit\": 153,\n\t\"routes.ProfileReplyEditSubmit\": 154,\n\t\"routes.ProfileReplyDeleteSubmit\": 155,\n\t\"routes.PollVote\": 156,\n\t\"routes.PollResults\": 157,\n\t\"routes.AccountLogin\": 158,\n\t\"routes.AccountRegister\": 159,\n\t\"routes.AccountLogout\": 160,\n\t\"routes.AccountLoginSubmit\": 161,\n\t\"routes.AccountLoginMFAVerify\": 162,\n\t\"routes.AccountLoginMFAVerifySubmit\": 163,\n\t\"routes.AccountRegisterSubmit\": 164,\n\t\"routes.AccountPasswordReset\": 165,\n\t\"routes.AccountPasswordResetSubmit\": 166,\n\t\"routes.AccountPasswordResetToken\": 167,\n\t\"routes.AccountPasswordResetTokenSubmit\": 168,\n\t\"routes.DynamicRoute\": 169,\n\t\"routes.UploadedFile\": 170,\n\t\"routes.StaticFile\": 171,\n\t\"routes.RobotsTxt\": 172,\n\t\"routes.SitemapXml\": 173,\n\t\"routes.OpenSearchXml\": 174,\n\t\"routes.Favicon\": 175,\n\t\"routes.BadRoute\": 176,\n\t\"routes.HTTPSRedirect\": 177,\n}\nvar reverseRouteMapEnum = map[int]string{ \n\t0: \"routes.Error\",\n\t1: \"routes.Overview\",\n\t2: \"routes.CustomPage\",\n\t3: \"routes.ForumList\",\n\t4: \"routes.ViewForum\",\n\t5: \"routes.ChangeTheme\",\n\t6: \"routes.ShowAttachment\",\n\t7: \"common.RouteWebsockets\",\n\t8: \"routeAPIPhrases\",\n\t9: \"routes.APIMe\",\n\t10: \"routeJSAntispam\",\n\t11: \"routeAPI\",\n\t12: \"routes.ReportSubmit\",\n\t13: \"routes.TopicListMostViewed\",\n\t14: \"routes.TopicListWeekViews\",\n\t15: \"routes.CreateTopic\",\n\t16: \"routes.TopicList\",\n\t17: \"panel.Forums\",\n\t18: \"panel.ForumsCreateSubmit\",\n\t19: \"panel.ForumsDelete\",\n\t20: \"panel.ForumsDeleteSubmit\",\n\t21: \"panel.ForumsOrderSubmit\",\n\t22: \"panel.ForumsEdit\",\n\t23: \"panel.ForumsEditSubmit\",\n\t24: \"panel.ForumsEditPermsSubmit\",\n\t25: \"panel.ForumsEditPermsAdvance\",\n\t26: \"panel.ForumsEditPermsAdvanceSubmit\",\n\t27: \"panel.ForumsEditActionCreateSubmit\",\n\t28: \"panel.ForumsEditActionDeleteSubmit\",\n\t29: \"panel.Settings\",\n\t30: \"panel.SettingEdit\",\n\t31: \"panel.SettingEditSubmit\",\n\t32: \"panel.WordFilters\",\n\t33: \"panel.WordFiltersCreateSubmit\",\n\t34: \"panel.WordFiltersEdit\",\n\t35: \"panel.WordFiltersEditSubmit\",\n\t36: \"panel.WordFiltersDeleteSubmit\",\n\t37: \"panel.Pages\",\n\t38: \"panel.PagesCreateSubmit\",\n\t39: \"panel.PagesEdit\",\n\t40: \"panel.PagesEditSubmit\",\n\t41: \"panel.PagesDeleteSubmit\",\n\t42: \"panel.Themes\",\n\t43: \"panel.ThemesSetDefault\",\n\t44: \"panel.ThemesMenus\",\n\t45: \"panel.ThemesMenusEdit\",\n\t46: \"panel.ThemesMenuItemEdit\",\n\t47: \"panel.ThemesMenuItemEditSubmit\",\n\t48: \"panel.ThemesMenuItemCreateSubmit\",\n\t49: \"panel.ThemesMenuItemDeleteSubmit\",\n\t50: \"panel.ThemesMenuItemOrderSubmit\",\n\t51: \"panel.ThemesWidgets\",\n\t52: \"panel.ThemesWidgetsEditSubmit\",\n\t53: \"panel.ThemesWidgetsCreateSubmit\",\n\t54: \"panel.ThemesWidgetsDeleteSubmit\",\n\t55: \"panel.Plugins\",\n\t56: \"panel.PluginsActivate\",\n\t57: \"panel.PluginsDeactivate\",\n\t58: \"panel.PluginsInstall\",\n\t59: \"panel.Users\",\n\t60: \"panel.UsersEdit\",\n\t61: \"panel.UsersEditSubmit\",\n\t62: \"panel.UsersAvatarSubmit\",\n\t63: \"panel.UsersAvatarRemoveSubmit\",\n\t64: \"panel.AnalyticsViews\",\n\t65: \"panel.AnalyticsRoutes\",\n\t66: \"panel.AnalyticsRoutesPerf\",\n\t67: \"panel.AnalyticsAgents\",\n\t68: \"panel.AnalyticsSystems\",\n\t69: \"panel.AnalyticsLanguages\",\n\t70: \"panel.AnalyticsReferrers\",\n\t71: \"panel.AnalyticsRouteViews\",\n\t72: \"panel.AnalyticsAgentViews\",\n\t73: \"panel.AnalyticsForumViews\",\n\t74: \"panel.AnalyticsSystemViews\",\n\t75: \"panel.AnalyticsLanguageViews\",\n\t76: \"panel.AnalyticsReferrerViews\",\n\t77: \"panel.AnalyticsPosts\",\n\t78: \"panel.AnalyticsMemory\",\n\t79: \"panel.AnalyticsActiveMemory\",\n\t80: \"panel.AnalyticsTopics\",\n\t81: \"panel.AnalyticsForums\",\n\t82: \"panel.AnalyticsPerf\",\n\t83: \"panel.Groups\",\n\t84: \"panel.GroupsEdit\",\n\t85: \"panel.GroupsEditPromotions\",\n\t86: \"panel.GroupsPromotionsCreateSubmit\",\n\t87: \"panel.GroupsPromotionsDeleteSubmit\",\n\t88: \"panel.GroupsEditPerms\",\n\t89: \"panel.GroupsEditSubmit\",\n\t90: \"panel.GroupsEditPermsSubmit\",\n\t91: \"panel.GroupsCreateSubmit\",\n\t92: \"panel.Backups\",\n\t93: \"panel.LogsRegs\",\n\t94: \"panel.LogsMod\",\n\t95: \"panel.LogsAdmin\",\n\t96: \"panel.Debug\",\n\t97: \"panel.DebugTasks\",\n\t98: \"panel.Dashboard\",\n\t99: \"routes.AccountEdit\",\n\t100: \"routes.AccountEditPassword\",\n\t101: \"routes.AccountEditPasswordSubmit\",\n\t102: \"routes.AccountEditAvatarSubmit\",\n\t103: \"routes.AccountEditRevokeAvatarSubmit\",\n\t104: \"routes.AccountEditUsernameSubmit\",\n\t105: \"routes.AccountEditPrivacy\",\n\t106: \"routes.AccountEditPrivacySubmit\",\n\t107: \"routes.AccountEditMFA\",\n\t108: \"routes.AccountEditMFASetup\",\n\t109: \"routes.AccountEditMFASetupSubmit\",\n\t110: \"routes.AccountEditMFADisableSubmit\",\n\t111: \"routes.AccountEditEmail\",\n\t112: \"routes.AccountEditEmailTokenSubmit\",\n\t113: \"routes.AccountLogins\",\n\t114: \"routes.AccountBlocked\",\n\t115: \"routes.LevelList\",\n\t116: \"routes.Convos\",\n\t117: \"routes.ConvosCreate\",\n\t118: \"routes.Convo\",\n\t119: \"routes.ConvosCreateSubmit\",\n\t120: \"routes.ConvosCreateReplySubmit\",\n\t121: \"routes.ConvosDeleteReplySubmit\",\n\t122: \"routes.ConvosEditReplySubmit\",\n\t123: \"routes.RelationsBlockCreate\",\n\t124: \"routes.RelationsBlockCreateSubmit\",\n\t125: \"routes.RelationsBlockRemove\",\n\t126: \"routes.RelationsBlockRemoveSubmit\",\n\t127: \"routes.ViewProfile\",\n\t128: \"routes.BanUserSubmit\",\n\t129: \"routes.UnbanUser\",\n\t130: \"routes.ActivateUser\",\n\t131: \"routes.IPSearch\",\n\t132: \"routes.DeletePostsSubmit\",\n\t133: \"routes.CreateTopicSubmit\",\n\t134: \"routes.EditTopicSubmit\",\n\t135: \"routes.DeleteTopicSubmit\",\n\t136: \"routes.StickTopicSubmit\",\n\t137: \"routes.UnstickTopicSubmit\",\n\t138: \"routes.LockTopicSubmit\",\n\t139: \"routes.UnlockTopicSubmit\",\n\t140: \"routes.MoveTopicSubmit\",\n\t141: \"routes.LikeTopicSubmit\",\n\t142: \"routes.UnlikeTopicSubmit\",\n\t143: \"routes.AddAttachToTopicSubmit\",\n\t144: \"routes.RemoveAttachFromTopicSubmit\",\n\t145: \"routes.ViewTopic\",\n\t146: \"routes.CreateReplySubmit\",\n\t147: \"routes.ReplyEditSubmit\",\n\t148: \"routes.ReplyDeleteSubmit\",\n\t149: \"routes.ReplyLikeSubmit\",\n\t150: \"routes.ReplyUnlikeSubmit\",\n\t151: \"routes.AddAttachToReplySubmit\",\n\t152: \"routes.RemoveAttachFromReplySubmit\",\n\t153: \"routes.ProfileReplyCreateSubmit\",\n\t154: \"routes.ProfileReplyEditSubmit\",\n\t155: \"routes.ProfileReplyDeleteSubmit\",\n\t156: \"routes.PollVote\",\n\t157: \"routes.PollResults\",\n\t158: \"routes.AccountLogin\",\n\t159: \"routes.AccountRegister\",\n\t160: \"routes.AccountLogout\",\n\t161: \"routes.AccountLoginSubmit\",\n\t162: \"routes.AccountLoginMFAVerify\",\n\t163: \"routes.AccountLoginMFAVerifySubmit\",\n\t164: \"routes.AccountRegisterSubmit\",\n\t165: \"routes.AccountPasswordReset\",\n\t166: \"routes.AccountPasswordResetSubmit\",\n\t167: \"routes.AccountPasswordResetToken\",\n\t168: \"routes.AccountPasswordResetTokenSubmit\",\n\t169: \"routes.DynamicRoute\",\n\t170: \"routes.UploadedFile\",\n\t171: \"routes.StaticFile\",\n\t172: \"routes.RobotsTxt\",\n\t173: \"routes.SitemapXml\",\n\t174: \"routes.OpenSearchXml\",\n\t175: \"routes.Favicon\",\n\t176: \"routes.BadRoute\",\n\t177: \"routes.HTTPSRedirect\",\n}\nvar osMapEnum = map[string]int{ \n\t\"unknown\": 0,\n\t\"windows\": 1,\n\t\"linux\": 2,\n\t\"mac\": 3,\n\t\"android\": 4,\n\t\"iphone\": 5,\n}\nvar reverseOSMapEnum = map[int]string{ \n\t0: \"unknown\",\n\t1: \"windows\",\n\t2: \"linux\",\n\t3: \"mac\",\n\t4: \"android\",\n\t5: \"iphone\",\n}\nvar agentMapEnum = map[string]int{ \n\t\"unknown\": 0,\n\t\"opera\": 1,\n\t\"chrome\": 2,\n\t\"firefox\": 3,\n\t\"safari\": 4,\n\t\"edge\": 5,\n\t\"internetexplorer\": 6,\n\t\"trident\": 7,\n\t\"androidchrome\": 8,\n\t\"mobilesafari\": 9,\n\t\"samsung\": 10,\n\t\"ucbrowser\": 11,\n\t\"googlebot\": 12,\n\t\"yandex\": 13,\n\t\"bing\": 14,\n\t\"slurp\": 15,\n\t\"exabot\": 16,\n\t\"mojeek\": 17,\n\t\"cliqz\": 18,\n\t\"qwant\": 19,\n\t\"datenbank\": 20,\n\t\"baidu\": 21,\n\t\"sogou\": 22,\n\t\"toutiao\": 23,\n\t\"haosou\": 24,\n\t\"duckduckgo\": 25,\n\t\"seznambot\": 26,\n\t\"discord\": 27,\n\t\"telegram\": 28,\n\t\"twitter\": 29,\n\t\"facebook\": 30,\n\t\"cloudflare\": 31,\n\t\"archive_org\": 32,\n\t\"uptimebot\": 33,\n\t\"slackbot\": 34,\n\t\"apple\": 35,\n\t\"discourse\": 36,\n\t\"xenforo\": 37,\n\t\"mattermost\": 38,\n\t\"alexa\": 39,\n\t\"lynx\": 40,\n\t\"blank\": 41,\n\t\"malformed\": 42,\n\t\"suspicious\": 43,\n\t\"semrush\": 44,\n\t\"dotbot\": 45,\n\t\"ahrefs\": 46,\n\t\"proximic\": 47,\n\t\"megaindex\": 48,\n\t\"majestic\": 49,\n\t\"cocolyze\": 50,\n\t\"babbar\": 51,\n\t\"surdotly\": 52,\n\t\"domcop\": 53,\n\t\"netcraft\": 54,\n\t\"seostar\": 55,\n\t\"pandalytics\": 56,\n\t\"blexbot\": 57,\n\t\"wappalyzer\": 58,\n\t\"twingly\": 59,\n\t\"linkfluence\": 60,\n\t\"pagething\": 61,\n\t\"burf\": 62,\n\t\"aspiegel\": 63,\n\t\"mail_ru\": 64,\n\t\"ccbot\": 65,\n\t\"yacy\": 66,\n\t\"zgrab\": 67,\n\t\"cloudsystemnetworks\": 68,\n\t\"maui\": 69,\n\t\"curl\": 70,\n\t\"python\": 71,\n\t\"headlesschrome\": 72,\n\t\"awesome_bot\": 73,\n}\nvar reverseAgentMapEnum = map[int]string{ \n\t0: \"unknown\",\n\t1: \"opera\",\n\t2: \"chrome\",\n\t3: \"firefox\",\n\t4: \"safari\",\n\t5: \"edge\",\n\t6: \"internetexplorer\",\n\t7: \"trident\",\n\t8: \"androidchrome\",\n\t9: \"mobilesafari\",\n\t10: \"samsung\",\n\t11: \"ucbrowser\",\n\t12: \"googlebot\",\n\t13: \"yandex\",\n\t14: \"bing\",\n\t15: \"slurp\",\n\t16: \"exabot\",\n\t17: \"mojeek\",\n\t18: \"cliqz\",\n\t19: \"qwant\",\n\t20: \"datenbank\",\n\t21: \"baidu\",\n\t22: \"sogou\",\n\t23: \"toutiao\",\n\t24: \"haosou\",\n\t25: \"duckduckgo\",\n\t26: \"seznambot\",\n\t27: \"discord\",\n\t28: \"telegram\",\n\t29: \"twitter\",\n\t30: \"facebook\",\n\t31: \"cloudflare\",\n\t32: \"archive_org\",\n\t33: \"uptimebot\",\n\t34: \"slackbot\",\n\t35: \"apple\",\n\t36: \"discourse\",\n\t37: \"xenforo\",\n\t38: \"mattermost\",\n\t39: \"alexa\",\n\t40: \"lynx\",\n\t41: \"blank\",\n\t42: \"malformed\",\n\t43: \"suspicious\",\n\t44: \"semrush\",\n\t45: \"dotbot\",\n\t46: \"ahrefs\",\n\t47: \"proximic\",\n\t48: \"megaindex\",\n\t49: \"majestic\",\n\t50: \"cocolyze\",\n\t51: \"babbar\",\n\t52: \"surdotly\",\n\t53: \"domcop\",\n\t54: \"netcraft\",\n\t55: \"seostar\",\n\t56: \"pandalytics\",\n\t57: \"blexbot\",\n\t58: \"wappalyzer\",\n\t59: \"twingly\",\n\t60: \"linkfluence\",\n\t61: \"pagething\",\n\t62: \"burf\",\n\t63: \"aspiegel\",\n\t64: \"mail_ru\",\n\t65: \"ccbot\",\n\t66: \"yacy\",\n\t67: \"zgrab\",\n\t68: \"cloudsystemnetworks\",\n\t69: \"maui\",\n\t70: \"curl\",\n\t71: \"python\",\n\t72: \"headlesschrome\",\n\t73: \"awesome_bot\",\n}\nvar markToAgent = map[string]string{ \n\t\"OPR\": \"opera\",\n\t\"Chrome\": \"chrome\",\n\t\"Firefox\": \"firefox\",\n\t\"Safari\": \"safari\",\n\t\"MSIE\": \"internetexplorer\",\n\t\"Trident\": \"trident\",\n\t\"Edge\": \"edge\",\n\t\"Lynx\": \"lynx\",\n\t\"SamsungBrowser\": \"samsung\",\n\t\"UCBrowser\": \"ucbrowser\",\n\t\"Google\": \"googlebot\",\n\t\"Googlebot\": \"googlebot\",\n\t\"yandex\": \"yandex\",\n\t\"DuckDuckBot\": \"duckduckgo\",\n\t\"DuckDuckGo\": \"duckduckgo\",\n\t\"Baiduspider\": \"baidu\",\n\t\"Sogou\": \"sogou\",\n\t\"ToutiaoSpider\": \"toutiao\",\n\t\"Bytespider\": \"toutiao\",\n\t\"360Spider\": \"haosou\",\n\t\"bingbot\": \"bing\",\n\t\"BingPreview\": \"bing\",\n\t\"msnbot\": \"bing\",\n\t\"Slurp\": \"slurp\",\n\t\"Exabot\": \"exabot\",\n\t\"MojeekBot\": \"mojeek\",\n\t\"Cliqzbot\": \"cliqz\",\n\t\"Qwantify\": \"qwant\",\n\t\"netEstate\": \"datenbank\",\n\t\"SeznamBot\": \"seznambot\",\n\t\"CloudFlare\": \"cloudflare\",\n\t\"archive\": \"archive_org\",\n\t\"Uptimebot\": \"uptimebot\",\n\t\"Slackbot\": \"slackbot\",\n\t\"Slack\": \"slackbot\",\n\t\"Discordbot\": \"discord\",\n\t\"TelegramBot\": \"telegram\",\n\t\"Twitterbot\": \"twitter\",\n\t\"facebookexternalhit\": \"facebook\",\n\t\"Facebot\": \"facebook\",\n\t\"Applebot\": \"apple\",\n\t\"Discourse\": \"discourse\",\n\t\"XenForo\": \"xenforo\",\n\t\"mattermost\": \"mattermost\",\n\t\"ia_archiver\": \"alexa\",\n\t\"SemrushBot\": \"semrush\",\n\t\"DotBot\": \"dotbot\",\n\t\"AhrefsBot\": \"ahrefs\",\n\t\"proximic\": \"proximic\",\n\t\"MegaIndex\": \"megaindex\",\n\t\"MJ12bot\": \"majestic\",\n\t\"mj12bot\": \"majestic\",\n\t\"Cocolyzebot\": \"cocolyze\",\n\t\"Barkrowler\": \"babbar\",\n\t\"SurdotlyBot\": \"surdotly\",\n\t\"DomCopBot\": \"domcop\",\n\t\"NetcraftSurveyAgent\": \"netcraft\",\n\t\"seostar\": \"seostar\",\n\t\"Pandalytics\": \"pandalytics\",\n\t\"BLEXBot\": \"blexbot\",\n\t\"Wappalyzer\": \"wappalyzer\",\n\t\"Twingly\": \"twingly\",\n\t\"linkfluence\": \"linkfluence\",\n\t\"PageThing\": \"pagething\",\n\t\"Burf\": \"burf\",\n\t\"AspiegelBot\": \"aspiegel\",\n\t\"PetalBot\": \"aspiegel\",\n\t\"RU_Bot\": \"mail_ru\",\n\t\"CCBot\": \"ccbot\",\n\t\"yacybot\": \"yacy\",\n\t\"zgrab\": \"zgrab\",\n\t\"Nimbostratus\": \"cloudsystemnetworks\",\n\t\"MauiBot\": \"maui\",\n\t\"curl\": \"curl\",\n\t\"python\": \"python\",\n\t\"HeadlessChrome\": \"headlesschrome\",\n\t\"awesome_bot\": \"awesome_bot\",\n}\nvar markToID = map[string]int{ \n\t\"OPR\": 1,\n\t\"Chrome\": 2,\n\t\"Firefox\": 3,\n\t\"Safari\": 4,\n\t\"MSIE\": 6,\n\t\"Trident\": 7,\n\t\"Edge\": 5,\n\t\"Lynx\": 40,\n\t\"SamsungBrowser\": 10,\n\t\"UCBrowser\": 11,\n\t\"Google\": 12,\n\t\"Googlebot\": 12,\n\t\"yandex\": 13,\n\t\"DuckDuckBot\": 25,\n\t\"DuckDuckGo\": 25,\n\t\"Baiduspider\": 21,\n\t\"Sogou\": 22,\n\t\"ToutiaoSpider\": 23,\n\t\"Bytespider\": 23,\n\t\"360Spider\": 24,\n\t\"bingbot\": 14,\n\t\"BingPreview\": 14,\n\t\"msnbot\": 14,\n\t\"Slurp\": 15,\n\t\"Exabot\": 16,\n\t\"MojeekBot\": 17,\n\t\"Cliqzbot\": 18,\n\t\"Qwantify\": 19,\n\t\"netEstate\": 20,\n\t\"SeznamBot\": 26,\n\t\"CloudFlare\": 31,\n\t\"archive\": 32,\n\t\"Uptimebot\": 33,\n\t\"Slackbot\": 34,\n\t\"Slack\": 34,\n\t\"Discordbot\": 27,\n\t\"TelegramBot\": 28,\n\t\"Twitterbot\": 29,\n\t\"facebookexternalhit\": 30,\n\t\"Facebot\": 30,\n\t\"Applebot\": 35,\n\t\"Discourse\": 36,\n\t\"XenForo\": 37,\n\t\"mattermost\": 38,\n\t\"ia_archiver\": 39,\n\t\"SemrushBot\": 44,\n\t\"DotBot\": 45,\n\t\"AhrefsBot\": 46,\n\t\"proximic\": 47,\n\t\"MegaIndex\": 48,\n\t\"MJ12bot\": 49,\n\t\"mj12bot\": 49,\n\t\"Cocolyzebot\": 50,\n\t\"Barkrowler\": 51,\n\t\"SurdotlyBot\": 52,\n\t\"DomCopBot\": 53,\n\t\"NetcraftSurveyAgent\": 54,\n\t\"seostar\": 55,\n\t\"Pandalytics\": 56,\n\t\"BLEXBot\": 57,\n\t\"Wappalyzer\": 58,\n\t\"Twingly\": 59,\n\t\"linkfluence\": 60,\n\t\"PageThing\": 61,\n\t\"Burf\": 62,\n\t\"AspiegelBot\": 63,\n\t\"PetalBot\": 63,\n\t\"RU_Bot\": 64,\n\t\"CCBot\": 65,\n\t\"yacybot\": 66,\n\t\"zgrab\": 67,\n\t\"Nimbostratus\": 68,\n\t\"MauiBot\": 69,\n\t\"curl\": 70,\n\t\"python\": 71,\n\t\"HeadlessChrome\": 72,\n\t\"awesome_bot\": 73,\n}\n/*var agentRank = map[string]int{\n\t\"opera\":9,\n\t\"chrome\":8,\n\t\"safari\":1,\n}*/\n\n// HTTPSRedirect is a connection handler which redirects all HTTP requests to HTTPS\ntype HTTPSRedirect struct {}\n\nfunc (red *HTTPSRedirect) ServeHTTP(w http.ResponseWriter, req *http.Request) {\n\tw.Header().Set(\"Connection\", \"close\")\n\tco.RouteViewCounter.Bump(177)\n\tdest := \"https://\" + req.Host + req.URL.String()\n\thttp.Redirect(w, req, dest, http.StatusTemporaryRedirect)\n}\n\nfunc (r *GenRouter) SuspiciousRequest(req *http.Request, pre string) {\n\tif c.Config.DisableSuspLog {\n\t\treturn\n\t}\n\tvar sb strings.Builder\n\tif pre != \"\" {\n\t\tsb.WriteString(\"Suspicious Request\\n\")\n\t} else {\n\t\tpre = \"Suspicious Request\"\n\t}\n\tr.ddumpRequest(req,pre,r.suspLog,&sb)\n\tco.AgentViewCounter.Bump(43)\n}\n\n// TODO: Pass the default path or config struct to the router rather than accessing it via a package global\n// TODO: SetDefaultPath\n// TODO: GetDefaultPath\nfunc (r *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {\n\tmalformedRequest := func(typ int) {\n\t\tw.WriteHeader(200) // 400\n\t\tw.Write([]byte(\"\"))\n\t\tr.DumpRequest(req,\"Malformed Request T\"+strconv.Itoa(typ))\n\t\tco.AgentViewCounter.Bump(42)\n\t}\n\t\n\t// Split the Host and Port string\n\tvar shost, sport string\n\tif req.Host[0]=='[' {\n\t\tspl := strings.Split(req.Host,\"]\")\n\t\tif len(spl) > 2 {\n\t\t\tmalformedRequest(0)\n\t\t\treturn\n\t\t}\n\t\tshost = strings.TrimPrefix(spl[0],\"[\")\n\t\tsport = strings.TrimPrefix(spl[1],\":\")\n\t} else if strings.Contains(req.Host,\":\") {\n\t\tspl := strings.Split(req.Host,\":\")\n\t\tif len(spl) > 2 {\n\t\t\tmalformedRequest(1)\n\t\t\treturn\n\t\t}\n\t\tshost = spl[0]\n\t\t//if len(spl)==2 {\n\t\t\tsport = spl[1]\n\t\t//}\n\t} else {\n\t\tshost = req.Host\n\t}\n\t// TODO: Reject requests from non-local IPs, if the site host is set to localhost or a localhost IP\n\tif !c.Config.LoosePort && c.Site.PortInt != 80 && c.Site.PortInt != 443 && sport != c.Site.Port {\n\t\tmalformedRequest(2)\n\t\treturn\n\t}\n\t\n\t// Redirect www. and local IP requests to the right place\n\tif strings.HasPrefix(shost, \"www.\") || c.Site.LocalHost {\n\tif shost == \"www.\" + c.Site.Host || (c.Site.LocalHost && shost != c.Site.Host && isLocalHost(shost)) {\n\t\t// TODO: Abstract the redirect logic?\n\t\tw.Header().Set(\"Connection\", \"close\")\n\t\tvar s, p string\n\t\tif c.Config.SslSchema {\n\t\t\ts = \"s\"\n\t\t}\n\t\tif c.Site.PortInt != 80 && c.Site.PortInt != 443 {\n\t\t\tp = \":\"+c.Site.Port\n\t\t}\n\t\tdest := \"http\"+s+\"://\" + c.Site.Host+p + req.URL.Path\n\t\tif len(req.URL.RawQuery) > 0 {\n\t\t\tdest += \"?\" + req.URL.RawQuery\n\t\t}\n\t\thttp.Redirect(w, req, dest, http.StatusMovedPermanently)\n\t\treturn\n\t}\n\t}\n\n\t// Deflect malformed requests\n\tif len(req.URL.Path) == 0 || req.URL.Path[0] != '/' || (!c.Config.LooseHost && shost != c.Site.Host) {\n\t\tmalformedRequest(3)\n\t\treturn\n\t}\n\tr.suspScan(req)\n\n\t// Indirect the default route onto a different one\n\tif req.URL.Path == \"/\" {\n\t\treq.URL.Path = c.Config.DefaultPath\n\t}\n\t//log.Print(\"URL.Path: \", req.URL.Path)\n\tprefix := req.URL.Path[0:strings.IndexByte(req.URL.Path[1:],'/') + 1]\n\n\t// TODO: Use the same hook table as downstream\n\thTbl := c.GetHookTable()\n\tskip, ferr := c.H_router_after_filters_hook(hTbl, w, req, prefix)\n\tif skip || ferr != nil {\n\t\treturn\n\t}\n\n\tif prefix != \"/ws\" {\n\t\th := w.Header()\n\t\th.Set(\"X-Frame-Options\", \"deny\")\n\t\th.Set(\"X-XSS-Protection\", \"1; mode=block\") // TODO: Remove when we add a CSP? CSP's are horrendously glitchy things, tread with caution before removing\n\t\th.Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\tif c.Config.RefNoRef || !c.Config.SslSchema {\n\t\t\th.Set(\"Referrer-Policy\",\"no-referrer\")\n\t\t} else {\n\t\t\th.Set(\"Referrer-Policy\",\"strict-origin\")\n\t\t}\n\t\th.Set(\"Permissions-Policy\",\"interest-cohort=()\")\n\t}\n\t\n\tif c.Dev.SuperDebug {\n\t\tr.DumpRequest(req,\"before routes.StaticFile\")\n\t}\n\t// Increment the request counter\n\tif !c.Config.DisableAnalytics {\n\t\tco.GlobalViewCounter.Bump()\n\t}\n\t\n\tif prefix == \"/s\" { //old prefix: /static\n\t\tif !c.Config.DisableAnalytics {\n\t\t\tco.RouteViewCounter.Bump(171)\n\t\t}\n\t\troutes.StaticFile(w, req)\n\t\treturn\n\t}\n\t// TODO: Handle JS routes\n\tif atomic.LoadInt32(&c.IsDBDown) == 1 {\n\t\tc.DatabaseError(w, req)\n\t\treturn\n\t}\n\tif c.Dev.SuperDebug {\n\t\tr.reqLogger.Print(\"before PreRoute\")\n\t}\n\n\t/*if c.Dev.QuicPort != 0 {\n\t\tsQuicPort := strconv.Itoa(c.Dev.QuicPort)\n\t\tw.Header().Set(\"Alt-Svc\", \"quic=\\\":\"+sQuicPort+\"\\\"; ma=2592000; v=\\\"44,43,39\\\", h3-23=\\\":\"+sQuicPort+\"\\\"; ma=3600, h3-24=\\\":\"+sQuicPort+\"\\\"; ma=3600, h2=\\\":443\\\"; ma=3600\")\n\t}*/\n\n\t// Track the user agents. Unfortunately, everyone pretends to be Mozilla, so this'll be a little less efficient than I would like.\n\t// TODO: Add a setting to disable this?\n\t// TODO: Use a more efficient detector instead of smashing every possible combination in\n\t// TODO: Make this testable\n\tvar agent int\n\tif !c.Config.DisableAnalytics {\n\t\n\tua := strings.TrimSpace(strings.Replace(strings.TrimPrefix(req.UserAgent(),\"Mozilla/5.0 \"),\" Safari/537.36\",\"\",-1)) // Noise, no one's going to be running this and it would require some sort of agent ranking system to determine which identifier should be prioritised over another\n\tif ua == \"\" {\n\t\tco.AgentViewCounter.Bump(41)\n\t\tr.unknownUA(req)\n\t} else {\t\t\n\t\t// WIP UA Parser\n\t\t//var ii = uaBufPool.Get()\n\t\tvar buf []byte\n\t\t//if ii != nil {\n\t\t//\tbuf = ii.([]byte)\n\t\t//}\n\t\tvar items []string\n\t\tvar os int\n\t\tfor _, it := range uutils.StringToBytes(ua) {\n\t\t\tif (it > 64 && it < 91) || (it > 96 && it < 123) || (it > 47 && it < 58) || it == '_' {\n\t\t\t\t// TODO: Store an index and slice that instead?\n\t\t\t\tbuf = append(buf, it)\n\t\t\t} else if it == ' ' || it == '(' || it == ')' || it == '-' || it == ';' || it == ':' || it == '.' || it == '+' || it == '~' || it == '@' /*|| (it == ':' && bytes.Equal(buf,[]byte(\"http\")))*/ || it == ',' || it == '/' {\n\t\t\t\t//log.Print(\"buf: \",string(buf))\n\t\t\t\t//log.Print(\"it: \",string(it))\n\t\t\t\tif len(buf) != 0 {\n\t\t\t\t\tif len(buf) > 2 {\n\t\t\t\t\t\t// Use an unsafe zero copy conversion here just to use the switch, it's not safe for this string to escape from here, as it will get mutated, so do a regular string conversion in append\n\t\t\t\t\t\tswitch(uutils.BytesToString(buf)) {\n\t\t\t\t\t\tcase \"Windows\":\n\t\t\t\t\t\t\tos = 1\n\t\t\t\t\t\tcase \"Linux\":\n\t\t\t\t\t\t\tos = 2\n\t\t\t\t\t\tcase \"Mac\":\n\t\t\t\t\t\t\tos = 3\n\t\t\t\t\t\tcase \"iPhone\":\n\t\t\t\t\t\t\tos = 5\n\t\t\t\t\t\tcase \"Android\":\n\t\t\t\t\t\t\tos = 4\n\t\t\t\t\t\tcase \"like\",\"compatible\",\"NT\",\"X\",\"com\",\"KHTML\":\n\t\t\t\t\t\t\t// Skip these words\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t//log.Print(\"append buf\")\n\t\t\t\t\t\t\titems = append(items, string(buf))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t//log.Print(\"reset buf\")\n\t\t\t\t\tbuf = buf[:0]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// TODO: Test this\n\t\t\t\titems = items[:0]\n\t\t\t\tif c.Config.DisableSuspLog {\n\t\t\t\t\tr.reqLogger.Print(\"Illegal char \"+strconv.Itoa(int(it))+\" in UA\\nUA Buf: \", buf,\"\\nUA Buf String: \", string(buf))\n\t\t\t\t} else {\n\t\t\t\t\tr.SuspiciousRequest(req,\"Illegal char \"+strconv.Itoa(int(it))+\" in UA\")\n\t\t\t\t\tr.reqLogger.Print(\"UA Buf: \", buf,\"\\nUA Buf String: \", string(buf))\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t//uaBufPool.Put(buf)\n\n\t\t// Iterate over this in reverse as the real UA tends to be on the right side\n\t\tfor i := len(items) - 1; i >= 0; i-- {\n\t\t\t//fAgent, ok := markToAgent[items[i]]\n\t\t\tfAgent, ok := markToID[items[i]]\n\t\t\tif ok {\n\t\t\t\tagent = fAgent\n\t\t\t\tif agent != 4 {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif c.Dev.SuperDebug {\n\t\t\tr.reqLogger.Print(\"parsed agent: \", agent,\"\\nos: \", os)\n\t\t\tr.reqLogger.Printf(\"items: %+v\\n\",items)\n\t\t\t/*for _, it := range items {\n\t\t\t\tr.reqLogger.Printf(\"it: %+v\\n\",string(it))\n\t\t\t}*/\n\t\t}\n\t\t\n\t\t// Special handling\n\t\tswitch(agent) {\n\t\tcase 2:\n\t\t\tif os == 4 {\n\t\t\t\tagent = 8\n\t\t\t}\n\t\tcase 4:\n\t\t\tif os == 5 {\n\t\t\t\tagent = 9\n\t\t\t}\n\t\tcase 7:\n\t\t\t// Hack to support IE11, change this after we start logging versions\n\t\t\tif strings.Contains(ua,\"rv:11\") {\n\t\t\t\tagent = 6\n\t\t\t}\n\t\tcase 67:\n\t\t\tw.WriteHeader(200) // 400\n\t\t\tw.Write([]byte(\"\"))\n\t\t\tr.DumpRequest(req,\"Blocked Scanner\")\n\t\t\tco.AgentViewCounter.Bump(67)\n\t\t\treturn\n\t\t}\n\t\t\n\t\tif agent == 0 {\n\t\t\t//co.AgentViewCounter.Bump(0)\n\t\t\tr.unknownUA(req)\n\t\t}// else {\n\t\t\t//co.AgentViewCounter.Bump(agentMapEnum[agent])\n\t\t\tco.AgentViewCounter.Bump(agent)\n\t\t//}\n\t\tco.OSViewCounter.Bump(os)\n\t}\n\n\t// TODO: Do we want to track missing language headers too? Maybe as it's own type, e.g. \"noheader\"?\n\t// TODO: Default to anything other than en, if anything else is present, to avoid over-representing it for multi-linguals?\n\tlang := req.Header.Get(\"Accept-Language\")\n\tif lang != \"\" {\n\t\t// TODO: Reduce allocs here\n\t\tlLang := strings.Split(strings.TrimSpace(lang),\"-\")\n\t\ttLang := strings.Split(strings.Split(lLang[0],\";\")[0],\",\")\n\t\tc.DebugDetail(\"tLang:\", tLang)\n\t\tvar llLang string\n\t\tfor _, seg := range tLang {\n\t\t\tif seg == \"*\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tllLang = seg\n\t\t\tbreak\n\t\t}\n\t\tc.DebugDetail(\"llLang:\", llLang)\n\t\tif !co.LangViewCounter.Bump(llLang) {\n\t\t\tr.DumpRequest(req,\"Invalid ISO Code\")\n\t\t}\n\t} else {\n\t\tco.LangViewCounter.Bump2(0)\n\t}\n\n\tif !c.Config.RefNoTrack {\n\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\tlikelyBot := ae == \"gzip\" || ae == \"\"\n\t\tif !likelyBot {\n\t\t\tref := req.Header.Get(\"Referer\") // Check the 'referrer' header too? :P\n\t\t\t// TODO: Extend the effects of DNT elsewhere?\n\t\t\tif ref != \"\" && req.Header.Get(\"DNT\") != \"1\" {\n\t\t\t\t// ? Optimise this a little?\n\t\t\t\tref = strings.TrimPrefix(strings.TrimPrefix(ref,\"http://\"),\"https://\")\n\t\t\t\tref = strings.Split(ref,\"/\")[0]\n\t\t\t\tportless := strings.Split(ref,\":\")[0]\n\t\t\t\t// TODO: Handle c.Site.Host in uppercase too?\n\t\t\t\tif portless != \"localhost\" && portless != \"127.0.0.1\" && portless != c.Site.Host {\n\t\t\t\t\tr.DumpRequest(req,\"Ref Route\")\n\t\t\t\t\tco.ReferrerTracker.Bump(ref)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t}\n\t\n\t// Deal with the session stuff, etc.\n\tucpy, ok := c.PreRoute(w, req)\n\tif !ok {\n\t\treturn\n\t}\n\tuser := &ucpy\n\tuser.LastAgent = agent\n\tif c.Dev.SuperDebug {\n\t\tr.reqLogger.Print(\n\t\t\t\"after PreRoute\\n\" +\n\t\t\t\"routeMapEnum: \", routeMapEnum)\n\t}\n\t//log.Println(\"req: \", req)\n\n\t// Disable Gzip when SSL is disabled for security reasons?\n\tif prefix != \"/ws\" {\n\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\t/*if strings.Contains(ae, \"br\") {\n\t\t\th := w.Header()\n\t\t\th.Set(\"Content-Encoding\", \"br\")\n\t\t\tvar ii = brPool.Get()\n\t\t\tvar igzw *brotli.Writer\n\t\t\tif ii == nil {\n\t\t\t\tigzw = brotli.NewWriter(w)\n\t\t\t} else {\n\t\t\t\tigzw = ii.(*brotli.Writer)\n\t\t\t\tigzw.Reset(w)\n\t\t\t}\n\t\t\tgzw := c.BrResponseWriter{Writer: igzw, ResponseWriter: w}\n\t\t\tdefer func() {\n\t\t\t\t//h := w.Header()\n\t\t\t\tif h.Get(\"Content-Encoding\") == \"br\" && h.Get(\"X-I\") == \"\" {\n\t\t\t\t\t//log.Print(\"push br close\")\n\t\t\t\t\tigzw := gzw.Writer.(*brotli.Writer)\n\t\t\t\t\tigzw.Close()\n\t\t\t\t\tbrPool.Put(igzw)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tw = gzw\n\t\t} else */if strings.Contains(ae, \"gzip\") {\n\t\t\th := w.Header()\n\t\t\th.Set(\"Content-Encoding\", \"gzip\")\n\t\t\tvar ii = gzipPool.Get()\n\t\t\tvar igzw *gzip.Writer\n\t\t\tif ii == nil {\n\t\t\t\tigzw = gzip.NewWriter(w)\n\t\t\t} else {\n\t\t\t\tigzw = ii.(*gzip.Writer)\n\t\t\t\tigzw.Reset(w)\n\t\t\t}\n\t\t\tgzw := c.GzipResponseWriter{Writer: igzw, ResponseWriter: w}\n\t\t\tdefer func() {\n\t\t\t\t//h := w.Header()\n\t\t\t\tif h.Get(\"Content-Encoding\") == \"gzip\" && h.Get(\"X-I\") == \"\" {\n\t\t\t\t\t//log.Print(\"push gzip close\")\n\t\t\t\t\tigzw := gzw.Writer.(*gzip.Writer)\n\t\t\t\t\tigzw.Close()\n\t\t\t\t\tgzipPool.Put(igzw)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tw = gzw\n\t\t}\n\t}\n\n\tskip, ferr = c.H_router_pre_route_hook(hTbl, w, req, user, prefix)\n\tif skip || ferr != nil {\n\t\tr.handleError(ferr,w,req,user)\n\t\treturn\n\t}\n\tvar extraData string\n\tif req.URL.Path[len(req.URL.Path) - 1] != '/' {\n\t\textraData = req.URL.Path[strings.LastIndexByte(req.URL.Path,'/') + 1:]\n\t\treq.URL.Path = req.URL.Path[:strings.LastIndexByte(req.URL.Path,'/') + 1]\n\t}\n\tferr = r.routeSwitch(w, req, user, prefix, extraData)\n\tif ferr != nil {\n\t\tr.handleError(ferr,w,req,user)\n\t\treturn\n\t}\n\t/*if !c.Config.DisableAnalytics {\n\t\tco.RouteViewCounter.Bump(id)\n\t}*/\n\n\thTbl.VhookNoRet(\"router_end\", w, req, user, prefix, extraData)\n\t//c.StoppedServer(\"Profile end\")\n}\n\t\nfunc (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user *c.User, prefix, extraData string) /*(id int, orerr */c.RouteError/*)*/ {\n\tvar err c.RouteError\n\tcn := uutils.Nanotime()\n\tswitch(prefix) {\n\t\tcase \"/overview\":\n\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = routes.Overview(w,req,user,h)\n\t\t\tco.RouteViewCounter.Bump3(1, cn)\n\t\tcase \"/pages\":\n\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = routes.CustomPage(w,req,user,h,extraData)\n\t\t\tco.RouteViewCounter.Bump3(2, cn)\n\t\tcase \"/forums\":\n\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = routes.ForumList(w,req,user,h)\n\t\t\tco.RouteViewCounter.Bump3(3, cn)\n\t\tcase \"/forum\":\n\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = routes.ViewForum(w,req,user,h,extraData)\n\t\t\tco.RouteViewCounter.Bump3(4, cn)\n\t\tcase \"/theme\":\n\t\t\terr = c.ParseForm(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\terr = routes.ChangeTheme(w,req,user)\n\t\t\tco.RouteViewCounter.Bump3(5, cn)\n\t\tcase \"/attachs\":\n\t\t\terr = c.ParseForm(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tw = r.responseWriter(w)\n\t\t\terr = routes.ShowAttachment(w,req,user,extraData)\n\t\t\tco.RouteViewCounter.Bump3(6, cn)\n\t\tcase \"/ws\":\n\t\t\treq.URL.Path += extraData\n\t\t\terr = c.RouteWebsockets(w,req,user)\n\t\tcase \"/api\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/api/phrases/\":\n\t\t\t\t\terr = routeAPIPhrases(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(8, cn)\n\t\t\t\tcase \"/api/me/\":\n\t\t\t\t\terr = routes.APIMe(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(9, cn)\n\t\t\t\tcase \"/api/watches/\":\n\t\t\t\t\terr = routeJSAntispam(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(10, cn)\n\t\t\t\tdefault:\n\t\t\t\t\terr = routeAPI(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(11, cn)\n\t\t\t}\n\t\tcase \"/report\":\n\t\t\terr = c.NoBanned(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/report/submit/\":\n\t\t\t\t\terr = routes.ReportSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(12, cn)\n\t\t\t}\n\t\tcase \"/topics\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/topics/most-viewed/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.TopicListMostViewed(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(13, cn)\n\t\t\t\tcase \"/topics/week-views/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.TopicListWeekViews(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(14, cn)\n\t\t\t\tcase \"/topics/create/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.CreateTopic(w,req,user,h,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(15, cn)\n\t\t\t\tdefault:\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.TopicList(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(16, cn)\n\t\t\t}\n\t\tcase \"/panel\":\n\t\t\terr = c.SuperModOnly(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/panel/forums/\":\n\t\t\t\t\terr = panel.Forums(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(17, cn)\n\t\t\t\tcase \"/panel/forums/create/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(18, cn)\n\t\t\t\tcase \"/panel/forums/delete/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsDelete(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(19, cn)\n\t\t\t\tcase \"/panel/forums/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(20, cn)\n\t\t\t\tcase \"/panel/forums/order/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsOrderSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(21, cn)\n\t\t\t\tcase \"/panel/forums/edit/\":\n\t\t\t\t\terr = panel.ForumsEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(22, cn)\n\t\t\t\tcase \"/panel/forums/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(23, cn)\n\t\t\t\tcase \"/panel/forums/edit/perms/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsEditPermsSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(24, cn)\n\t\t\t\tcase \"/panel/forums/edit/perms/\":\n\t\t\t\t\terr = panel.ForumsEditPermsAdvance(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(25, cn)\n\t\t\t\tcase \"/panel/forums/edit/perms/adv/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsEditPermsAdvanceSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(26, cn)\n\t\t\t\tcase \"/panel/forums/action/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsEditActionCreateSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(27, cn)\n\t\t\t\tcase \"/panel/forums/action/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ForumsEditActionDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(28, cn)\n\t\t\t\tcase \"/panel/settings/\":\n\t\t\t\t\terr = panel.Settings(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(29, cn)\n\t\t\t\tcase \"/panel/settings/edit/\":\n\t\t\t\t\terr = panel.SettingEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(30, cn)\n\t\t\t\tcase \"/panel/settings/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.SettingEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(31, cn)\n\t\t\t\tcase \"/panel/settings/word-filters/\":\n\t\t\t\t\terr = panel.WordFilters(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(32, cn)\n\t\t\t\tcase \"/panel/settings/word-filters/create/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.WordFiltersCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(33, cn)\n\t\t\t\tcase \"/panel/settings/word-filters/edit/\":\n\t\t\t\t\terr = panel.WordFiltersEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(34, cn)\n\t\t\t\tcase \"/panel/settings/word-filters/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.WordFiltersEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(35, cn)\n\t\t\t\tcase \"/panel/settings/word-filters/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.WordFiltersDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(36, cn)\n\t\t\t\tcase \"/panel/pages/\":\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.Pages(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(37, cn)\n\t\t\t\tcase \"/panel/pages/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PagesCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(38, cn)\n\t\t\t\tcase \"/panel/pages/edit/\":\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PagesEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(39, cn)\n\t\t\t\tcase \"/panel/pages/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PagesEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(40, cn)\n\t\t\t\tcase \"/panel/pages/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PagesDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(41, cn)\n\t\t\t\tcase \"/panel/themes/\":\n\t\t\t\t\terr = panel.Themes(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(42, cn)\n\t\t\t\tcase \"/panel/themes/default/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesSetDefault(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(43, cn)\n\t\t\t\tcase \"/panel/themes/menus/\":\n\t\t\t\t\terr = panel.ThemesMenus(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(44, cn)\n\t\t\t\tcase \"/panel/themes/menus/edit/\":\n\t\t\t\t\terr = panel.ThemesMenusEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(45, cn)\n\t\t\t\tcase \"/panel/themes/menus/item/edit/\":\n\t\t\t\t\terr = panel.ThemesMenuItemEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(46, cn)\n\t\t\t\tcase \"/panel/themes/menus/item/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesMenuItemEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(47, cn)\n\t\t\t\tcase \"/panel/themes/menus/item/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesMenuItemCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(48, cn)\n\t\t\t\tcase \"/panel/themes/menus/item/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesMenuItemDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(49, cn)\n\t\t\t\tcase \"/panel/themes/menus/item/order/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesMenuItemOrderSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(50, cn)\n\t\t\t\tcase \"/panel/themes/widgets/\":\n\t\t\t\t\terr = panel.ThemesWidgets(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(51, cn)\n\t\t\t\tcase \"/panel/themes/widgets/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesWidgetsEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(52, cn)\n\t\t\t\tcase \"/panel/themes/widgets/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesWidgetsCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(53, cn)\n\t\t\t\tcase \"/panel/themes/widgets/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.ThemesWidgetsDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(54, cn)\n\t\t\t\tcase \"/panel/plugins/\":\n\t\t\t\t\terr = panel.Plugins(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(55, cn)\n\t\t\t\tcase \"/panel/plugins/activate/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PluginsActivate(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(56, cn)\n\t\t\t\tcase \"/panel/plugins/deactivate/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PluginsDeactivate(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(57, cn)\n\t\t\t\tcase \"/panel/plugins/install/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.PluginsInstall(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(58, cn)\n\t\t\t\tcase \"/panel/users/\":\n\t\t\t\t\terr = panel.Users(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(59, cn)\n\t\t\t\tcase \"/panel/users/edit/\":\n\t\t\t\t\terr = panel.UsersEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(60, cn)\n\t\t\t\tcase \"/panel/users/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.UsersEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(61, cn)\n\t\t\t\tcase \"/panel/users/avatar/submit/\":\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.UsersAvatarSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(62, cn)\n\t\t\t\tcase \"/panel/users/avatar/remove/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.UsersAvatarRemoveSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(63, cn)\n\t\t\t\tcase \"/panel/analytics/views/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsViews(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(64, cn)\n\t\t\t\tcase \"/panel/analytics/routes/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsRoutes(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(65, cn)\n\t\t\t\tcase \"/panel/analytics/routes-perf/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsRoutesPerf(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(66, cn)\n\t\t\t\tcase \"/panel/analytics/agents/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsAgents(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(67, cn)\n\t\t\t\tcase \"/panel/analytics/systems/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsSystems(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(68, cn)\n\t\t\t\tcase \"/panel/analytics/langs/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsLanguages(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(69, cn)\n\t\t\t\tcase \"/panel/analytics/referrers/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsReferrers(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(70, cn)\n\t\t\t\tcase \"/panel/analytics/route/\":\n\t\t\t\t\terr = panel.AnalyticsRouteViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(71, cn)\n\t\t\t\tcase \"/panel/analytics/agent/\":\n\t\t\t\t\terr = panel.AnalyticsAgentViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(72, cn)\n\t\t\t\tcase \"/panel/analytics/forum/\":\n\t\t\t\t\terr = panel.AnalyticsForumViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(73, cn)\n\t\t\t\tcase \"/panel/analytics/system/\":\n\t\t\t\t\terr = panel.AnalyticsSystemViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(74, cn)\n\t\t\t\tcase \"/panel/analytics/lang/\":\n\t\t\t\t\terr = panel.AnalyticsLanguageViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(75, cn)\n\t\t\t\tcase \"/panel/analytics/referrer/\":\n\t\t\t\t\terr = panel.AnalyticsReferrerViews(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(76, cn)\n\t\t\t\tcase \"/panel/analytics/posts/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsPosts(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(77, cn)\n\t\t\t\tcase \"/panel/analytics/memory/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsMemory(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(78, cn)\n\t\t\t\tcase \"/panel/analytics/active-memory/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsActiveMemory(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(79, cn)\n\t\t\t\tcase \"/panel/analytics/topics/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsTopics(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(80, cn)\n\t\t\t\tcase \"/panel/analytics/forums/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsForums(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(81, cn)\n\t\t\t\tcase \"/panel/analytics/perf/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.AnalyticsPerf(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(82, cn)\n\t\t\t\tcase \"/panel/groups/\":\n\t\t\t\t\terr = panel.Groups(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(83, cn)\n\t\t\t\tcase \"/panel/groups/edit/\":\n\t\t\t\t\terr = panel.GroupsEdit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(84, cn)\n\t\t\t\tcase \"/panel/groups/edit/promotions/\":\n\t\t\t\t\terr = panel.GroupsEditPromotions(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(85, cn)\n\t\t\t\tcase \"/panel/groups/promotions/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.GroupsPromotionsCreateSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(86, cn)\n\t\t\t\tcase \"/panel/groups/promotions/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.GroupsPromotionsDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(87, cn)\n\t\t\t\tcase \"/panel/groups/edit/perms/\":\n\t\t\t\t\terr = panel.GroupsEditPerms(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(88, cn)\n\t\t\t\tcase \"/panel/groups/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.GroupsEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(89, cn)\n\t\t\t\tcase \"/panel/groups/edit/perms/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.GroupsEditPermsSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(90, cn)\n\t\t\t\tcase \"/panel/groups/create/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.GroupsCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(91, cn)\n\t\t\t\tcase \"/panel/backups/\":\n\t\t\t\t\terr = c.SuperAdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\tw = r.responseWriter(w)\n\t\t\t\t\terr = panel.Backups(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(92, cn)\n\t\t\t\tcase \"/panel/logs/regs/\":\n\t\t\t\t\terr = panel.LogsRegs(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(93, cn)\n\t\t\t\tcase \"/panel/logs/mod/\":\n\t\t\t\t\terr = panel.LogsMod(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(94, cn)\n\t\t\t\tcase \"/panel/logs/admin/\":\n\t\t\t\t\terr = panel.LogsAdmin(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(95, cn)\n\t\t\t\tcase \"/panel/debug/\":\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.Debug(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(96, cn)\n\t\t\t\tcase \"/panel/debug/tasks/\":\n\t\t\t\t\terr = c.AdminOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = panel.DebugTasks(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(97, cn)\n\t\t\t\tdefault:\n\t\t\t\t\terr = panel.Dashboard(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(98, cn)\n\t\t\t}\n\t\tcase \"/user\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/user/edit/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEdit(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(99, cn)\n\t\t\t\tcase \"/user/edit/password/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEditPassword(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(100, cn)\n\t\t\t\tcase \"/user/edit/password/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditPasswordSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(101, cn)\n\t\t\t\tcase \"/user/edit/avatar/submit/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditAvatarSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(102, cn)\n\t\t\t\tcase \"/user/edit/avatar/revoke/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditRevokeAvatarSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(103, cn)\n\t\t\t\tcase \"/user/edit/username/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditUsernameSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(104, cn)\n\t\t\t\tcase \"/user/edit/privacy/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEditPrivacy(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(105, cn)\n\t\t\t\tcase \"/user/edit/privacy/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditPrivacySubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(106, cn)\n\t\t\t\tcase \"/user/edit/mfa/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEditMFA(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(107, cn)\n\t\t\t\tcase \"/user/edit/mfa/setup/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEditMFASetup(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(108, cn)\n\t\t\t\tcase \"/user/edit/mfa/setup/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditMFASetupSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(109, cn)\n\t\t\t\tcase \"/user/edit/mfa/disable/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountEditMFADisableSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(110, cn)\n\t\t\t\tcase \"/user/edit/email/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountEditEmail(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(111, cn)\n\t\t\t\tcase \"/user/edit/token/\":\n\t\t\t\t\terr = routes.AccountEditEmailTokenSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(112, cn)\n\t\t\t\tcase \"/user/edit/logins/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountLogins(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(113, cn)\n\t\t\t\tcase \"/user/edit/blocked/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountBlocked(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(114, cn)\n\t\t\t\tcase \"/user/levels/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.LevelList(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(115, cn)\n\t\t\t\tcase \"/user/convos/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.Convos(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(116, cn)\n\t\t\t\tcase \"/user/convos/create/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.ConvosCreate(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(117, cn)\n\t\t\t\tcase \"/user/convo/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.Convo(w,req,user,h,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(118, cn)\n\t\t\t\tcase \"/user/convos/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ConvosCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(119, cn)\n\t\t\t\tcase \"/user/convo/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ConvosCreateReplySubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(120, cn)\n\t\t\t\tcase \"/user/convo/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ConvosDeleteReplySubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(121, cn)\n\t\t\t\tcase \"/user/convo/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ConvosEditReplySubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(122, cn)\n\t\t\t\tcase \"/user/block/create/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.RelationsBlockCreate(w,req,user,h,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(123, cn)\n\t\t\t\tcase \"/user/block/create/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.RelationsBlockCreateSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(124, cn)\n\t\t\t\tcase \"/user/block/remove/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.RelationsBlockRemove(w,req,user,h,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(125, cn)\n\t\t\t\tcase \"/user/block/remove/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.RelationsBlockRemoveSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(126, cn)\n\t\t\t\tdefault:\n\t\t\t\treq.URL.Path += extraData\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.ViewProfile(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(127, cn)\n\t\t\t}\n\t\tcase \"/users\":\n\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/users/ban/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.BanUserSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(128, cn)\n\t\t\t\tcase \"/users/unban/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.UnbanUser(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(129, cn)\n\t\t\t\tcase \"/users/activate/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ActivateUser(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(130, cn)\n\t\t\t\tcase \"/users/ips/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.IPSearch(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(131, cn)\n\t\t\t\tcase \"/users/delete-posts/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.DeletePostsSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(132, cn)\n\t\t\t}\n\t\tcase \"/topic\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/topic/create/submit/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.CreateTopicSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(133, cn)\n\t\t\t\tcase \"/topic/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.EditTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(134, cn)\n\t\t\t\tcase \"/topic/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\treq.URL.Path += extraData\n\t\t\t\t\terr = routes.DeleteTopicSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(135, cn)\n\t\t\t\tcase \"/topic/stick/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.StickTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(136, cn)\n\t\t\t\tcase \"/topic/unstick/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.UnstickTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(137, cn)\n\t\t\t\tcase \"/topic/lock/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\treq.URL.Path += extraData\n\t\t\t\t\terr = routes.LockTopicSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(138, cn)\n\t\t\t\tcase \"/topic/unlock/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.UnlockTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(139, cn)\n\t\t\t\tcase \"/topic/move/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.MoveTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(140, cn)\n\t\t\t\tcase \"/topic/like/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.LikeTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(141, cn)\n\t\t\t\tcase \"/topic/unlike/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.UnlikeTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(142, cn)\n\t\t\t\tcase \"/topic/attach/add/submit/\":\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AddAttachToTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(143, cn)\n\t\t\t\tcase \"/topic/attach/remove/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.RemoveAttachFromTopicSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(144, cn)\n\t\t\t\tdefault:\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.ViewTopic(w,req,user,h,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(145, cn)\n\t\t\t}\n\t\tcase \"/reply\":\n\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/reply/create/\":\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.CreateReplySubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(146, cn)\n\t\t\t\tcase \"/reply/edit/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ReplyEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(147, cn)\n\t\t\t\tcase \"/reply/delete/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ReplyDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(148, cn)\n\t\t\t\tcase \"/reply/like/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ReplyLikeSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(149, cn)\n\t\t\t\tcase \"/reply/unlike/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.ReplyUnlikeSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(150, cn)\n\t\t\t\tcase \"/reply/attach/add/submit/\":\n\t\t\t\t\terr = c.HandleUploadRoute(w,req,user,int(c.Config.MaxRequestSize))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = c.NoUploadSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AddAttachToReplySubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(151, cn)\n\t\t\t\tcase \"/reply/attach/remove/submit/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.RemoveAttachFromReplySubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(152, cn)\n\t\t\t}\n\t\tcase \"/profile\":\n\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\t\t\t\n\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/profile/reply/create/\":\n\t\t\t\t\terr = routes.ProfileReplyCreateSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(153, cn)\n\t\t\t\tcase \"/profile/reply/edit/submit/\":\n\t\t\t\t\terr = routes.ProfileReplyEditSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(154, cn)\n\t\t\t\tcase \"/profile/reply/delete/submit/\":\n\t\t\t\t\terr = routes.ProfileReplyDeleteSubmit(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(155, cn)\n\t\t\t}\n\t\tcase \"/poll\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/poll/vote/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.PollVote(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(156, cn)\n\t\t\t\tcase \"/poll/results/\":\n\t\t\t\t\terr = routes.PollResults(w,req,user,extraData)\n\t\t\t\t\tco.RouteViewCounter.Bump3(157, cn)\n\t\t\t}\n\t\tcase \"/accounts\":\n\t\t\tswitch(req.URL.Path) {\n\t\t\t\tcase \"/accounts/login/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountLogin(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(158, cn)\n\t\t\t\tcase \"/accounts/create/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountRegister(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(159, cn)\n\t\t\t\tcase \"/accounts/logout/\":\n\t\t\t\t\terr = c.NoSessionMismatch(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = c.MemberOnly(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountLogout(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(160, cn)\n\t\t\t\tcase \"/accounts/login/submit/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountLoginSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(161, cn)\n\t\t\t\tcase \"/accounts/mfa_verify/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountLoginMFAVerify(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(162, cn)\n\t\t\t\tcase \"/accounts/mfa_verify/submit/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountLoginMFAVerifySubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(163, cn)\n\t\t\t\tcase \"/accounts/create/submit/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountRegisterSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(164, cn)\n\t\t\t\tcase \"/accounts/password-reset/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountPasswordReset(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(165, cn)\n\t\t\t\tcase \"/accounts/password-reset/submit/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountPasswordResetSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(166, cn)\n\t\t\t\tcase \"/accounts/password-reset/token/\":\n\t\t\t\t\th, err := c.UserCheckNano(w,req,user,cn)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\terr = routes.AccountPasswordResetToken(w,req,user,h)\n\t\t\t\t\tco.RouteViewCounter.Bump3(167, cn)\n\t\t\t\tcase \"/accounts/password-reset/token/submit/\":\n\t\t\t\t\terr = c.ParseForm(w,req,user)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\t\n\t\t\t\t\terr = routes.AccountPasswordResetTokenSubmit(w,req,user)\n\t\t\t\t\tco.RouteViewCounter.Bump3(168, cn)\n\t\t\t}\n\t\t/*case \"/sitemaps\": // TODO: Count these views\n\t\t\treq.URL.Path += extraData\n\t\t\terr = sitemapSwitch(w,req)*/\n\t\t// ! Temporary fix for certain bots\n\t\tcase \"/static\":\n\t\t\tw.Header().Set(\"Connection\", \"close\")\n\t\t\thttp.Redirect(w, req, \"/s/\"+extraData, http.StatusTemporaryRedirect)\n\t\tcase \"/uploads\":\n\t\t\tif extraData == \"\" {\n\t\t\t\tco.RouteViewCounter.Bump3(170, cn)\n\t\t\t\treturn c.NotFound(w,req,nil)\n\t\t\t}\n\t\t\tw = r.responseWriter(w)\n\t\t\treq.URL.Path += extraData\n\t\t\t// TODO: Find a way to propagate errors up from this?\n\t\t\tr.UploadHandler(w,req) // TODO: Count these views\n\t\t\tco.RouteViewCounter.Bump3(170, cn)\n\t\t\treturn nil\n\t\tcase \"\":\n\t\t\t// Stop the favicons, robots.txt file, etc. resolving to the topics list\n\t\t\t// TODO: Add support for favicons and robots.txt files\n\t\t\tswitch(extraData) {\n\t\t\t\tcase \"robots.txt\":\n\t\t\t\t\tco.RouteViewCounter.Bump3(172, cn)\n\t\t\t\t\treturn routes.RobotsTxt(w,req)\n\t\t\t\tcase \"favicon.ico\":\n\t\t\t\t\tw = r.responseWriter(w)\n\t\t\t\t\treq.URL.Path = \"/s/favicon.ico\"\n\t\t\t\t\tco.RouteViewCounter.Bump3(175, cn)\n\t\t\t\t\troutes.StaticFile(w,req)\n\t\t\t\t\treturn nil\n\t\t\t\tcase \"opensearch.xml\":\n\t\t\t\t\tco.RouteViewCounter.Bump3(174, cn)\n\t\t\t\t\treturn routes.OpenSearchXml(w,req)\n\t\t\t\t/*case \"sitemap.xml\":\n\t\t\t\t\tco.RouteViewCounter.Bump3(173, cn)\n\t\t\t\t\treturn routes.SitemapXml(w,req)*/\n\t\t\t}\n\t\t\tco.RouteViewCounter.Bump(0)\n\t\t\treturn c.NotFound(w,req,nil)\n\t\tdefault:\n\t\t\t// A fallback for dynamic routes, e.g. ones declared by plugins\n\t\t\tr.RLock()\n\t\t\th, ok := r.extraRoutes[req.URL.Path]\n\t\t\tr.RUnlock()\n\t\t\treq.URL.Path += extraData\n\t\t\t\n\t\t\tif ok {\n\t\t\t\t// TODO: Be more specific about *which* dynamic route it is\n\t\t\t\tco.RouteViewCounter.Bump(169)\n\t\t\t\treturn h(w,req,user)\n\t\t\t}\n\t\t\tco.RouteViewCounter.Bump3(176, cn)\n\n\t\t\tif !c.Config.DisableSuspLog {\n\t\t\tlp := strings.ToLower(req.URL.Path)\n\t\t\tif strings.Contains(lp,\"w\") {\n\t\t\t\tif strings.Contains(lp,\"wp\") || strings.Contains(lp,\"wordpress\") || strings.Contains(lp,\"wget\") || strings.Contains(lp,\"wp-\") {\n\t\t\t\t\tr.SuspiciousRequest(req,\"Bad Route\")\n\t\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif strings.Contains(lp,\"admin\") || strings.Contains(lp,\"sql\") || strings.Contains(lp,\"manage\") || strings.Contains(lp,\"//\") || strings.Contains(lp,\"\\\\\\\\\") || strings.Contains(lp,\"config\") || strings.Contains(lp,\"setup\") || strings.Contains(lp,\"install\") || strings.Contains(lp,\"update\") || strings.Contains(lp,\"php\") || strings.Contains(lp,\"pl\") || strings.Contains(lp,\"include\") || strings.Contains(lp,\"vendor\") || strings.Contains(lp,\"bin\") || strings.Contains(lp,\"system\") || strings.Contains(lp,\"eval\") || strings.Contains(lp,\"config\") {\n\t\t\t\tr.SuspiciousRequest(req,\"Bad Route\")\n\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t}\n\t\t\t}\n\n\t\t\tif !c.Config.DisableBadRouteLog {\n\t\t\t\tr.DumpRequest(req,\"Bad Route\")\n\t\t\t}\n\t\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\t\tlikelyBot := ae == \"gzip\" || ae == \"\"\n\t\t\tif likelyBot {\n\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t}\n\t\t\treturn c.NotFound(w,req,nil)\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "gen_tables.go",
    "content": "// Generated by Gosora's Query Generator. DO NOT EDIT.\npackage main\n\nvar dbTablePrimaryKeys = map[string]string{\n\t\"polls\":\"pollID\",\n\t\"widgets\":\"wid\",\n\t\"users_groups_promotions\":\"pid\",\n\t\"topics\":\"tid\",\n\t\"attachments\":\"attachID\",\n\t\"word_filters\":\"wfid\",\n\t\"menu_items\":\"miid\",\n\t\"users_groups\":\"gid\",\n\t\"users_2fa_keys\":\"uid\",\n\t\"activity_stream\":\"asid\",\n\t\"conversations_posts\":\"pid\",\n\t\"menus\":\"mid\",\n\t\"login_logs\":\"lid\",\n\t\"forums\":\"fid\",\n\t\"users_replies\":\"rid\",\n\t\"conversations\":\"cid\",\n\t\"replies\":\"rid\",\n\t\"revisions\":\"reviseID\",\n\t\"pages\":\"pid\",\n\t\"registration_logs\":\"rlid\",\n\t\"users\":\"uid\",\n\t\"users_groups_scheduler\":\"uid\",\n\t\"users_avatar_queue\":\"uid\",\n}\n"
  },
  {
    "path": "general_test.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/pkg/errors\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\te \"github.com/Azareal/Gosora/extend\"\n\t\"github.com/Azareal/Gosora/install\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/Gosora/routes\"\n)\n\n//var dbTest *sql.DB\nvar dbProd *sql.DB\nvar gloinited bool\nvar installAdapter install.InstallAdapter\n\nfunc ResetTables() (err error) {\n\terr = installAdapter.InitDatabase()\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\terr = installAdapter.TableDefs()\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\terr = installAdapter.CreateAdmin()\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn installAdapter.InitialData()\n}\n\nfunc gloinit() (e error) {\n\tif gloinited {\n\t\treturn nil\n\t}\n\t// TODO: Make these configurable via flags to the go test command\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\tc.Dev.TemplateDebug = false\n\tqgen.LogPrepares = false\n\t//nogrouplog = true\n\tc.StartTime = time.Now()\n\n\tws := func(e error) error {\n\t\treturn errors.WithStack(e)\n\t}\n\tif e = c.LoadConfig(); e != nil {\n\t\treturn ws(e)\n\t}\n\tif e = c.ProcessConfig(); e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Tasks = c.NewScheduledTasks()\n\n\te = c.InitTemplates()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Themes, e = c.NewThemeList()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.TopicListThaw = c.NewTestThaw()\n\tc.SwitchToTestDB()\n\n\tvar ok bool\n\tinstallAdapter, ok = install.Lookup(dbAdapter)\n\tif !ok {\n\t\treturn ws(errors.New(\"We couldn't find the adapter '\" + dbAdapter + \"'\"))\n\t}\n\tinstallAdapter.SetConfig(c.DbConfig.Host, c.DbConfig.Username, c.DbConfig.Password, c.DbConfig.Dbname, c.DbConfig.Port)\n\n\te = ResetTables()\n\tif e != nil {\n\t\treturn e\n\t}\n\te = InitDatabase()\n\tif e != nil {\n\t\treturn e\n\t}\n\te = afterDBInit()\n\tif e != nil {\n\t\treturn e\n\t}\n\n\trrcfg := rcfg()\n\trrcfg.DisableTick = false\n\trouter, e = NewGenRouter(rrcfg)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tgloinited = true\n\treturn nil\n}\n\nfunc rcfg() *RouterConfig {\n\treturn &RouterConfig{\n\t\tUploads:     http.FileServer(http.Dir(\"./uploads\")),\n\t\tDisableTick: true,\n\t}\n}\n\nfunc init() {\n\tif e := gloinit(); e != nil {\n\t\tlog.Print(\"Something bad happened\")\n\t\t//debug.PrintStack()\n\t\tlog.Fatalf(\"%+v\\n\", e)\n\t}\n}\n\nvar benchTidI = 1\nvar benchTid = \"1\"\n\n// TODO: Swap out LocalError for a panic for this?\nfunc BenchmarkTopicAdminRouteParallel(b *testing.B) {\n\tbinit(b)\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, err := c.Users.Get(1)\n\tif err != nil {\n\t\tb.Fatal(err)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tadminUIDCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tadminSessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", \"/topic/hm.\"+benchTid, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&adminUIDCookie)\n\t\t\treqAdmin.AddCookie(&adminSessionCookie)\n\n\t\t\t// Deal with the session stuff, etc.\n\t\t\tuser, ok := c.PreRoute(w, reqAdmin)\n\t\t\tif !ok {\n\t\t\t\tb.Fatal(\"Mysterious error!\")\n\t\t\t}\n\t\t\thead, e := c.UserCheck(w, reqAdmin, &user)\n\t\t\tif e != nil {\n\t\t\t\tb.Fatal(e)\n\t\t\t}\n\t\t\t//w.Body.Reset()\n\t\t\troutes.ViewTopic(w, reqAdmin, &user, head, \"1\")\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicAdminRouteParallelWithRouter(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topic/hm.\" + benchTid\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\t//w.Body.Reset()\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicAdminRouteParallelAlt(b *testing.B) {\n\tBenchmarkTopicAdminRouteParallel(b)\n}\n\nfunc BenchmarkTopicAdminRouteParallelWithRouterAlt(b *testing.B) {\n\tBenchmarkTopicAdminRouteParallelWithRouter(b)\n}\n\nfunc BenchmarkTopicAdminRouteParallelAltAlt(b *testing.B) {\n\tBenchmarkTopicAdminRouteParallel(b)\n}\n\nfunc BenchmarkTopicGuestAdminRouteParallelWithRouterPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicGuestAdminRouteParallelWithRouter(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topic/hm.\" + benchTid\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\n\t\t\t{\n\t\t\t\tw := httptest.NewRecorder()\n\t\t\t\treq := httptest.NewRequest(\"GET\", path, bytes.NewReader(nil))\n\t\t\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\t\treq.Header.Set(\"Host\", \"localhost\")\n\t\t\t\treq.Host = \"localhost\"\n\t\t\t\trouter.ServeHTTP(w, req)\n\t\t\t\tif w.Code != 200 {\n\t\t\t\t\tb.Log(w.Body)\n\t\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicGuestAdminRouteParallelWithRouterPre2(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicGuestAdminRouteParallelWithRouterGC(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topic/hm.\" + benchTid\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\n\t\t\t{\n\t\t\t\tw := httptest.NewRecorder()\n\t\t\t\treq := httptest.NewRequest(\"GET\", path, bytes.NewReader(nil))\n\t\t\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\t\treq.Header.Set(\"Host\", \"localhost\")\n\t\t\t\treq.Host = \"localhost\"\n\t\t\t\trouter.ServeHTTP(w, req)\n\t\t\t\tif w.Code != 200 {\n\t\t\t\t\tb.Log(w.Body)\n\t\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicGuestRouteParallel(b *testing.B) {\n\tbinit(b)\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treq := httptest.NewRequest(\"get\", \"/topic/hm.\"+benchTid, bytes.NewReader(nil))\n\t\t\tuser := c.GuestUser\n\n\t\t\thead, e := c.UserCheck(w, req, &user)\n\t\t\tif e != nil {\n\t\t\t\tb.Fatal(e)\n\t\t\t}\n\t\t\t//w.Body.Reset()\n\t\t\troutes.ViewTopic(w, req, &user, head, \"1\")\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\t})\n\tcfg.Restore()\n}\n\n//before\n\nfunc BenchmarkForumsRouteAdminParallelWithRouterGC2Pre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkForumsRouteAdminParallelWithRouterGC2(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/forums/\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkForumsRouteAdminParallelWithRouterGCBrotliPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkForumsRouteAdminParallelWithRouterGCBrotli(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/forums/\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Accept-Encoding\", \"br\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\n//end\n//before\n\nfunc BenchmarkTopicRouteAdminParallelWithRouterGC2Pre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicRouteAdminParallelWithRouterGC2(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topic/1\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicRouteAdminParallelWithRouterGCBrotliPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicRouteAdminParallelWithRouterGCBrotli(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topic/1\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Accept-Encoding\", \"br\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\n//end\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGC2Pre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGC2(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topics/\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGCBrotliPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGCBrotli(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topics/\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Accept-Encoding\", \"br\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGCGzipPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkTopicsRouteAdminParallelWithRouterGCGzip(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/topics/\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicGuestRouteParallelDebugMode(b *testing.B) {\n\tbinit(b)\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = true\n\tc.Dev.SuperDebug = false\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treq := httptest.NewRequest(\"get\", \"/topic/hm.\"+benchTid, bytes.NewReader(nil))\n\t\t\tuser := c.GuestUser\n\n\t\t\thead, err := c.UserCheck(w, req, &user)\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t}\n\t\t\t//w.Body.Reset()\n\t\t\troutes.ViewTopic(w, req, &user, head, \"1\")\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\t})\n\tcfg.Restore()\n}\n\nfunc BenchmarkAlertsRouteAdminParallelWithRouterGCPre(b *testing.B) {\n\truntime.GC()\n}\n\nfunc BenchmarkAlertsRouteAdminParallelWithRouterGC(b *testing.B) {\n\tbinit(b)\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\n\tadmin, e := c.Users.Get(1)\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\tif !admin.IsAdmin {\n\t\tb.Fatal(\"UID1 is not an admin\")\n\t}\n\tuidCookie := http.Cookie{Name: \"uid\", Value: \"1\", Path: \"/\", MaxAge: c.Year}\n\tsessionCookie := http.Cookie{Name: \"session\", Value: admin.Session, Path: \"/\", MaxAge: c.Year}\n\tpath := \"/api/?m=alerts\"\n\t//runtime.GC()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treqAdmin := httptest.NewRequest(\"get\", path, bytes.NewReader(nil))\n\t\t\treqAdmin.AddCookie(&uidCookie)\n\t\t\treqAdmin.AddCookie(&sessionCookie)\n\t\t\treqAdmin.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treqAdmin.Header.Set(\"Host\", \"localhost\")\n\t\t\treqAdmin.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, reqAdmin)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\n\t\truntime.GC()\n\t})\n\n\tcfg.Restore()\n}\n\nfunc obRoute(b *testing.B, path string) {\n\tbinit(b)\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\tb.RunParallel(benchRoute(b, path))\n\tcfg.Restore()\n}\n\nfunc obRouteNoError(b *testing.B, path string) {\n\tbinit(b)\n\tcfg := NewStashConfig()\n\tc.Dev.DebugMode = false\n\tc.Dev.SuperDebug = false\n\tb.RunParallel(benchRouteNoError(b, path))\n\tcfg.Restore()\n}\n\nfunc BenchmarkTopicsGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topics/\")\n}\n\nfunc BenchmarkTopicsGuestJSRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topics/?js=1\")\n}\n\nfunc BenchmarkForumsGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/forums/\")\n}\n\nfunc BenchmarkForumGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/forum/general.2\")\n}\n\nfunc BenchmarkTopicGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid)\n}\n\nfunc BenchmarkTopicGuestRouteParallelWithRouterAlt(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid)\n}\n\nfunc BenchmarkBadRouteGuestRouteParallelWithRouter(b *testing.B) {\n\tobRouteNoError(b, \"/garble/haa\")\n}\n\nfunc BenchmarkAlertsRouteGuestParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/api/?m=alerts\")\n}\n\n// TODO: Alternate between member and guest to bust some CPU caches?\n\nfunc binit(b *testing.B) {\n\tb.ReportAllocs()\n\tif err := gloinit(); err != nil {\n\t\tb.Fatal(err)\n\t}\n}\n\ntype StashConfig struct {\n\tprev  bool\n\tprev2 bool\n}\n\nfunc NewStashConfig() *StashConfig {\n\tprev := c.Dev.DebugMode\n\tprev2 := c.Dev.SuperDebug\n\treturn &StashConfig{prev, prev2}\n}\n\nfunc (cfg *StashConfig) Restore() {\n\tc.Dev.DebugMode = cfg.prev\n\tc.Dev.SuperDebug = cfg.prev2\n}\n\nfunc benchRoute(b *testing.B, path string) func(*testing.PB) {\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\treturn func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treq := httptest.NewRequest(\"GET\", path, bytes.NewReader(nil))\n\t\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treq.Header.Set(\"Host\", \"localhost\")\n\t\t\treq.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, req)\n\t\t\tif w.Code != 200 {\n\t\t\t\tb.Log(w.Body)\n\t\t\t\tb.Fatal(\"HTTP Error!\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc benchRouteNoError(b *testing.B, path string) func(*testing.PB) {\n\trouter, e := NewGenRouter(rcfg())\n\tif e != nil {\n\t\tb.Fatal(e)\n\t}\n\treturn func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tw := httptest.NewRecorder()\n\t\t\treq := httptest.NewRequest(\"GET\", path, bytes.NewReader(nil))\n\t\t\treq.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36\")\n\t\t\treq.Header.Set(\"Host\", \"localhost\")\n\t\t\treq.Host = \"localhost\"\n\t\t\trouter.ServeHTTP(w, req)\n\t\t}\n\t}\n}\n\nfunc BenchmarkProfileGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/profile/admin.1\")\n}\n\nfunc BenchmarkPopulateTopicWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\ttopic, err := c.Topics.Get(benchTidI)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tfor i := 0; i < 25; i++ {\n\t\t\t\t_, err := c.Rstore.Create(topic, \"hiii\", \"\", 1)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\n//var fullPage = false\n\nfunc BenchmarkTopicAdminFullPageRouteParallelWithRouter(b *testing.B) {\n\t/*if !fullPage {\n\t\ttopic, err := c.Topics.Get(benchTidI)\n\t\tpanicIfErr(err)\n\t\tfor i := 0; i < 25; i++ {\n\t\t\t_, err = c.Rstore.Create(topic, \"hiii\", \"::1\", 1)\n\t\t\tpanicIfErr(err)\n\t\t}\n\t\tfullPage = true\n\t}*/\n\tBenchmarkTopicAdminRouteParallel(b)\n}\n\nfunc BenchmarkTopicGuestFullPageRouteParallelWithRouter(b *testing.B) {\n\t/*if !fullPage {\n\t\ttopic, err := c.Topics.Get(benchTidI)\n\t\tpanicIfErr(err)\n\t\tfor i := 0; i < 25; i++ {\n\t\t\t_, err = c.Rstore.Create(topic, \"hiii\", \"::1\", 1)\n\t\t\tpanicIfErr(err)\n\t\t}\n\t\tfullPage = true\n\t}*/\n\tobRoute(b, \"/topic/hm.\"+benchTid)\n}\n\nvar benchTidI2 int\nvar benchTid2 string\n\nfunc BenchmarkPopulateTopicMentionWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\ttid, err := c.Topics.Create(2, \"test topic\", \"@1\", 1, \"\")\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchTidI2 = tid\n\tbenchTid2 = strconv.Itoa(tid)\n\ttopic, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tfor i := 0; i < 25; i++ {\n\t\t\t\t_, err := c.Rstore.Create(topic, \"@1\", \"\", 1)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkTopicMentionAdminFullPageRouteParallelWithRouter(b *testing.B) {\n\ttI := benchTidI\n\tt := benchTid\n\tbenchTidI = benchTidI2\n\tbenchTid = benchTid2\n\tBenchmarkTopicAdminRouteParallel(b)\n\tbenchTidI = tI\n\tbenchTid = t\n}\n\nfunc BenchmarkTopicMentionGuestFullPageRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid2)\n}\n\nvar benchTidI3 int\nvar benchTid3 string\n\nfunc BenchmarkPopulateTopic10MentionWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\ttid, err := c.Topics.Create(2, \"test topic\", \"@1 @1 @1 @1 @1 @1 @1 @1 @1 @1\", 1, \"\")\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchTidI3 = tid\n\tbenchTid3 = strconv.Itoa(tid)\n\ttopic, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tfor i := 0; i < 25; i++ {\n\t\t\t\t_, err := c.Rstore.Create(topic, \"@1 @1 @1 @1 @1 @1 @1 @1 @1 @1\", \"\", 1)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkTopic10MentionAdminFullPageRouteParallelWithRouter(b *testing.B) {\n\ttI := benchTidI\n\tt := benchTid\n\tbenchTidI = benchTidI3\n\tbenchTid = benchTid3\n\tBenchmarkTopicAdminRouteParallel(b)\n\tbenchTidI = tI\n\tbenchTid = t\n}\n\nfunc BenchmarkTopic10MentionGuestFullPageRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid3)\n}\n\nvar benchTidI4 int\nvar benchTid4 string\n\nfunc BenchmarkPopulateTopic1ReplyWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\ttid, err := c.Topics.Create(2, \"test topic\", \"hiii\", 1, \"\")\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchTidI4 = tid\n\tbenchTid4 = strconv.Itoa(tid)\n\ttopic, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\t_, err := c.Rstore.Create(topic, \"hiii\", \"\", 1)\n\t\t\tif err != nil {\n\t\t\t\tdebug.PrintStack()\n\t\t\t\tb.Fatal(err)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkTopic1ReplyAdminRouteParallelWithRouter(b *testing.B) {\n\ttI := benchTidI\n\tt := benchTid\n\tbenchTidI = benchTidI4\n\tbenchTid = benchTid4\n\tBenchmarkTopicAdminRouteParallel(b)\n\tbenchTidI = tI\n\tbenchTid = t\n}\n\nfunc BenchmarkTopic1ReplyGuestRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid4)\n}\n\nvar benchTidI5 int\nvar benchTid5 string\nvar benchUidI int\n\nfunc BenchmarkPopulateTopic2UserWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\tnUid, err := c.Users.Create(\"testing\", \"testpass\", \"\", 2, true)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchUidI = nUid\n\ttid, err := c.Topics.Create(2, \"test topic\", \"hiii\", 1, \"\")\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchTidI5 = tid\n\tbenchTid5 = strconv.Itoa(tid)\n\ttopic, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tvar uid int\n\t\tfor pb.Next() {\n\t\t\tfor i := 0; i < 25; i++ {\n\t\t\t\tif i%2 == 0 {\n\t\t\t\t\tuid = nUid\n\t\t\t\t} else {\n\t\t\t\t\tuid = 1\n\t\t\t\t}\n\t\t\t\t_, err := c.Rstore.Create(topic, \"hiii\", \"\", uid)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkTopic2UserAdminFullPageRouteParallelWithRouter(b *testing.B) {\n\ttI := benchTidI\n\tt := benchTid\n\tbenchTidI = benchTidI5\n\tbenchTid = benchTid5\n\tBenchmarkTopicAdminRouteParallel(b)\n\tbenchTidI = tI\n\tbenchTid = t\n}\n\nfunc BenchmarkTopic2UserGuestFullPageRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid5)\n}\n\nvar benchTidI6 int\nvar benchTid6 string\n\nfunc BenchmarkPopulateTopic3UserWithRouter(b *testing.B) {\n\tb.ReportAllocs()\n\tnUid, err := c.Users.Create(\"testing2\", \"testpass\", \"\", 2, true)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\ttid, err := c.Topics.Create(2, \"test topic\", \"hiii\", 1, \"\")\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tbenchTidI6 = tid\n\tbenchTid6 = strconv.Itoa(tid)\n\tt, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\tdebug.PrintStack()\n\t\tb.Fatal(err)\n\t}\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tfor i := 0; i < 5; i++ {\n\t\t\t\t_, err := c.Rstore.Create(t, \"hiii\", \"\", 1)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t\t_, err = c.Rstore.Create(t, \"hiii\", \"\", benchUidI)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t\t_, err = c.Rstore.Create(t, \"hiii\", \"\", nUid)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdebug.PrintStack()\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkTopic3UserAdminFullPageRouteParallelWithRouter(b *testing.B) {\n\ttI := benchTidI\n\tt := benchTid\n\tbenchTidI = benchTidI6\n\tbenchTid = benchTid6\n\tBenchmarkTopicAdminRouteParallel(b)\n\tbenchTidI = tI\n\tbenchTid = t\n}\n\nfunc BenchmarkTopic3UserGuestFullPageRouteParallelWithRouter(b *testing.B) {\n\tobRoute(b, \"/topic/hm.\"+benchTid6)\n}\n\n// TODO: Add topic poll page bench\n\n// TODO: Make these routes compatible with the changes to the router\n/*\nfunc BenchmarkForumsAdminRouteParallel(b *testing.B) {\n\tb.ReportAllocs()\n\tgloinit()\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tadmin, err := users.Get(1)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tif !admin.Is_Admin {\n\t\t\tpanic(\"UID1 is not an admin\")\n\t\t}\n\t\tadminUidCookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\t\tadminSessionCookie := http.Cookie{Name:\"session\",Value: admin.Session,Path:\"/\",MaxAge: year}\n\n\t\tforumsW := httptest.NewRecorder()\n\t\tforumsReq := httptest.NewRequest(\"get\",\"/forums/\",bytes.NewReader(nil))\n\t\tforumsReqAdmin := forums_req\n\t\tforumsReqAdmin.AddCookie(&adminUidCookie)\n\t\tforumsReqAdmin.AddCookie(&adminSessionCookie)\n\t\tforumsHandler := http.HandlerFunc(route_forums)\n\n\t\tfor pb.Next() {\n\t\t\tforumsW.Body.Reset()\n\t\t\tforumsHandler.ServeHTTP(forumsW,forumsReqAdmin)\n\t\t}\n\t})\n}\n\nfunc BenchmarkForumsAdminRouteParallelProf(b *testing.B) {\n\tb.ReportAllocs()\n\tgloinit()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tadmin, err := users.Get(1)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tif !admin.Is_Admin {\n\t\t\tpanic(\"UID1 is not an admin\")\n\t\t}\n\t\tadminUidCookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\t\tadminSessionCookie := http.Cookie{Name:\"session\",Value: admin.Session,Path: \"/\",MaxAge: year}\n\n\t\tforumsW := httptest.NewRecorder()\n\t\tforumsReq := httptest.NewRequest(\"get\",\"/forums/\",bytes.NewReader(nil))\n\t\tforumsReqAdmin := forumsReq\n\t\tforumsReqAdmin.AddCookie(&admin_uid_cookie)\n\t\tforumsReqAdmin.AddCookie(&admin_session_cookie)\n\t\tforumsHandler := http.HandlerFunc(route_forums)\n\t\tf, err := os.Create(\"cpu_forums_admin_parallel.prof\")\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tpprof.StartCPUProfile(f)\n\t\tfor pb.Next() {\n\t\t\tforumsW.Body.Reset()\n\t\t\tforumsHandler.ServeHTTP(forumsW,forumsReqAdmin)\n\t\t}\n\t\tpprof.StopCPUProfile()\n\t})\n}\n\nfunc BenchmarkRoutesSerial(b *testing.B) {\n\tb.ReportAllocs()\n\tadmin, err := users.Get(1)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif !admin.Is_Admin {\n\t\tpanic(\"UID1 is not an admin\")\n\t}\n\n\tadmin_uid_cookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\tadmin_session_cookie := http.Cookie{Name:\"session\",Value: admin.Session,Path: \"/\",MaxAge: year}\n\n\tif plugins_inited {\n\t\tb.Log(\"Plugins have already been initialised, they can't be deinitialised so these tests will run with plugins on\")\n\t}\n\tstatic_w := httptest.NewRecorder()\n\tstatic_req := httptest.NewRequest(\"get\",\"/s/global.js\",bytes.NewReader(nil))\n\tstatic_handler := http.HandlerFunc(route_static)\n\n\ttopic_w := httptest.NewRecorder()\n\ttopic_req := httptest.NewRequest(\"get\",\"/topic/1\",bytes.NewReader(nil))\n\ttopic_req_admin := topic_req\n\ttopic_req_admin.AddCookie(&admin_uid_cookie)\n\ttopic_req_admin.AddCookie(&admin_session_cookie)\n\ttopic_handler := http.HandlerFunc(route_topic_id)\n\n\ttopics_w := httptest.NewRecorder()\n\ttopics_req := httptest.NewRequest(\"get\",\"/topics/\",bytes.NewReader(nil))\n\ttopics_req_admin := topics_req\n\ttopics_req_admin.AddCookie(&admin_uid_cookie)\n\ttopics_req_admin.AddCookie(&admin_session_cookie)\n\ttopics_handler := http.HandlerFunc(route_topics)\n\n\tforum_w := httptest.NewRecorder()\n\tforum_req := httptest.NewRequest(\"get\",\"/forum/1\",bytes.NewReader(nil))\n\tforum_req_admin := forum_req\n\tforum_req_admin.AddCookie(&admin_uid_cookie)\n\tforum_req_admin.AddCookie(&admin_session_cookie)\n\tforum_handler := http.HandlerFunc(route_forum)\n\n\tforums_w := httptest.NewRecorder()\n\tforums_req := httptest.NewRequest(\"get\",\"/forums/\",bytes.NewReader(nil))\n\tforums_req_admin := forums_req\n\tforums_req_admin.AddCookie(&admin_uid_cookie)\n\tforums_req_admin.AddCookie(&admin_session_cookie)\n\tforums_handler := http.HandlerFunc(route_forums)\n\n\tgloinit()\n\n\t//f, err := os.Create(\"routes_bench_cpu.prof\")\n\t//if err != nil {\n\t//\tlog.Fatal(err)\n\t//}\n\t//pprof.StartCPUProfile(f)\n\t///defer pprof.StopCPUProfile()\n\t///pprof.StopCPUProfile()\n\n\tb.Run(\"static_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//static_w.Code = 200\n\t\t\tstatic_w.Body.Reset()\n\t\t\tstatic_handler.ServeHTTP(static_w,static_req)\n\t\t\t//if static_w.Code != 200 {\n\t\t\t//\tb.Print(static_w.Body)\n\t\t\t//\tb.Fatal(\"HTTP Error!\")\n\t\t\t//}\n\t\t}\n\t})\n\n\tb.Run(\"topic_admin_recorder\", func(b *testing.B) {\n\t\t//f, err := os.Create(\"routes_bench_topic_cpu.prof\")\n\t\t//if err != nil {\n\t\t//\tb.Fatal(err)\n\t\t//}\n\t\t//pprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topic_w.Code = 200\n\t\t\ttopic_w.Body.Reset()\n\t\t\ttopic_handler.ServeHTTP(topic_w,topic_req_admin)\n\t\t\t//if topic_w.Code != 200 {\n\t\t\t//\tb.Print(topic_w.Body)\n\t\t\t//\tb.Fatal(\"HTTP Error!\")\n\t\t\t//}\n\t\t}\n\t\t//pprof.StopCPUProfile()\n\t})\n\tb.Run(\"topic_guest_recorder\", func(b *testing.B) {\n\t\tf, err := os.Create(\"routes_bench_topic_cpu_2.prof\")\n\t\tif err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t\tpprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topic_w.Code = 200\n\t\t\ttopic_w.Body.Reset()\n\t\t\ttopic_handler.ServeHTTP(topic_w,topic_req)\n\t\t}\n\t\tpprof.StopCPUProfile()\n\t})\n\tb.Run(\"topics_admin_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topics_w.Code = 200\n\t\t\ttopics_w.Body.Reset()\n\t\t\ttopics_handler.ServeHTTP(topics_w,topics_req_admin)\n\t\t}\n\t})\n\tb.Run(\"topics_guest_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topics_w.Code = 200\n\t\t\ttopics_w.Body.Reset()\n\t\t\ttopics_handler.ServeHTTP(topics_w,topics_req)\n\t\t}\n\t})\n\tb.Run(\"forum_admin_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forum_w.Code = 200\n\t\t\tforum_w.Body.Reset()\n\t\t\tforum_handler.ServeHTTP(forum_w,forum_req_admin)\n\t\t}\n\t})\n\tb.Run(\"forum_guest_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forum_w.Code = 200\n\t\t\tforum_w.Body.Reset()\n\t\t\tforum_handler.ServeHTTP(forum_w,forum_req)\n\t\t}\n\t})\n\tb.Run(\"forums_admin_recorder\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forums_w.Code = 200\n\t\t\tforums_w.Body.Reset()\n\t\t\tforums_handler.ServeHTTP(forums_w,forums_req_admin)\n\t\t}\n\t})\n\tb.Run(\"forums_guest_recorder\", func(b *testing.B) {\n\t\t//f, err := os.Create(\"routes_bench_forums_cpu_2.prof\")\n\t\t//if err != nil {\n\t\t//\tb.Fatal(err)\n\t\t//}\n\t\t//pprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forums_w.Code = 200\n\t\t\tforums_w.Body.Reset()\n\t\t\tforums_handler.ServeHTTP(forums_w,forums_req)\n\t\t}\n\t\t//pprof.StopCPUProfile()\n\t})\n\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tb.Run(\"topic_admin_recorder_with_plugins\", func(b *testing.B) {\n\t\t//f, err := os.Create(\"routes_bench_topic_cpu.prof\")\n\t\t//if err != nil {\n\t\t//\tb.Fatal(err)\n\t\t//}\n\t\t//pprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topic_w.Code = 200\n\t\t\ttopic_w.Body.Reset()\n\t\t\ttopic_handler.ServeHTTP(topic_w,topic_req_admin)\n\t\t\t//if topic_w.Code != 200 {\n\t\t\t//\tb.Print(topic_w.Body)\n\t\t\t//\tb.Fatal(\"HTTP Error!\")\n\t\t\t//}\n\t\t}\n\t\t//pprof.StopCPUProfile()\n\t})\n\tb.Run(\"topic_guest_recorder_with_plugins\", func(b *testing.B) {\n\t\t//f, err := os.Create(\"routes_bench_topic_cpu_2.prof\")\n\t\t//if err != nil {\n\t\t//\tb.Fatal(err)\n\t\t//}\n\t\t//pprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topic_w.Code = 200\n\t\t\ttopic_w.Body.Reset()\n\t\t\ttopic_handler.ServeHTTP(topic_w,topic_req)\n\t\t}\n\t\t//pprof.StopCPUProfile()\n\t})\n\tb.Run(\"topics_admin_recorder_with_plugins\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topics_w.Code = 200\n\t\t\ttopics_w.Body.Reset()\n\t\t\ttopics_handler.ServeHTTP(topics_w,topics_req_admin)\n\t\t}\n\t})\n\tb.Run(\"topics_guest_recorder_with_plugins\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//topics_w.Code = 200\n\t\t\ttopics_w.Body.Reset()\n\t\t\ttopics_handler.ServeHTTP(topics_w,topics_req)\n\t\t}\n\t})\n\tb.Run(\"forum_admin_recorder_with_plugins\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forum_w.Code = 200\n\t\t\tforum_w.Body.Reset()\n\t\t\tforum_handler.ServeHTTP(forum_w,forum_req_admin)\n\t\t}\n\t})\n\tb.Run(\"forum_guest_recorder_with_plugins\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forum_w.Code = 200\n\t\t\tforum_w.Body.Reset()\n\t\t\tforum_handler.ServeHTTP(forum_w,forum_req)\n\t\t}\n\t})\n\tb.Run(\"forums_admin_recorder_with_plugins\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forums_w.Code = 200\n\t\t\tforums_w.Body.Reset()\n\t\t\tforums_handler.ServeHTTP(forums_w,forums_req_admin)\n\t\t}\n\t})\n\tb.Run(\"forums_guest_recorder_with_plugins\", func(b *testing.B) {\n\t\t//f, err := os.Create(\"routes_bench_forums_cpu_2.prof\")\n\t\t//if err != nil {\n\t\t//\tb.Fatal(err)\n\t\t//}\n\t\t//pprof.StartCPUProfile(f)\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\t//forums_w.Code = 200\n\t\t\tforums_w.Body.Reset()\n\t\t\tforums_handler.ServeHTTP(forums_w,forums_req)\n\t\t}\n\t\t//pprof.StopCPUProfile()\n\t})\n}*/\n\nfunc BenchmarkQueryTopicParallel(b *testing.B) {\n\tb.ReportAllocs()\n\tif err := gloinit(); err != nil {\n\t\tb.Fatal(err)\n\t}\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tvar tu c.TopicUser\n\t\tfor pb.Next() {\n\t\t\terr := db.QueryRow(\"select topics.title, topics.content, topics.createdBy, topics.createdAt, topics.is_closed, topics.sticky, topics.parentID, topics.ip, topics.views, topics.postCount, topics.likeCount, users.name, users.avatar, users.group, users.level from topics left join users ON topics.createdBy = users.uid where tid = ?\", 1).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.ViewCount, &tu.PostCount, &tu.LikeCount, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level)\n\t\t\tif err == ErrNoRows {\n\t\t\t\tlog.Fatal(\"No rows found!\")\n\t\t\t\treturn\n\t\t\t} else if err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkQueryPreparedTopicParallel(b *testing.B) {\n\tb.ReportAllocs()\n\tif err := gloinit(); err != nil {\n\t\tb.Fatal(err)\n\t}\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tvar tu c.TopicUser\n\n\t\tgetTopicUser, err := qgen.Builder.SimpleLeftJoin(\"topics\", \"users\", \"topics.title, topics.content, topics.createdBy, topics.createdAt, topics.is_closed, topics.sticky, topics.parentID, topics.ip, topics.postCount, topics.likeCount, users.name, users.avatar, users.group, users.level\", \"topics.createdBy=users.uid\", \"tid=?\", \"\", \"\")\n\t\tif err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t\tdefer getTopicUser.Close()\n\n\t\tfor pb.Next() {\n\t\t\terr := getTopicUser.QueryRow(1).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.PostCount, &tu.LikeCount, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level)\n\t\t\tif err == ErrNoRows {\n\t\t\t\tb.Fatal(\"No rows found!\")\n\t\t\t\treturn\n\t\t\t} else if err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkUserGet(b *testing.B) {\n\tb.ReportAllocs()\n\tif err := gloinit(); err != nil {\n\t\tb.Fatal(err)\n\t}\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tvar err error\n\t\tfor pb.Next() {\n\t\t\t_, err = c.Users.Get(1)\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkUserBypassGet(b *testing.B) {\n\tb.ReportAllocs()\n\tif err := gloinit(); err != nil {\n\t\tb.Fatal(err)\n\t}\n\n\t// Bypass the cache and always hit the database\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tvar err error\n\t\tfor pb.Next() {\n\t\t\t_, err = c.Users.BypassGet(1)\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc BenchmarkQueriesSerial(b *testing.B) {\n\tb.ReportAllocs()\n\tvar tu c.TopicUser\n\tb.Run(\"topic\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\terr := db.QueryRow(\"select topics.title, topics.content, topics.createdBy, topics.createdAt, topics.is_closed, topics.sticky, topics.parentID, topics.ip, topics.postCount, topics.likeCount, users.name, users.avatar, users.group, users.level from topics left join users ON topics.createdBy = users.uid where tid = ?\", 1).Scan(&tu.Title, &tu.Content, &tu.CreatedBy, &tu.CreatedAt, &tu.IsClosed, &tu.Sticky, &tu.ParentID, &tu.IP, &tu.PostCount, &tu.LikeCount, &tu.CreatedByName, &tu.Avatar, &tu.Group, &tu.Level)\n\t\t\tif err == ErrNoRows {\n\t\t\t\tb.Fatal(\"No rows found!\")\n\t\t\t\treturn\n\t\t\t} else if err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n\tb.Run(\"topic_replies\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\trows, err := db.Query(\"select replies.rid, replies.content, replies.createdBy, replies.createdAt, replies.lastEdit, replies.lastEditBy, users.avatar, users.name, users.is_super_admin, users.group, users.level, replies.ip from replies left join users ON replies.createdBy = users.uid where tid = ?\", 1)\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t}\n\t\t\terr = rows.Err()\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n\n\tvar r c.ReplyUser\n\tvar isSuperAdmin bool\n\tvar group int\n\tb.Run(\"topic_replies_scan\", func(b *testing.B) {\n\t\tfor i := 0; i < b.N; i++ {\n\t\t\trows, err := db.Query(\"select replies.rid, replies.content, replies.createdBy, replies.createdAt, replies.lastEdit, replies.lastEditBy, users.avatar, users.name, users.is_super_admin, users.group, users.level, replies.ip from replies left join users ON replies.createdBy = users.uid where tid = ?\", 1)\n\t\t\tif err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor rows.Next() {\n\t\t\t\terr := rows.Scan(&r.ID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.Avatar, &r.CreatedByName, &isSuperAdmin, &group, &r.Level, &r.IP)\n\t\t\t\tif err != nil {\n\t\t\t\t\tb.Fatal(err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tdefer rows.Close()\n\t\t\tif err = rows.Err(); err != nil {\n\t\t\t\tb.Fatal(err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t})\n}\n\n// TODO: Take the attachment system into account in these parser benches\nfunc BenchmarkParserSerial(b *testing.B) {\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tb.ReportAllocs()\n\tf := func(name, msg string) func(b *testing.B) {\n\t\treturn func(b *testing.B) {\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = c.ParseMessage(msg, 0, \"\", nil, nil)\n\t\t\t}\n\t\t}\n\t}\n\tf(\"empty_post\", \"\")\n\tf(\"short_post\", \"Hey everyone, how's it going?\")\n\tf(\"one_smily\", \"Hey everyone, how's it going? :)\")\n\tf(\"five_smilies\", \"Hey everyone, how's it going? :):):):):)\")\n\tf(\"ten_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):)\")\n\tf(\"twenty_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):):):):):):):):):):):)\")\n}\n\nfunc BenchmarkBBCodePluginWithRegexpSerial(b *testing.B) {\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tb.ReportAllocs()\n\tf := func(name string, msg string) {\n\t\tb.Run(name, func(b *testing.B) {\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = e.BbcodeRegexParse(msg)\n\t\t\t}\n\t\t})\n\t}\n\tf(\"empty_post\", \"\")\n\tf(\"short_post\", \"Hey everyone, how's it going?\")\n\tf(\"one_smily\", \"Hey everyone, how's it going? :)\")\n\tf(\"five_smilies\", \"Hey everyone, how's it going? :):):):):)\")\n\tf(\"ten_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):)\")\n\tf(\"twenty_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):):):):):):):):):):):)\")\n\tf(\"one_bold\", \"[b]H[/b]ey everyone, how's it going?\")\n\tf(\"five_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b]eryone, how's it going?\")\n\tf(\"ten_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b][b]e[/b][b]r[/b][b]y[/b][b]o[/b][b]n[/b]e, how's it going?\")\n}\n\nfunc BenchmarkBBCodePluginWithoutCodeTagSerial(b *testing.B) {\n\tb.ReportAllocs()\n\tf := func(name string, msg string) {\n\t\tb.Run(name, func(b *testing.B) {\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = e.BbcodeParseWithoutCode(msg)\n\t\t\t}\n\t\t})\n\t}\n\tf(\"empty_post\", \"\")\n\tf(\"short_post\", \"Hey everyone, how's it going?\")\n\tf(\"one_smily\", \"Hey everyone, how's it going? :)\")\n\tf(\"five_smilies\", \"Hey everyone, how's it going? :):):):):)\")\n\tf(\"ten_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):)\")\n\tf(\"twenty_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):):):):):):):):):):):)\")\n\tf(\"one_bold\", \"[b]H[/b]ey everyone, how's it going?\")\n\tf(\"five_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b]eryone, how's it going?\")\n\tf(\"ten_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b][b]e[/b][b]r[/b][b]y[/b][b]o[/b][b]n[/b]e, how's it going?\")\n}\n\nfunc BenchmarkBBCodePluginWithFullParserSerial(b *testing.B) {\n\tb.ReportAllocs()\n\tf := func(name string, msg string) {\n\t\tb.Run(name, func(b *testing.B) {\n\t\t\tfor i := 0; i < b.N; i++ {\n\t\t\t\t_ = e.BbcodeFullParse(msg)\n\t\t\t}\n\t\t})\n\t}\n\tf(\"empty_post\", \"\")\n\tf(\"short_post\", \"Hey everyone, how's it going?\")\n\tf(\"one_smily\", \"Hey everyone, how's it going? :)\")\n\tf(\"five_smilies\", \"Hey everyone, how's it going? :):):):):)\")\n\tf(\"ten_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):)\")\n\tf(\"twenty_smilies\", \"Hey everyone, how's it going? :):):):):):):):):):):):):):):):):):):):)\")\n\tf(\"one_bold\", \"[b]H[/b]ey everyone, how's it going?\")\n\tf(\"five_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b]eryone, how's it going?\")\n\tf(\"ten_bold\", \"[b]H[/b][b]e[/b][b]y[/b] [b]e[/b][b]v[/b][b]e[/b][b]r[/b][b]y[/b][b]o[/b][b]n[/b]e, how's it going?\")\n}\n\nfunc TestLevels(t *testing.T) {\n\tlevels := c.GetLevels(40)\n\tfor level, score := range levels {\n\t\tsscore := strconv.FormatFloat(score, 'f', -1, 64)\n\t\tt.Log(\"Level: \" + strconv.Itoa(level) + \" Score: \" + sscore)\n\t}\n}\n\n// TODO: Make this compatible with the changes to the router\n/*\nfunc TestStaticRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tstatic_w := httptest.NewRecorder()\n\tstatic_req := httptest.NewRequest(\"get\",\"/s/global.js\",bytes.NewReader(nil))\n\tstatic_handler := http.HandlerFunc(route_static)\n\n\tstatic_handler.ServeHTTP(static_w,static_req)\n\tif static_w.Code != 200 {\n\t\tt.Fatal(static_w.Body)\n\t}\n}\n*/\n\n/*func TestTopicAdminRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tadmin, err := users.Get(1)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif !admin.Is_Admin {\n\t\tpanic(\"UID1 is not an admin\")\n\t}\n\n\tadmin_uid_cookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\tadmin_session_cookie := http.Cookie{Name:\"session\",Value: admin.Session,Path:\"/\",MaxAge: year}\n\n\ttopic_w := httptest.NewRecorder()\n\ttopic_req := httptest.NewRequest(\"get\",\"/topic/1\",bytes.NewReader(nil))\n\ttopic_req_admin := topic_req\n\ttopic_req_admin.AddCookie(&admin_uid_cookie)\n\ttopic_req_admin.AddCookie(&admin_session_cookie)\n\ttopic_handler := http.HandlerFunc(route_topic_id)\n\n\ttopic_handler.ServeHTTP(topic_w,topic_req_admin)\n\tif topic_w.Code != 200 {\n\t\tt.Print(topic_w.Body)\n\t\tt.Fatal(\"HTTP Error!\")\n\t}\n\tt.Print(\"No problems found in the topic-admin route!\")\n}*/\n\n/*func TestTopicGuestRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\ttopic_w := httptest.NewRecorder()\n\ttopic_req := httptest.NewRequest(\"get\",\"/topic/1\",bytes.NewReader(nil))\n\ttopic_handler := http.HandlerFunc(route_topic_id)\n\n\ttopic_handler.ServeHTTP(topic_w,topic_req)\n\tif topic_w.Code != 200 {\n\t\tt.Print(topic_w.Body)\n\t\tt.Fatal(\"HTTP Error!\")\n\t}\n\tt.Print(\"No problems found in the topic-guest route!\")\n}*/\n\n// TODO: Make these routes compatible with the changes to the router\n/*\nfunc TestForumsAdminRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tadmin, err := users.Get(1)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !admin.Is_Admin {\n\t\tt.Fatal(\"UID1 is not an admin\")\n\t}\n\tadminUidCookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\tadminSessionCookie := http.Cookie{Name:\"session\",Value: admin.Session,Path:\"/\",MaxAge: year}\n\n\tforumsW := httptest.NewRecorder()\n\tforumsReq := httptest.NewRequest(\"get\",\"/forums/\",bytes.NewReader(nil))\n\tforumsReqAdmin := forums_req\n\tforumsReqAdmin.AddCookie(&adminUidCookie)\n\tforumsReqAdmin.AddCookie(&adminSessionCookie)\n\tforumsHandler := http.HandlerFunc(route_forums)\n\n\tforumsHandler.ServeHTTP(forumsW,forumsReqAdmin)\n\tif forumsW.Code != 200 {\n\t\tt.Fatal(forumsW.Body)\n\t}\n}\n\nfunc TestForumsGuestRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tforums_w := httptest.NewRecorder()\n\tforums_req := httptest.NewRequest(\"get\",\"/forums/\",bytes.NewReader(nil))\n\tforums_handler := http.HandlerFunc(route_forums)\n\n\tforums_handler.ServeHTTP(forums_w,forums_req)\n\tif forums_w.Code != 200 {\n\t\tt.Fatal(forums_w.Body)\n\t}\n}\n*/\n\n/*func TestForumAdminRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tadmin, err := users.Get(1)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif !admin.Is_Admin {\n\t\tpanic(\"UID1 is not an admin\")\n\t}\n\tadmin_uid_cookie := http.Cookie{Name:\"uid\",Value:\"1\",Path:\"/\",MaxAge: year}\n\tadmin_session_cookie := http.Cookie{Name:\"session\",Value: admin.Session,Path:\"/\",MaxAge: year}\n\n\tforum_w := httptest.NewRecorder()\n\tforum_req := httptest.NewRequest(\"get\",\"/forum/1\",bytes.NewReader(nil))\n\tforum_req_admin := forum_req\n\tforum_req_admin.AddCookie(&admin_uid_cookie)\n\tforum_req_admin.AddCookie(&admin_session_cookie)\n\tforum_handler := http.HandlerFunc(route_forum)\n\n\tforum_handler.ServeHTTP(forum_w,forum_req_admin)\n\tif forum_w.Code != 200 {\n\t\tt.Print(forum_w.Body)\n\t\tt.Fatal(\"HTTP Error!\")\n\t}\n}*/\n\n/*func TestForumGuestRoute(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\n\tforum_w := httptest.NewRecorder()\n\tforum_req := httptest.NewRequest(\"get\",\"/forum/2\",bytes.NewReader(nil))\n\tforum_handler := http.HandlerFunc(route_forum)\n\n\tforum_handler.ServeHTTP(forum_w,forum_req)\n\tif forum_w.Code != 200 {\n\t\tt.Print(forum_w.Body)\n\t\tt.Fatal(\"HTTP Error!\")\n\t}\n}*/\n\n/*func TestAlerts(t *testing.T) {\n\tgloinit()\n\tif !plugins_inited {\n\t\tinit_plugins()\n\t}\n\tdb = db_test\n\talert_w := httptest.NewRecorder()\n\talert_req := httptest.NewRequest(\"get\",\"/api/?action=get&module=alerts&format=json\",bytes.NewReader(nil))\n\talert_handler := http.HandlerFunc(route_api)\n\t//testdb.StubQuery()\n\ttestdb.SetQueryFunc(func(query string) (result sql.Rows, err error) {\n\t\tcols := []string{\"asid\",\"actor\",\"targetUser\",\"event\",\"elementType\",\"elementID\"}\n\t\trows := `1,1,0,like,post,5\n\t\t1,1,0,friend_invite,user,2`\n\t\treturn testdb.RowsFromCSVString(cols,rows), nil\n\t})\n\n\talert_handler.ServeHTTP(alert_w,alert_req)\n\tt.Print(alert_w.Body)\n\tif alert_w.Code != 200 {\n\t\tt.Fatal(\"HTTP Error!\")\n\t}\n\tdb = db_prod\n}*/\n\nfunc TestSplittyThing(t *testing.T) {\n\tvar extraData string\n\tpath := \"/pages/hohoho\"\n\tt.Log(\"Raw Path:\", path)\n\tif path[len(path)-1] != '/' {\n\t\textraData = path[strings.LastIndexByte(path, '/')+1:]\n\t\tpath = path[:strings.LastIndexByte(path, '/')+1]\n\t}\n\tt.Log(\"Path:\", path)\n\tt.Log(\"Extra Data:\", extraData)\n\tt.Log(\"Path Bytes:\", []byte(path))\n\tt.Log(\"Extra Data Bytes:\", []byte(extraData))\n\n\tt.Log(\"Splitty thing test\")\n\tpath = \"/topics/\"\n\textraData = \"\"\n\tt.Log(\"Raw Path:\", path)\n\tif path[len(path)-1] != '/' {\n\t\textraData = path[strings.LastIndexByte(path, '/')+1:]\n\t\tpath = path[:strings.LastIndexByte(path, '/')+1]\n\t}\n\tt.Log(\"Path:\", path)\n\tt.Log(\"Extra Data:\", extraData)\n\tt.Log(\"Path Bytes:\", []byte(path))\n\tt.Log(\"Extra Data Bytes:\", []byte(extraData))\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/Azareal/Gosora\n\nrequire (\n\tcloud.google.com/go v0.31.0 // indirect\n\tgithub.com/Azareal/gopsutil v0.0.0-20170716174751-0763ca4e911d\n\tgithub.com/StackExchange/wmi v0.0.0-20180725035823-b12b22c5341f // indirect\n\tgithub.com/andybalholm/brotli v1.0.1-0.20200619015827-c3da72aa01ed\n\tgithub.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc\n\tgithub.com/fortytw2/leaktest v1.3.0 // indirect\n\tgithub.com/fsnotify/fsnotify v1.4.9\n\tgithub.com/go-ole/go-ole v1.2.1 // indirect\n\tgithub.com/go-sql-driver/mysql v1.6.0\n\tgithub.com/gorilla/websocket v1.4.2\n\tgithub.com/lib/pq v1.0.0\n\tgithub.com/mailru/easyjson v0.7.0\n\tgithub.com/olivere/elastic v6.2.16+incompatible // indirect\n\tgithub.com/oschwald/geoip2-golang v1.2.1\n\tgithub.com/oschwald/maxminddb-golang v1.3.0 // indirect\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d\n\tgolang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79\n\tgolang.org/x/image v0.0.0-20200430140353-33d19683fad8\n\tgoogle.golang.org/appengine v1.2.0 // indirect\n\tgopkg.in/olivere/elastic.v6 v6.2.16\n\tgopkg.in/sourcemap.v1 v1.0.5 // indirect\n\tgopkg.in/src-d/go-git.v4 v4.7.1\n)\n\ngo 1.13\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.31.0 h1:o9K5MWWt2wk+d9jkGn2DAZ7Q9nUdnFLOpK9eIkDwONQ=\ncloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ngithub.com/Azareal/gopsutil v0.0.0-20170716174751-0763ca4e911d h1:biEIFfkaXGysjNAACgZ9yeCKkR3jOcARFgDewOhrwHw=\ngithub.com/Azareal/gopsutil v0.0.0-20170716174751-0763ca4e911d/go.mod h1:BxcRRJJc1AsnFl41ujb+8dv75d1fRCRYAnAXAsFypq4=\ngithub.com/StackExchange/wmi v0.0.0-20180725035823-b12b22c5341f h1:5ZfJxyXo8KyX8DgGXC5B7ILL8y51fci/qYz2B4j8iLY=\ngithub.com/StackExchange/wmi v0.0.0-20180725035823-b12b22c5341f/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=\ngithub.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=\ngithub.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=\ngithub.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4=\ngithub.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=\ngithub.com/andybalholm/brotli v1.0.1-0.20200508234816-e2c5f2109f20 h1:pa+MyizoSKiho/6GZGXpAos2Ys/DhQND/SAiumgjgoQ=\ngithub.com/andybalholm/brotli v1.0.1-0.20200508234816-e2c5f2109f20/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=\ngithub.com/andybalholm/brotli v1.0.1-0.20200510083619-a01a7b12c94e h1:cDaBGqWGgjs/qw5xkI9lZonVLwFcFZ4lio9Bxd63F7c=\ngithub.com/andybalholm/brotli v1.0.1-0.20200510083619-a01a7b12c94e/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=\ngithub.com/andybalholm/brotli v1.0.1-0.20200619015827-c3da72aa01ed h1:G/gj6aolvcaqMTCmlHRDsLLQlJ/fXTC4vE9o18KRZtw=\ngithub.com/andybalholm/brotli v1.0.1-0.20200619015827-c3da72aa01ed/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=\ngithub.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=\ngithub.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f h1:WH0w/R4Yoey+04HhFxqZ6VX6I0d7RMyw5aXQ9UTvQPs=\ngithub.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=\ngithub.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc h1:VRRKCwnzqk8QCaRC4os14xoKDdbHqqlJtJA0oc1ZAjg=\ngithub.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=\ngithub.com/emirpasic/gods v1.9.0 h1:rUF4PuzEjMChMiNsVjdI+SyLu7rEqpQ5reNFnhC7oFo=\ngithub.com/emirpasic/gods v1.9.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=\ngithub.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=\ngithub.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=\ngithub.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=\ngithub.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=\ngithub.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/gliderlabs/ssh v0.1.1 h1:j3L6gSLQalDETeEg/Jg0mGY0/y/N6zI2xX1978P0Uqw=\ngithub.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=\ngithub.com/go-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E=\ngithub.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8=\ngithub.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=\ngithub.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=\ngithub.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=\ngithub.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=\ngithub.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=\ngithub.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=\ngithub.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=\ngithub.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=\ngithub.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=\ngithub.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=\ngithub.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=\ngithub.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=\ngithub.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e h1:RgQk53JHp/Cjunrr1WlsXSZpqXn+uREuHvUVcK82CV8=\ngithub.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=\ngithub.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=\ngithub.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=\ngithub.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=\ngithub.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0=\ngithub.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/olivere/elastic v6.2.16+incompatible h1:+mQIHbkADkOgq9tFqnbyg7uNFVV6swGU07EoK1u0nEQ=\ngithub.com/olivere/elastic v6.2.16+incompatible/go.mod h1:J+q1zQJTgAz9woqsbVRqGeB5G1iqDKVBWLNSYW8yfJ8=\ngithub.com/oschwald/geoip2-golang v1.2.1 h1:3iz+jmeJc6fuCyWeKgtXSXu7+zvkxJbHFXkMT5FVebU=\ngithub.com/oschwald/geoip2-golang v1.2.1/go.mod h1:0LTTzix/Ao1uMvOhAV4iLU0Lz7eCrP94qZWBTDKf0iE=\ngithub.com/oschwald/maxminddb-golang v1.3.0 h1:oTh8IBSj10S5JNlUDg5WjJ1QdBMdeaZIkPEVfESSWgE=\ngithub.com/oschwald/maxminddb-golang v1.3.0/go.mod h1:3jhIUymTJ5VREKyIhWm66LJiQt04F0UCDdodShpjWsY=\ngithub.com/pelletier/go-buffruneio v0.2.0 h1:U4t4R6YkofJ5xHm3dJzuRpPZ0mr5MMCoAWooScCR7aA=\ngithub.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=\ngithub.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d h1:1VUlQbCfkoSGv7qP7Y+ro3ap1P1pPZxgdGVqiTVy5C4=\ngithub.com/robertkrimen/otto v0.0.0-20180617131154-15f95af6e78d/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=\ngithub.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=\ngithub.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=\ngithub.com/src-d/gcfg v1.3.0 h1:2BEDr8r0I0b8h/fOqwtxCEiq2HJu8n2JGZJQFGXWLjg=\ngithub.com/src-d/gcfg v1.3.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=\ngithub.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/xanzy/ssh-agent v0.2.0 h1:Adglfbi5p9Z0BmK2oKU9nTG+zKfniSfnaMYB+ULd+Ro=\ngithub.com/xanzy/ssh-agent v0.2.0/go.mod h1:0NyE30eGUDliuLEHJgYte/zncp2zdTStcOnWhgSqHD8=\ngolang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20181025213731-e84da0312774 h1:a4tQYYYuK9QdeO/+kEvNYyuR21S+7ve5EANok6hABhI=\ngolang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 h1:IaQbIIB2X/Mp/DKctl6ROxz1KyMlKp4uyvL6+kQ7C88=\ngolang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=\ngolang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/image v0.0.0-20200430140353-33d19683fad8 h1:6WW6V3x1P/jokJBpRQYUJnMHRP6isStQwCozxnU7XQw=\ngolang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/sys v0.0.0-20180903190138-2b024373dcd9 h1:lkiLiLBHGoH3XnqSLUIaBsilGMUjI+Uy2Xu2JLUtTas=\ngolang.org/x/sys v0.0.0-20180903190138-2b024373dcd9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9 h1:L2auWcuQIvxz9xSEqzESnV/QN/gNRXNApHi3fYwl2w0=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngoogle.golang.org/appengine v1.2.0 h1:S0iUepdCWODXRvtE+gcRDd15L+k+k1AiHlMiMjefH24=\ngoogle.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/olivere/elastic.v6 v6.2.16 h1:SvZm4VE4auXSIWpuG2630o+NA1hcIFFzzcHFQpCsv/w=\ngopkg.in/olivere/elastic.v6 v6.2.16/go.mod h1:2cTT8Z+/LcArSWpCgvZqBgt3VOqXiy7v00w12Lz8bd4=\ngopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=\ngopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=\ngopkg.in/src-d/go-billy.v4 v4.2.1 h1:omN5CrMrMcQ+4I8bJ0wEhOBPanIRWzFC953IiXKdYzo=\ngopkg.in/src-d/go-billy.v4 v4.2.1/go.mod h1:tm33zBoOwxjYHZIE+OV8bxTWFMJLrconzFMd38aARFk=\ngopkg.in/src-d/go-git-fixtures.v3 v3.1.1 h1:XWW/s5W18RaJpmo1l0IYGqXKuJITWRFuA45iOf1dKJs=\ngopkg.in/src-d/go-git-fixtures.v3 v3.1.1/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=\ngopkg.in/src-d/go-git.v4 v4.7.1 h1:phAV/kNULxfYEvyInGdPuq3U2MtPpJdgmtOUF3cghkQ=\ngopkg.in/src-d/go-git.v4 v4.7.1/go.mod h1:xrJH/YX8uSWewT6evfocf8qsivF18JgCN7/IMitOptY=\ngopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=\ngopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=\n"
  },
  {
    "path": "gosora_example.service",
    "content": "# An example systemd service file\n[Unit]\nDescription=Gosora\n\n[Service]\nUser=gosora\nGroup=www-data\n\nRestart=on-failure\nRestartSec=10\n# Set these to the location of Gosora\nWorkingDirectory=/home/gosora/src\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n# Make sure you manually run pre-run-linux before you start the service\nExecStart=/home/gosora/src/Gosora\n\nProtectSystem=full\nPrivateDevices=true\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "install/install.go",
    "content": "package install\n\nimport (\n\t\"fmt\"\n\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nvar adapters = make(map[string]InstallAdapter)\n\ntype InstallAdapter interface {\n\tName() string\n\tDefaultPort() string\n\tSetConfig(dbHost, dbUsername, dbPassword, dbName, dbPort string)\n\tInitDatabase() error\n\tTableDefs() error\n\tInitialData() error\n\tCreateAdmin() error\n\n\tDBHost() string\n\tDBUsername() string\n\tDBPassword() string\n\tDBName() string\n\tDBPort() string\n}\n\nfunc Lookup(name string) (InstallAdapter, bool) {\n\tadap, ok := adapters[name]\n\treturn adap, ok\n}\n\nfunc createAdmin() error {\n\tfmt.Println(\"Creating the admin user\")\n\thashedPassword, salt, e := BcryptGeneratePassword(\"password\")\n\tif e != nil {\n\t\treturn e\n\t}\n\n\t// Build the admin user query\n\tadminUserStmt, e := qgen.Builder.SimpleInsert(\"users\", \"name, password, salt, email, group, is_super_admin, active, createdAt, lastActiveAt, lastLiked, oldestItemLikedCreatedAt, message, last_ip\", \"'Admin',?,?,'admin@localhost',1,1,1,UTC_TIMESTAMP(),UTC_TIMESTAMP(),UTC_TIMESTAMP(),UTC_TIMESTAMP(),'',''\")\n\tif e != nil {\n\t\treturn e\n\t}\n\n\t// Run the admin user query\n\t_, e = adminUserStmt.Exec(hashedPassword, salt)\n\treturn e\n}\n"
  },
  {
    "path": "install/mssql.go",
    "content": "/*\n*\n* Gosora MSSQL Interface\n* Copyright Azareal 2017 - 2018\n*\n */\npackage install\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"net/url\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/denisenkom/go-mssqldb\"\n)\n\nfunc init() {\n\tadapters[\"mssql\"] = &MssqlInstaller{dbHost: \"\"}\n}\n\ntype MssqlInstaller struct {\n\tdb         *sql.DB\n\tdbHost     string\n\tdbUsername string\n\tdbPassword string\n\tdbName     string\n\tdbInstance string\n\tdbPort     string\n}\n\nfunc (ins *MssqlInstaller) SetConfig(dbHost string, dbUsername string, dbPassword string, dbName string, dbPort string) {\n\tins.dbHost = dbHost\n\tins.dbUsername = dbUsername\n\tins.dbPassword = dbPassword\n\tins.dbName = dbName\n\tins.dbInstance = \"\" // You can't set this from the installer right now, it allows you to connect to a named instance instead of a port\n\tins.dbPort = dbPort\n}\n\nfunc (ins *MssqlInstaller) Name() string {\n\treturn \"mssql\"\n}\n\nfunc (ins *MssqlInstaller) DefaultPort() string {\n\treturn \"1433\"\n}\n\nfunc (ins *MssqlInstaller) InitDatabase() (err error) {\n\tquery := url.Values{}\n\tquery.Add(\"database\", ins.dbName)\n\tu := &url.URL{\n\t\tScheme:   \"sqlserver\",\n\t\tUser:     url.UserPassword(ins.dbUsername, ins.dbPassword),\n\t\tHost:     ins.dbHost + \":\" + ins.dbPort,\n\t\tPath:     ins.dbInstance,\n\t\tRawQuery: query.Encode(),\n\t}\n\tlog.Print(\"u.String() \", u.String())\n\n\tdb, err := sql.Open(\"mssql\", u.String())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive..\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"Successfully connected to the database\")\n\n\t// TODO: Create the database, if it doesn't exist\n\n\t// Ready the query builder\n\tins.db = db\n\tqgen.Builder.SetConn(db)\n\treturn qgen.Builder.SetAdapter(\"mssql\")\n}\n\nfunc (ins *MssqlInstaller) TableDefs() (err error) {\n\t//fmt.Println(\"Creating the tables\")\n\tfiles, _ := ioutil.ReadDir(\"./schema/mssql/\")\n\tfor _, f := range files {\n\t\tif !strings.HasPrefix(f.Name(), \"query_\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar table, ext string\n\t\ttable = strings.TrimPrefix(f.Name(), \"query_\")\n\t\text = filepath.Ext(table)\n\t\tif ext != \".sql\" {\n\t\t\tcontinue\n\t\t}\n\t\ttable = strings.TrimSuffix(table, ext)\n\n\t\t// ? - This is mainly here for tests, although it might allow the installer to overwrite a production database, so we might want to proceed with caution\n\t\t_, err = ins.db.Exec(\"DROP TABLE IF EXISTS [\" + table + \"];\")\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Failed query:\", \"DROP TABLE IF EXISTS [\"+table+\"]\")\n\t\t\treturn err\n\t\t}\n\n\t\tfmt.Println(\"Creating table '\" + table + \"'\")\n\t\tdata, err := ioutil.ReadFile(\"./schema/mssql/\" + f.Name())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdata = bytes.TrimSpace(data)\n\n\t\t_, err = ins.db.Exec(string(data))\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Failed query:\", string(data))\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (ins *MssqlInstaller) InitialData() (err error) {\n\t//fmt.Println(\"Seeding the tables\")\n\tdata, err := ioutil.ReadFile(\"./schema/mssql/inserts.sql\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdata = bytes.TrimSpace(data)\n\n\tstatements := bytes.Split(data, []byte(\";\"))\n\tfor key, statement := range statements {\n\t\tif len(statement) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tfmt.Println(\"Executing query #\" + strconv.Itoa(key) + \" \" + string(statement))\n\t\t_, err = ins.db.Exec(string(statement))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (ins *MssqlInstaller) CreateAdmin() error {\n\treturn createAdmin()\n}\n\nfunc (ins *MssqlInstaller) DBHost() string {\n\treturn ins.dbHost\n}\n\nfunc (ins *MssqlInstaller) DBUsername() string {\n\treturn ins.dbUsername\n}\n\nfunc (ins *MssqlInstaller) DBPassword() string {\n\treturn ins.dbPassword\n}\n\nfunc (ins *MssqlInstaller) DBName() string {\n\treturn ins.dbName\n}\n\nfunc (ins *MssqlInstaller) DBPort() string {\n\treturn ins.dbPort\n}\n"
  },
  {
    "path": "install/mysql.go",
    "content": "/*\n*\n* Gosora MySQL Interface\n* Copyright Azareal 2017 - 2020\n*\n */\npackage install\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\n//var dbCollation string = \"utf8mb4_general_ci\"\n\nfunc init() {\n\tadapters[\"mysql\"] = &MysqlInstaller{dbHost: \"\"}\n}\n\ntype MysqlInstaller struct {\n\tdb         *sql.DB\n\tdbHost     string\n\tdbUsername string\n\tdbPassword string\n\tdbName     string\n\tdbPort     string\n}\n\nfunc (ins *MysqlInstaller) SetConfig(dbHost string, dbUsername string, dbPassword string, dbName string, dbPort string) {\n\tins.dbHost = dbHost\n\tins.dbUsername = dbUsername\n\tins.dbPassword = dbPassword\n\tins.dbName = dbName\n\tins.dbPort = dbPort\n}\n\nfunc (ins *MysqlInstaller) Name() string {\n\treturn \"mysql\"\n}\n\nfunc (ins *MysqlInstaller) DefaultPort() string {\n\treturn \"3306\"\n}\n\nfunc (ins *MysqlInstaller) dbExists(dbName string) (bool, error) {\n\tvar waste string\n\terr := ins.db.QueryRow(\"SHOW DATABASES LIKE '\" + dbName + \"'\").Scan(&waste)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn false, err\n\t} else if err == sql.ErrNoRows {\n\t\treturn false, nil\n\t}\n\treturn true, nil\n}\n\nfunc (ins *MysqlInstaller) InitDatabase() (err error) {\n\t_dbPassword := ins.dbPassword\n\tif _dbPassword != \"\" {\n\t\t_dbPassword = \":\" + _dbPassword\n\t}\n\tdb, err := sql.Open(\"mysql\", ins.dbUsername+_dbPassword+\"@tcp(\"+ins.dbHost+\":\"+ins.dbPort+\")/\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive..\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"Successfully connected to the database\")\n\n\tins.db = db\n\tok, err := ins.dbExists(ins.dbName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !ok {\n\t\tfmt.Println(\"Unable to find the database. Attempting to create it\")\n\t\t_, err = db.Exec(\"CREATE DATABASE IF NOT EXISTS \" + ins.dbName)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfmt.Println(\"The database was successfully created\")\n\t}\n\n\t/*fmt.Println(\"Switching to database \", ins.dbName)\n\t_, err = db.Exec(\"USE \" + ins.dbName)\n\tif err != nil {\n\t\treturn err\n\t}*/\n\tdb.Close()\n\n\tdb, err = sql.Open(\"mysql\", ins.dbUsername+_dbPassword+\"@tcp(\"+ins.dbHost+\":\"+ins.dbPort+\")/\"+ins.dbName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive..\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"Successfully connected to the database\")\n\n\t// Ready the query builder\n\tins.db = db\n\tqgen.Builder.SetConn(db)\n\treturn qgen.Builder.SetAdapter(\"mysql\")\n}\n\nfunc (ins *MysqlInstaller) createTable(f os.FileInfo) error {\n\ttable := strings.TrimPrefix(f.Name(), \"query_\")\n\text := filepath.Ext(table)\n\tif ext != \".sql\" {\n\t\treturn nil\n\t}\n\ttable = strings.TrimSuffix(table, ext)\n\n\t// ? - This is mainly here for tests, although it might allow the installer to overwrite a production database, so we might want to proceed with caution\n\tq := \"DROP TABLE IF EXISTS `\" + table + \"`;\"\n\t_, err := ins.db.Exec(q)\n\tif err != nil {\n\t\tfmt.Println(\"Failed query:\", q)\n\t\tfmt.Println(\"e:\", err)\n\t\treturn err\n\t}\n\n\tdata, err := ioutil.ReadFile(\"./schema/mysql/\" + f.Name())\n\tif err != nil {\n\t\treturn err\n\t}\n\tdata = bytes.TrimSpace(data)\n\n\tq = string(data)\n\t_, err = ins.db.Exec(q)\n\tif err != nil {\n\t\tfmt.Println(\"Failed query:\", q)\n\t\tfmt.Println(\"e:\", err)\n\t\treturn err\n\t}\n\tfmt.Printf(\"Created table '%s'\\n\", table)\n\n\treturn nil\n}\n\nfunc (ins *MysqlInstaller) TableDefs() (err error) {\n\tfmt.Println(\"Creating the tables\")\n\tfiles, err := ioutil.ReadDir(\"./schema/mysql/\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = ins.db.Exec(\"SET FOREIGN_KEY_CHECKS = 0;\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, f := range files {\n\t\tif !strings.HasPrefix(f.Name(), \"query_\") {\n\t\t\tcontinue\n\t\t}\n\t\terr := ins.createTable(f)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t_, err = ins.db.Exec(\"SET FOREIGN_KEY_CHECKS = 1;\")\n\treturn err\n}\n\n// ? - Moved this here since it was breaking the installer, we need to add this at some point\n/* TODO: Implement the html-attribute setting type before deploying this */\n/*INSERT INTO settings(`name`,`content`,`type`) VALUES ('meta_desc','','html-attribute');*/\n\nfunc (ins *MysqlInstaller) InitialData() error {\n\tfmt.Println(\"Seeding the tables\")\n\tdata, err := ioutil.ReadFile(\"./schema/mysql/inserts.sql\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdata = bytes.TrimSpace(data)\n\n\tstatements := bytes.Split(data, []byte(\";\"))\n\tfor key, sBytes := range statements {\n\t\tstatement := string(sBytes)\n\t\tif statement == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tstatement += \";\"\n\n\t\tfmt.Println(\"Executing query #\" + strconv.Itoa(key) + \" \" + statement)\n\t\t_, err = ins.db.Exec(statement)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (ins *MysqlInstaller) CreateAdmin() error {\n\treturn createAdmin()\n}\n\nfunc (ins *MysqlInstaller) DBHost() string {\n\treturn ins.dbHost\n}\n\nfunc (ins *MysqlInstaller) DBUsername() string {\n\treturn ins.dbUsername\n}\n\nfunc (ins *MysqlInstaller) DBPassword() string {\n\treturn ins.dbPassword\n}\n\nfunc (ins *MysqlInstaller) DBName() string {\n\treturn ins.dbName\n}\n\nfunc (ins *MysqlInstaller) DBPort() string {\n\treturn ins.dbPort\n}\n"
  },
  {
    "path": "install/pgsql.go",
    "content": "/*\n*\n* Gosora PostgreSQL Interface\n* Under heavy development\n* Copyright Azareal 2017 - 2019\n*\n */\npackage install\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\n// We don't need SSL to run an installer... Do we?\nvar dbSslmode = \"disable\"\n\nfunc init() {\n\tadapters[\"pgsql\"] = &PgsqlInstaller{dbHost: \"\"}\n}\n\ntype PgsqlInstaller struct {\n\tdb         *sql.DB\n\tdbHost     string\n\tdbUsername string\n\tdbPassword string\n\tdbName     string\n\tdbPort     string\n}\n\nfunc (ins *PgsqlInstaller) SetConfig(dbHost string, dbUsername string, dbPassword string, dbName string, dbPort string) {\n\tins.dbHost = dbHost\n\tins.dbUsername = dbUsername\n\tins.dbPassword = dbPassword\n\tins.dbName = dbName\n\tins.dbPort = dbPort\n}\n\nfunc (ins *PgsqlInstaller) Name() string {\n\treturn \"pgsql\"\n}\n\nfunc (ins *PgsqlInstaller) DefaultPort() string {\n\treturn \"5432\"\n}\n\nfunc (ins *PgsqlInstaller) InitDatabase() (err error) {\n\t_dbPassword := ins.dbPassword\n\tif _dbPassword != \"\" {\n\t\t_dbPassword = \" password=\" + pgEscapeBit(_dbPassword)\n\t}\n\tdb, err := sql.Open(\"postgres\", \"host='\"+pgEscapeBit(ins.dbHost)+\"' port='\"+pgEscapeBit(ins.dbPort)+\"' user='\"+pgEscapeBit(ins.dbUsername)+\"' dbname='\"+pgEscapeBit(ins.dbName)+\"'\"+_dbPassword+\" sslmode='\"+dbSslmode+\"'\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive..\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Println(\"Successfully connected to the database\")\n\n\t// TODO: Create the database, if it doesn't exist\n\n\t// Ready the query builder\n\tins.db = db\n\tqgen.Builder.SetConn(db)\n\treturn qgen.Builder.SetAdapter(\"pgsql\")\n}\n\nfunc (ins *PgsqlInstaller) TableDefs() (err error) {\n\treturn errors.New(\"TableDefs() not implemented\")\n}\n\nfunc (ins *PgsqlInstaller) InitialData() (err error) {\n\treturn errors.New(\"InitialData() not implemented\")\n}\n\nfunc (ins *PgsqlInstaller) CreateAdmin() error {\n\treturn createAdmin()\n}\n\nfunc (ins *PgsqlInstaller) DBHost() string {\n\treturn ins.dbHost\n}\n\nfunc (ins *PgsqlInstaller) DBUsername() string {\n\treturn ins.dbUsername\n}\n\nfunc (ins *PgsqlInstaller) DBPassword() string {\n\treturn ins.dbPassword\n}\n\nfunc (ins *PgsqlInstaller) DBName() string {\n\treturn ins.dbName\n}\n\nfunc (ins *PgsqlInstaller) DBPort() string {\n\treturn ins.dbPort\n}\n\nfunc pgEscapeBit(bit string) string {\n\t// TODO: Write a custom parser, so that backslashes work properly in the sql.Open string. Do something similar for the database driver, if possible?\n\treturn strings.Replace(bit, \"'\", \"\\\\'\", -1)\n}\n"
  },
  {
    "path": "install/utils.go",
    "content": "package install\n\nimport \"encoding/base64\"\nimport \"crypto/rand\"\nimport \"golang.org/x/crypto/bcrypt\"\n\nconst saltLength int = 32\n\n// Generate a cryptographically secure set of random bytes..\nfunc GenerateSafeString(length int) (string, error) {\n\trb := make([]byte, length)\n\t_, err := rand.Read(rb)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.StdEncoding.EncodeToString(rb), nil\n}\n\n// Generate a bcrypt hash\n// Note: The salt is in the hash, therefore the salt value is blank\nfunc BcryptGeneratePassword(password string) (hash string, salt string, err error) {\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn string(hashedPassword), salt, nil\n}\n"
  },
  {
    "path": "install-docker",
    "content": "go get -u github.com/mailru/easyjson/...\neasyjson -pkg common\ngo get\n\ngo build -ldflags=\"-s -w\" -o Installer \"./cmd/install\"\n\n./Installer --dbType=mysql --dbHost=localhost --dbUser=$MYSQL_USER --dbPassword=$MYSQL_PASSWORD --dbName=$MYSQL_DATABASE --shortSiteName=$SITE_SHORT_NAME --siteName=$SITE_NAME --siteURL=$SITE_URL --serverPort=$SERVER_PORT--secureServerPort=$SECURE_SERVER_PORT"
  },
  {
    "path": "install-linux",
    "content": "echo \"Installing the dependencies\"\n./update-deps-linux\n\necho \"Building the installer\"\ngo build -ldflags=\"-s -w\" -o Installer \"./cmd/install\"\n\necho \"Running the installer\"\n./Installer\n"
  },
  {
    "path": "install.bat",
    "content": "@echo off\n\necho Installing the dependencies\ngo get -u github.com/mailru/easyjson/...\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\neasyjson -pkg common\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\ngo get\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the installer\ngo generate\ngo build -ldflags=\"-s -w\" \"./cmd/install\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\ninstall.exe\n"
  },
  {
    "path": "langs/english.json",
    "content": "{\n\t\"Name\": \"english\",\n\t\"IsoCode\":\"en\",\n\t\n\t\"Levels\": {\n\t\t\"Level\": \"<span class='level_hideable'>Level </span>{0}\",\n\t\t\"LevelMax\": \"\"\n\t},\n\n\t\"Perms\": {\n\t\t\"BanUsers\":              \"Can ban users\",\n\t\t\"ActivateUsers\":         \"Can activate users\",\n\t\t\"EditUser\":              \"Can edit users\",\n\t\t\"EditUserEmail\":         \"Can change a user's email\",\n\t\t\"EditUserPassword\":      \"Can change a user's password\",\n\t\t\"EditUserGroup\":         \"Can change a user's group\",\n\t\t\"EditUserGroupSuperMod\": \"Can edit super-mods\",\n\t\t\"EditUserGroupAdmin\":    \"Can edit admins\",\n\t\t\"EditGroup\":             \"Can edit groups\",\n\t\t\"EditGroupLocalPerms\":   \"Can edit a group's minor perms\",\n\t\t\"EditGroupGlobalPerms\":  \"Can edit a group's global perms\",\n\t\t\"EditGroupSuperMod\":     \"Can edit super-mod groups\",\n\t\t\"EditGroupAdmin\":        \"Can edit admin groups\",\n\t\t\"ManageForums\":          \"Can manage forums\",\n\t\t\"EditSettings\":          \"Can edit settings\",\n\t\t\"ManageThemes\":          \"Can manage themes\",\n\t\t\"ManagePlugins\":         \"Can manage plugins\",\n\t\t\"ViewAdminLogs\":         \"Can view the administrator action logs\",\n\t\t\"ViewIPs\":               \"Can view IP addresses\",\n\n\t\t\"UploadFiles\": \"Can upload files\",\n\t\t\"UploadAvatars\": \"Can upload avatars\",\n\t\t\"UseConvos\":\"Can use conversations\",\n\t\t\"UseConvosOnlyWithMod\":\"Can use conversations only to contact global mods\",\n\t\t\"CreateProfileReply\": \"Can create profile replies\",\n\t\t\"AutoEmbed\":\"Automatically embed media they post\",\n\t\t\"AutoLink\":\"Linkify their links\",\n\t\t\n\t\t\"ViewTopic\":   \"Can view topics\",\n\t\t\"LikeItem\":    \"Can like items\",\n\t\t\"CreateTopic\": \"Can create topics\",\n\t\t\"EditTopic\":   \"Can edit topics\",\n\t\t\"DeleteTopic\": \"Can delete topics\",\n\t\t\"CreateReply\": \"Can create replies\",\n\t\t\"EditReply\":   \"Can edit replies\",\n\t\t\"DeleteReply\": \"Can delete replies\",\n\t\t\"PinTopic\":    \"Can pin topics\",\n\t\t\"CloseTopic\":  \"Can lock topics\",\n\t\t\"MoveTopic\": \"Can move topics in or out\"\n\t},\n\n\t\"SettingPhrases\": {\n\t\t\"activation_type\":\"Activation Type\",\n\t\t\"activation_type_label\": \"Activate All,Email Activation,Staff Approval\",\n\t\t\"bigpost_min_words\":\"Big Post Minimum Words\",\n\t\t\"megapost_min_words\":\"Mega Post Minimum Words\",\n\t\t\"meta_desc\":\"Meta Description\",\n\t\t\"rapid_loading\":\"Rapid Loaded?\",\n\t\t\"google_site_verify\":\"Google Site Verify\",\n\t\t\"avatar_visibility\":\"Avatar Visibility\",\n\t\t\"avatar_visibility_label\":\"Everyone,Member Only\"\n\t},\n\n\t\"PermPresets\": {\n\t\t\"all\":\"Public\",\n\t\t\"announce\":\"Announcements\",\n\t\t\"members\":\"Member Only\",\n\t\t\"staff\":\"Staff Only\",\n\t\t\"admins\":\"Admin Only\",\n\t\t\"archive\":\"Archive\",\n\t\t\"custom\":\"Custom\",\n\t\t\"unknown\":\"Unknown\"\n\t},\n\n\t\"Accounts\": {\n\t\t\"ActivateEmailSubject\": \"Validate Your Email - {{name}}\",\n\t\t\"ActivateEmailBody\": \"Dear {{username}}, following your registration on our forums, we ask you to validate your email, so that we can confirm that this email actually belongs to you.\\n\\nClick on the following link to do so. {{schema}}://{{url}}/user/edit/token/{{token}}\\n\\nIf you haven't created an account here, then please feel free to ignore this email.\\nWe're sorry for the inconvenience this may have caused.\",\n\n\t\t\"ValidateEmailSubject\":\"Validate Your Email - {{name}}\",\n\t\t\"ValidateEmailBody\":\"Dear {{username}}, to receive emails from our site on this address, we need you to confirm that this email address actually belongs to you.\\n\\nPlease click on the following link to do so: {{schema}}://{{url}}/user/edit/token/{{token}}\\n\\nIf you're not a user of our site, then please ignore this email.\"\n\t},\n\n\t\"Errors\": {\n\t\t\"error_title\":\"Error\",\n\t\t\"local_error_title\":\"Local Error\",\n\t\t\n\t\t\"not_found_title\":\"Page Not Found\",\n\t\t\"not_found_body\":\"The requested page doesn't exist.\",\n\t\t\"login_required_body\":\"You need to login to do that.\",\n\t\t\"no_permissions_title\":\"Access Denied\",\n\t\t\"no_permissions_body\":\"You don't have permission to do that.\",\n\t\t\"internal_error_title\":\"Internal Server Error\",\n\t\t\"internal_error_body\":\"A problem has occurred in the system.\",\n\t\t\"security_error_title\":\"Security Error\",\n\t\t\"security_error_body\":\"There was a security issue with your request.\",\n\t\t\"banned_title\":\"Banned\",\n\t\t\"banned_body\":\"You have been banned from this site.\",\n\n\t\t\"id_must_be_integer\": \"The ID must be an integer.\",\n\t\t\"url_id_must_be_integer\": \"The ID in the URL needs to be a valid integer.\",\n\n\t\t\"register_might_be_machine\":\"Our algorithms have detected that you may be a machine. If not, please try to avoid acting so quickly.\",\n\t\t\"register_need_username\":\"You didn't put in a username.\",\n\t\t\"register_need_email\":\"You didn't put in an email.\",\n\t\t\"register_first_word_numeric\":\"The first word of your name must not be purely numeric\",\n\t\t\"register_url_username\":\"You cannot have a URL within your username\",\n\t\t\"register_suspicious_email\":\"Your email address is suspicious.\",\n\t\t\"register_password_mismatch\":\"The two passwords don't match.\",\n\t\t\"register_username_unavailable\":\"This username isn't available. Try another.\",\n\t\t\"register_username_too_long_prefix\":\"The username is too long, max: \",\n\t\t\"register_email_fail\":\"We were unable to send the email for you to confirm that this email address belongs to you. You may not have access to some functionality until you do so. Please ask an administrator for assistance.\",\n\n\t\t\"password_reset_email_fail\":\"We were unable to send a password reset email to this user.\",\n\n\t\t\"alerts_no_actor\":\"Unable to find the actor\",\n\t\t\"alerts_no_target_user\":\"Unable to find the target user\",\n\t\t\"alerts_no_linked_topic\":\"Unable to find linked topic\",\n\t\t\"alerts_no_linked_topic_by_reply\":\"Unable to find linked reply or parent topic\",\n\t\t\"alerts_no_linked_convo\":\"Unable to find linked convo\",\n\t\t\"alerts_invalid_elementtype\":\"Invalid elementType\",\n\n\t\t\"panel_groups_need_name\":\"The group name can't be left blank.\",\n\t\t\"panel_groups_cannot_edit_admin\":\"You need the EditGroupAdmin permission to edit an admin group.\",\n\t\t\"panel_groups_cannot_edit_supermod\":\"You need the EditGroupSuperMod permission to edit a super-mod group.\",\n\t\t\"panel_groups_cannot_edit_group_type\":\"You need the EditGroupGlobalPerms permission to change the group type.\",\n\t\t\"panel_groups_edit_cannot_designate_admin\":\"You need the EditGroupAdmin permission to designate this group as an admin group.\",\n\t\t\"panel_groups_edit_cannot_designate_supermod\":\"You need the EditGroupSuperMod permission to designate this group as a super-mod group.\",\n\t\t\"panel_groups_create_cannot_designate_admin\":\"You need the EditGroupAdmin permission to create admin groups\",\n\t\t\"panel_groups_create_cannot_designate_supermod\":\"You need the EditGroupSuperMod permission to create super-mod groups\",\n\t\t\"panel_groups_cannot_be_guest\":\"You can't designate a group as a guest group.\",\n\t\t\"panel_groups_invalid_group_type\":\"Invalid group type.\"\n\t},\n\n\t\"PageTitles\": {\n\t\t\"overview\":\"Overview\",\n\t\t\"page\":\"Page\",\n\t\t\"topics\":\"All Topics\",\n\t\t\"topics_search\":\"Search Results\",\n\t\t\"forums\":\"Forum List\",\n\t\t\"create_topic\":\"Create Topic\",\n\t\t\"login\":\"Login\",\n\t\t\"login_mfa_verify\":\"2FA Verify\",\n\t\t\"register\":\"Registration\",\n\t\t\"password_reset\":\"Password Reset\",\n\t\t\"password_reset_token\":\"Password Reset\",\n\t\t\"ip_search\":\"IP Search\",\n\t\t\"profile\": \"%s's Profile\",\n\t\t\"account\":\"My Account\",\n\t\t\"account_password\":\"Edit Password\",\n\t\t\"account_privacy\":\"Privacy\",\n\t\t\"account_mfa\":\"Manage 2FA\",\n\t\t\"account_mfa_setup\":\"Setup 2FA\",\n\t\t\"account_email\":\"Email Manager\",\n\t\t\"account_logins\":\"Logins\",\n\t\t\"account_blocked\":\"Blocks\",\n\t\t\"account_penalties\":\"Penalties\",\n\t\t\"account_level_list\":\"Level Progress\",\n\t\t\"convos\":\"Conversations\",\n\t\t\"convo\":\"Conversation\",\n\t\t\"create_block\":\"Block User\",\n\t\t\"remove_block\":\"Unblock User\",\n\n\t\t\"panel_dashboard\":\"Control Panel Dashboard\",\n\t\t\"panel_forums\":\"Forum Manager\",\n\t\t\"panel_delete_forum\":\"Delete Forum\",\n\t\t\"panel_edit_forum\":\"Forum Editor\",\n\t\t\"panel_analytics\":\"Analytics\",\n\t\t\"panel_settings\":\"Setting Manager\",\n\t\t\"panel_edit_setting\":\"Edit Setting\",\n\t\t\"panel_word_filters\":\"Word Filter Manager\",\n\t\t\"panel_edit_word_filter\":\"Edit Word Filter\",\n\t\t\"panel_pages\":\"Page Manager\",\n\t\t\"panel_pages_edit\":\"Page Editor\",\n\t\t\"panel_plugins\":\"Plugin Manager\",\n\t\t\"panel_users\":\"User Manager\",\n\t\t\"panel_edit_user\":\"User Editor\",\n\t\t\"panel_groups\":\"Group Manager\",\n\t\t\"panel_edit_group\":\"Group Editor\",\n\t\t\"panel_themes\":\"Theme Manager\",\n\t\t\"panel_themes_menus\":\"Menu Manager\",\n\t\t\"panel_themes_menus_edit\":\"Menu Editor\",\n\t\t\"panel_themes_widgets\":\"Widget Manager\",\n\t\t\"panel_backups\":\"Backups\",\n\t\t\"panel_registration_logs\":\"Registration Logs\",\n\t\t\"panel_mod_logs\":\"Mod Action Logs\",\n\t\t\"panel_admin_logs\":\"Admin Action Logs\",\n\t\t\"panel_debug\":\"Debug\"\n\t},\n\n\t\"UserAgents\": {\n\t\t\"chrome\": \"Google Chrome\",\n\t\t\"firefox\":\"Mozilla Firefox\",\n\t\t\"opera\":\"Opera\",\n\t\t\"safari\":\"Safari\",\n\t\t\"edge\": \"Edge\",\n\t\t\"internetexplorer\":\"MS Internet Explorer\",\n\t\t\"trident\":\"Trident Engine\",\n\t\t\"androidchrome\":\"Chrome for Android\",\n\t\t\"mobilesafari\":\"Mobile Safari\",\n\t\t\"samsung\":\"Samsung Browser\",\n\t\t\"ucbrowser\":\"UCBrowser\",\n\n\t\t\"googlebot\":\"Googlebot\",\n\t\t\"yandex\":\"Yandex\",\n\t\t\"bing\":\"Bing\",\n\t\t\"slurp\":\"Yahoo! Slurp\",\n\t\t\"exabot\":\"Exabot\",\n\t\t\"mojeek\":\"MojeekBot\",\n\t\t\"cliqz\":\"Cliqzbot\",\n\t\t\"qwant\":\"Qwant\",\n\t\t\"datenbank\":\"Website Datenbank\",\n\t\t\"sogou\":\"Sogou\",\n\t\t\"toutiao\":\"Toutiao\",\n\t\t\"haosou\":\"Qihoo 360 Search\",\n\t\t\"baidu\":\"Baidu\",\n\t\t\"duckduckgo\":\"DuckDuckBot\",\n\t\t\"seznambot\":\"SeznamBot\",\n\t\t\"discord\":\"Discord\",\n\t\t\"telegram\":\"TelegramBot\",\n\t\t\"twitter\":\"Twitterbot\",\n\t\t\"cloudflare\":\"Cloudflare Alwayson\",\n\t\t\"archive_org\":\"Archive.org\",\n\t\t\"uptimebot\":\"Uptimebot\",\n\t\t\"slackbot\":\"Slack\",\n\t\t\"facebook\":\"Facebook\",\n\t\t\"apple\":\"AppleBot\",\n\t\t\"discourse\":\"Discourse Forum Onebox\",\n\t\t\"xenforo\":\"XenForo\",\n\t\t\"mattermost\":\"Mattermost\",\n\t\t\"alexa\":\"Alexa\",\n\t\t\"lynx\":\"Lynx\",\n\t\t\n\t\t\"semrush\":\"Semrush\",\n\t\t\"dotbot\":\"DotBot\",\n\t\t\"ahrefs\":\"Ahrefs\",\n\t\t\"proximic\":\"Comscore\",\n\t\t\"megaindex\":\"MegaIndex\",\n\t\t\"majestic\":\"MJ12bot\",\n\t\t\"cocolyze\":\"Cocolyze\",\n\t\t\"babbar\":\"Babbar\",\n\t\t\"surdotly\":\"Surdotly\",\n\t\t\"domcop\":\"DomCopBot\",\n\t\t\"netcraft\":\"Netcraft\",\n\t\t\"seostar\":\"seostar.co\",\n\t\t\"pandalytics\":\"Pandalytics\",\n\t\t\"blexbot\":\"BLEXBot\",\n\t\t\"wappalyzer\":\"Wappalyzer\",\n\t\t\"twingly\":\"Twingly\",\n\t\t\"linkfluence\":\"Linkfluence\",\n\t\t\"pagething\":\"PageThing\",\n\t\t\"burf\":\"Burf.co\",\n\t\t\"aspiegel\":\"Aspiegel\",\n\t\t\"mail_ru\":\"Mail.ru bot\",\n\t\t\"ccbot\":\"CCBot\",\n\t\t\"yacy\":\"YaCy P2P Search Engine\",\n\t\t\"zgrab\":\"Zgrab App Scanner\",\n\t\t\"cloudsystemnetworks\":\"Nimbostratus / Cloud System Networks\",\n\t\t\"maui\":\"MauiBot\",\n\t\t\"curl\":\"curl\",\n\t\t\"python\":\"Python Bot\",\n\t\t\"go\":\"Go Bot\",\n\t\t\"headlesschrome\":\"Headless Chrome\",\n\t\t\"awesome_bot\":\"Awesome Bot\",\n\t\t\"suspicious\":\"Suspicious\",\n\t\t\"unknown\":\"Unknown\",\n\t\t\"blank\":\"Blank\",\n\t\t\"malformed\":\"Malformed\"\n\t},\n\n\t\"OperatingSystems\": {\n\t\t\"windows\": \"Microsoft Windows\",\n\t\t\"linux\":\"Linux\",\n\t\t\"mac\":\"Apple Mac\",\n\t\t\"android\": \"Android\",\n\t\t\"iphone\":\"iPhone\",\n\t\t\"unknown\":\"Unknown\"\n\t},\n\n\t\"HumanLanguages\": {\n\t\t\"unknown\":\"Unknown\",\n\t\t\"none\":\"None\",\n\t\t\"af\":\"Afrikaans\",\n\t\t\"ar\":\"Arabic\",\n\t\t\"az\":\"Azeri (Latin)\",\n\t\t\"be\":\"Belarusian\",\n\t\t\"bg\":\"Bulgarian\",\n\t\t\"bs\":\"Bosnian (Bosnia and Herzegovina)\",\n\t\t\"ca\":\"Catalan\",\n\t\t\"cs\":\"Czech\",\n\t\t\"cy\":\"Welsh\",\n\t\t\"da\":\"Danish\",\n\t\t\"de\":\"German\",\n\t\t\"dv\":\"Divehi\",\n\t\t\"el\":\"Greek\",\n\t\t\"en\":\"English\",\n\t\t\"eo\":\"Esperanto\",\n\t\t\"es\":\"Spanish\",\n\t\t\"et\":\"Estonian\",\n\t\t\"eu\":\"Basque\",\n\t\t\"fa\":\"Farsi\",\n\t\t\"fi\":\"Finnish\",\n\t\t\"fo\":\"Faroese\",\n\t\t\"fr\":\"French\",\n\t\t\"gl\":\"Galician\",\n\t\t\"gu\":\"Gujarati\",\n\t\t\"he\":\"Hebrew\",\n\t\t\"hi\":\"Hindi\",\n\t\t\"hr\":\"Croatian\",\n\t\t\"hu\":\"Hungarian\",\n\t\t\"hy\":\"Armenian\",\n\t\t\"id\":\"Indonesian\",\n\t\t\"is\":\"Icelandic\",\n\t\t\"it\":\"Italian\",\n\t\t\"ja\":\"Japanese\",\n\t\t\"ka\":\"Georgian\",\n\t\t\"kk\":\"Kazakh\",\n\t\t\"kn\":\"Kannada\",\n\t\t\"ko\":\"Korean\",\n\t\t\"kok\":\"Konkani\",\n\t\t\"kw\":\"Kuwait\",\n\t\t\"ky\":\"Kyrgyz\",\n\t\t\"lt\":\"Lithuanian\",\n\t\t\"lv\":\"Latvian\",\n\t\t\"mi\":\"Maori\",\n\t\t\"mk\":\"FYRO Macedonian\",\n\t\t\"mn\":\"Mongolian\",\n\t\t\"mr\":\"Marathi\",\n\t\t\"ms\":\"Malay\",\n\t\t\"mt\":\"Maltese\",\n\t\t\"nb\":\"Norwegian (Bokm?l)\",\n\t\t\"nl\":\"Dutch\",\n\t\t\"nn\":\"Norwegian (Nynorsk) (Norway)\",\n\t\t\"ns\":\"Northern Sotho\",\n\t\t\"pa\":\"Punjabi\",\n\t\t\"pl\":\"Polish\",\n\t\t\"ps\":\"Pashto\",\n\t\t\"pt\":\"Portuguese\",\n\t\t\"qu\":\"Quechua\",\n\t\t\"ro\":\"Romanian\",\n\t\t\"ru\":\"Russian\",\n\t\t\"sa\":\"Sanskrit\",\n\t\t\"se\":\"Sami (Northern)\",\n\t\t\"sk\":\"Slovak\",\n\t\t\"sl\":\"Slovenian\",\n\t\t\"sq\":\"Albanian\",\n\t\t\"sr\":\"Serbian (Latin)\",\n\t\t\"sv\":\"Swedish\",\n\t\t\"sw\":\"Swahili\",\n\t\t\"syr\":\"Syriac\",\n\t\t\"ta\":\"Tamil\",\n\t\t\"te\":\"Telugu\",\n\t\t\"th\":\"Thai\",\n\t\t\"tl\":\"Tagalog\",\n\t\t\"tn\":\"Tswana\",\n\t\t\"tr\":\"Turkish\",\n\t\t\"tt\":\"Tatar\",\n\t\t\"ts\":\"Tsonga\",\n\t\t\"uk\":\"Ukrainian\",\n\t\t\"ur\":\"Urdu\",\n\t\t\"uz\":\"Uzbek (Latin)\",\n\t\t\"vi\":\"Vietnamese\",\n\t\t\"xh\":\"Xhosa\",\n\t\t\"zh\":\"Chinese\",\n\t\t\"zu\":\"Zulu\"\n\t},\n\n\t\"NoticePhrases\": {\n\t\t\"account_banned\":\"Your account has been suspended. Some of your permissions may have been revoked.\",\n\t\t\"account_inactive\":\"Your account hasn't been activated yet. Some features may remain unavailable until it is.\",\n\t\t\"account_avatar_updated\":\"Your avatar was successfully updated.\",\n\t\t\"account_name_updated\":\"Your name was successfully updated.\",\n\t\t\"account_mail_disabled\":\"The mail system is currently disabled.\",\n\t\t\"account_mail_verify_success\":\"Your email was successfully verified.\",\n\t\t\"account_mfa_setup_success\":\"Two-factor authentication was successfully setup for your account.\",\n\t\t\"password_reset_email_sent\":\"An email was sent to you. Please follow the steps within.\",\n\t\t\"password_reset_token_token_verified\":\"Your password was successfully updated.\",\n\n\t\t\"convo_dev\":\"Conversations are currently under development. Some features may not work yet and your messages may be purged every now and then.\",\n\t\t\"convo_rand_0\":\"Remember that the person on the other end has thoughts and emotions just like you.\",\n\t\t\"convo_rand_1\":\"Try to look at things from the other person's perspective.\",\n\t\t\"convo_rand_2\":\"Treat others how you'd like to be treated.\",\n\t\t\"convo_rand_3\":\"Having a differing opinion isn't an attack on you.\",\n\n\t\t\"panel_forum_created\":\"The forum was successfully created.\",\n\t\t\"panel_forum_deleted\":\"The forum was successfully deleted.\",\n\t\t\"panel_forum_updated\":\"The forum was successfully updated.\",\n\t\t\"panel_forum_perms_updated\":\"The forum permissions were successfully updated.\",\n\t\t\"panel_user_updated\":\"The user was successfully updated.\",\n\t\t\"panel_page_created\":\"The page was successfully created.\",\n\t\t\"panel_page_updated\":\"The page was successfully updated.\",\n\t\t\"panel_page_deleted\":\"The page was successfully deleted.\"\n\t},\n\n\t\"TmplPhrases\": {\n\t\t\"pipe\":\"|\",\n\t\t\"unit\":\"%.1f%s\",\n\n\t\t\"menu_forums\":\"Forums\",\n\t\t\"menu_topics\":\"Topics\",\n\t\t\"menu_alerts\":\"Alerts\",\n\t\t\"menu_account\":\"Account\",\n\t\t\"menu_profile\":\"Profile\",\n\t\t\"menu_panel\":\"Panel\",\n\t\t\"menu_logout\":\"Logout\",\n\t\t\"menu_login\":\"Login\",\n\t\t\"menu_register\":\"Register\",\n\t\t\"menu_more\":\"More\",\n\n\t\t\"alerts.forum_new_topic\":\"{0} created the topic {1}\",\n\t\t\"alerts.forum_unknown_action\":\"{0} did something in a forum\",\n\n\t\t\"alerts.topic_own_reply\":\"{0} replied to your topic {1}\",\n\t\t\"alerts.topic_reply\":\"{0} replied to {1}\",\n\t\t\"alerts.topic_own_like\":\"{0} liked your topic {1}\",\n\t\t\"alerts.topic_like\":\"{0} liked {1}\",\n\t\t\"alerts.topic_own_mention\":\"{0} mentioned you in {1}\",\n\t\t\"alerts.topic_mention\":\"{0} mentioned you in {1}\",\n\n\t\t\"alerts.post_own_reply\":\"{0} replied to your post in {1}\",\n\t\t\"alerts.post_reply\":\"{0} replied to {1}\",\n\t\t\"alerts.post_own_like\":\"{0} liked your post in {1}\",\n\t\t\"alerts.post_like\":\"{0} liked a post in {1}\",\n\t\t\"alerts.post_own_mention\":\"{0} mentioned you in {1}\",\n\t\t\"alerts.post_mention\":\"{0} mentioned you in {1}\",\n\n\t\t\"alerts.user_own_reply\":\"{0} made a post on your profile\",\n\t\t\"alerts.user_reply\":\"{0} posted on {1}'s profile\",\n\t\t\"alerts.user_own_like\":\"{0} likes you\",\n\t\t\"alerts.user_like\":\"{0} likes {1}\",\n\t\t\"alerts.user_own_mention\":\"{0} mentioned you on your profile\",\n\t\t\"alerts.user_mention\":\"{0} mentioned you on {1}'s profile\",\n\t\t\"alerts.new_friend_invite\":\"You received a friend invite from {0}\",\n\n\t\t\"alerts.convo_create\":\"{0} added you to a conversation\",\n\t\t\"alerts.convo_reply\":\"{0} replied to a conversation\",\n\t\t\n\t\t\"alerts.no_alerts\":\"You don't have any alerts\",\n\t\t\"alerts.no_alerts_short\":\"No new alerts\",\n\n\t\t\"topics_click_topics_to_select\":\"Click the topics to select them\",\n\t\t\"topics_new_topic\":\"New Topic\",\n\t\t\"forum_locked\":\"Locked\",\n\t\t\"topics_moderate\":\"Moderate\",\n\t\t\"topics_replies_suffix\":\" replies\",\n\t\t\"forums.topics_suffix\":\" topics\",\n\t\t\"topics_gap_likes_suffix\":\" likes\",\n\t\t\"topics_likes_suffix\":\"likes\",\n\t\t\"topics_last\":\"Last\",\n\t\t\"topics_starter\":\"Starter\",\n\t\t\"topic.like_count_suffix\":\" likes\",\n\t\t\"topic.view_count_suffix\":\" views\",\n\t\t\"topic.plus\":\"+\",\n\t\t\"topic.plus_one\":\"+1\",\n\t\t\"topic.minus_one\":\"-1\",\n\t\t\"topic.gap_up\":\" up\",\n\t\t\"topic.quote_button_text\":\"Quote\",\n\t\t\"topic.edit_button_text\":\"Edit\",\n\t\t\"topic.delete_button_text\":\"Delete\",\n\t\t\"topic.ip_button_text\":\"IP\",\n\t\t\"topic.lock_button_text\":\"Lock\",\n\t\t\"topic.unlock_button_text\":\"Unlock\",\n\t\t\"topic.pin_button_text\":\"Pin\",\n\t\t\"topic.unpin_button_text\":\"Unpin\",\n\t\t\"topic.report_button_text\":\"Report\",\n\t\t\"topic.flag_button_text\":\"Flag\",\n\n\t\t\"topic.select_button_text\":\"Select\",\n\t\t\"topic.copy_button_text\":\"Copy\",\n\t\t\"topic.upload_button_text\":\"Upload\",\n\n\t\t\"analytics.now\": \"now\",\n\t\t\"analytics.today\": \"today\",\n\t\t\"analytics.days\": \" days\",\n\t\t\"analytics.days_short\": \"d\",\n\t\t\"analytics.months\": \" months\",\n\t\t\"analytics.months_short\": \"m\",\n\n\t\t\"panel_rank_admins\":\"Admins\",\n\t\t\"panel_rank_mods\":\"Mods\",\n\t\t\"panel_rank_banned\":\"Banned\",\n\t\t\"panel_rank_guests\":\"Guests\",\n\t\t\"panel_rank_members\":\"Members\",\n\n\t\t\"panel.preset_everyone\":\"Everyone\",\n\t\t\"panel.preset_announcements\":\"Announcements\",\n\t\t\"panel.preset_member_only\":\"Member Only\",\n\t\t\"panel.preset_staff_only\":\"Staff Only\",\n\t\t\"panel.preset_admin_only\":\"Admin Only\",\n\t\t\"panel.preset_archive\":\"Archive\",\n\t\t\"panel.preset_custom\":\"Custom\",\n\t\t\"panel.preset_public\":\"Public\",\n\t\t\"panel_active_hidden\":\"Hidden\",\n\n\t\t\"panel_perms_no_access\":\"No Access\",\n\t\t\"panel_perms_read_only\":\"Read Only\",\n\t\t\"panel_perms_can_post\":\"Can Post\",\n\t\t\"panel_perms_can_moderate\":\"Can Moderate\",\n\t\t\"panel_perms_quasi_mod\":\"Partial Mod\",\n\t\t\"panel_perms_custom\":\"Custom\",\n\t\t\"panel_perms_default\":\"Default\",\n\n\t\t\"panel_edit_button_text\":\"Edit\",\n\t\t\"panel_update_button_text\":\"Update\",\n\t\t\"panel_delete_button_text\":\"Delete\",\n\t\t\n\t\t\"menu_forums_tooltip\":\"Forum List\",\n\t\t\"menu_forums_aria\":\"The forum list\",\n\t\t\"menu_topics_tooltip\":\"Topic List\",\n\t\t\"menu_topics_aria\":\"The topic list\",\n\t\t\"menu_alert_counter_aria\":\"The number of alerts\",\n\t\t\"menu_alert_list_aria\":\"The alert list\",\n\t\t\"menu_account_tooltip\":\"Account Manager\",\n\t\t\"menu_account_aria\":\"The account manager\",\n\t\t\"menu_profile_tooltip\":\"Your profile\",\n\t\t\"menu_profile_aria\":\"Your profile\",\n\t\t\"menu_panel_tooltip\":\"Control Panel\",\n\t\t\"menu_panel_aria\":\"The Control Panel\",\n\t\t\"menu_logout_tooltip\":\"Logout\",\n\t\t\"menu_logout_aria\":\"Log out of your account\",\n\t\t\"menu_register_tooltip\":\"Register\",\n\t\t\"menu_register_aria\":\"Create a new account\",\n\t\t\"menu_login_tooltip\":\"Login\",\n\t\t\"menu_login_aria\":\"Login to your account\",\n\t\t\"menu_hamburger_tooltip\":\"Menu\",\n\t\t\n\t\t\"login_head\":\"Login\",\n\t\t\"login_account_name\":\"Account Name\",\n\t\t\"login_account_password\":\"Password\",\n\t\t\"login_submit_button\":\"Login\",\n\t\t\"login_no_account\":\"Don't have an account?\",\n\t\t\"login_forgot_password\":\"Forgot your password?\",\n\n\t\t\"login_mfa_verify_head\":\"2FA Verify\",\n\t\t\"login_mfa_verify_explanation\":\"Please input the code from the authenticator app below.\",\n\t\t\"login_mfa_token\":\"Token\",\n\t\t\"login_mfa_verify_button\":\"Confirm\",\n\n\t\t\"register_head\":\"Create Account\",\n\t\t\"register_account_name\":\"Account Name\",\n\t\t\"register_account_email\":\"Email\",\n\t\t\"register_account_email_optional\":\"Email (optional)\",\n\t\t\"register_account_password\":\"Password\",\n\t\t\"register_account_confirm_password\":\"Confirm Password\",\n\t\t\"register_account_anti_spam\":\"Ar​e y​ou a sp​am​bo​t?\",\n\t\t\"register_account_verify_image\":\"Verify Image\",\n\t\t\"register_submit_button\":\"Create Account\",\n\n\t\t\"password_reset_head\":\"Password Reset\",\n\t\t\"password_reset_username\":\"Account Name\",\n\t\t\"password_reset_button\":\"Send Email\",\n\t\t\"password_reset_subject\":\"Reset your email\",\n\t\t\"password_reset_body\":\"Dear %s, someone has requested that your password be reset. If this was you, then please click on the following link to do so, otherwise disregard this email.\\n\\n %s\",\n\n\t\t\"password_reset_token_head\":\"Password Reset\",\n\t\t\"password_reset_token_password\":\"New Password\",\n\t\t\"password_reset_token_confirm_password\":\"Confirm Password\",\n\t\t\"password_reset_mfa_token\":\"2FA Token\",\n\t\t\"password_reset_token_button\":\"Update Account\",\n\n\t\t\"account_menu_head\":\"My Account\",\n\t\t\"account_menu_password\":\"Password\",\n\t\t\"account_menu_email\":\"Email\",\n\t\t\"account_menu_security\":\"Security\",\n\t\t\"account_menu_notifications\":\"Notifications\",\n\t\t\"account_menu_logins\":\"Logins\",\n\t\t\"account_menu_privacy\":\"Privacy\",\n\t\t\"account_menu_blocked\":\"Blocked\",\n\t\t\"account_menu_penalties\":\"Penalties\",\n\t\t\"account_menu_messages\":\"Conversations\",\n\n\t\t\"account_coming_soon\":\"Coming Soon\",\n\n\t\t\"account_dash_2fa_setup\":\"Setup your two-factor authentication.\",\n\t\t\"account_dash_2fa_manage\":\"Remove or manage your two-factor authentication.\",\n\t\t\"account_dash_security_notice\":\"Security\",\n\t\t\"account_username_save\":\"Save\",\n\t\t\"account_avatar_select\":\"Select\",\n\t\t\"account_avatar_update_button\":\"Upload\",\n\t\t\"account_avatar_revoke_button\":\"Remove\",\n\n\t\t\"account_email_head\":\"Emails\",\n\t\t\"account_email_primary\":\"Primary\",\n\t\t\"account_email_secondary\":\"Secondary\",\n\t\t\"account_email_verified\":\"Verified\",\n\t\t\"account_email_resend_email\":\"Resend Verification Email\",\n\t\t\"account_email_none\":\"No email addresses found.\",\n\t\t\"account_email_create_email_label\":\"Email\",\n\t\t\"account_email_create_email\":\"john.doe@example.com\",\n\t\t\"account_email_create_button\":\"Add Email\",\n\n\t\t\"account_password_head\":\"Edit Password\",\n\t\t\"account_password_current_password\":\"Current Password\",\n\t\t\"account_password_new_password\":\"New Password\",\n\t\t\"account_password_confirm_password\":\"Confirm Password\",\n\t\t\"account_password_update_button\":\"Update\",\n\n\t\t\"account_privacy_head\":\"Privacy\",\n\t\t\"account_privacy_profile_comments\":\"Profile Comment Visibility\",\n\t\t\"account_privacy_profile_comments_public\":\"Anyone\",\n\t\t\"account_privacy_profile_comments_registered\":\"Registered Users\",\n\t\t\"account_privacy_profile_comments_self\":\"Only Me\",\n\t\t\"account_privacy_enable_embeds\":\"Enable Embeds\",\n\t\t\"account_privacy_button\":\"Update\",\n\n\t\t\"account_mfa_head\":\"Manage 2FA\",\n\t\t\"account_mfa_disable_explanation\":\"You can disable two-factor authentication on your account and go back to logging in normal with just your password by clicking on the following button.\",\n\t\t\"account_mfa_disable_button\":\"Disable 2FA\",\n\t\t\"account_mfa_scratch_head\":\"One Time Codes\",\n\t\t\"account_mfa_scratch_explanation\":\"You can use the following codes to login without having an authenticator app generate codes for you.\\n\\nEach code can only be used once, a new one will replace it when it's used. These are intended as a backup, if your app fails or device (e.g. your phone) dies, be sure to keep them somewhere safe.\",\n\n\t\t\"account_mfa_setup_head\":\"Setup 2FA\",\n\t\t\"account_mfa_setup_explanation\":\"Type this secret into your Google Authenticator and type the code it gives you below. You will have to input codes provided by it for all future logins.\",\n\t\t\"account_mfa_setup_verify\":\"Verify\",\n\t\t\"account_mfa_setup_button\":\"Setup\",\n\n\t\t\"account_logins_head\":\"Logins\",\n\t\t\"account_logins_success\":\"Successful Login\",\n\t\t\"account_logins_failure\":\"Failed Login\",\n\n\t\t\"account_blocked_head\":\"Blocked Users\",\n\t\t\"account_blocked_remove\":\"Remove\",\n\t\t\"account_blocked_no_users\":\"You haven't blocked any users.\",\n\n\t\t\"convos_head\":\"Conversations\",\n\t\t\"convos_create\":\"Create Convo\",\n\t\t\"convos_none\":\"You don't have any conversations yet.\",\n\t\t\"convo_head\":\"Conversation\",\n\t\t\"convo_users\":\"Participants\",\n\t\t\"create_convo_head\":\"Create Conversation\",\n\t\t\"create_convo_recp\":\"Recipient/s\",\n\t\t\"create_convo_button\":\"Create Convo\",\n\n\t\t\"create_block_msg\":\"Are you sure you want to block this user?\",\n\t\t\"remove_block_msg\":\"Are you sure you want to unblock this user?\",\n\n\t\t\"areyousure_head\":\"Are you sure?\",\n\t\t\"areyousure_continue\":\"Continue\",\n\n\t\t\"create_topic_head\":\"Create Topic\",\n\t\t\"create_topic_board\":\"Board\",\n\t\t\"create_topic_name\":\"Topic Name\",\n\t\t\"create_topic_content\":\"Content\",\n\t\t\"create_topic_placeholder\":\"Insert content here\",\n\t\t\"create_topic_create_button\":\"Create Topic\",\n\t\t\"create_topic_add_file_button\":\"Add File\",\n\n\t\t\"quick_topic.aria\":\"Quick Topic Form\",\n\t\t\"quick_topic.avatar_tooltip\":\"Your Avatar\",\n\t\t\"quick_topic.avatar_alt\":\"Your Avatar\",\n\t\t\"quick_topic.whatsup\":\"What's up?\",\n\t\t\"quick_topic.content_placeholder\":\"Insert post here\",\n\t\t\"quick_topic.add_poll_option_first\":\"Poll option #0\",\n\t\t\"quick_topic.create_button\":\"Create Topic\",\n\t\t\"quick_topic.create_button_short\":\"New Topic\",\n\t\t\"quick_topic.add_poll_button\":\"Add Poll\",\n\t\t\"quick_topic.add_file_button\":\"Add File\",\n\t\t\"quick_topic.cancel_button\":\"Cancel\",\n\n\t\t\"topic_list.search_head\":\"Search Results\",\n\t\t\"topic_list.create_topic_tooltip\":\"Create Topic\",\n\t\t\"topic_list.create_topic_aria\":\"Create a topic\",\n\t\t\"topic_list.moderate\":\"Moderate\",\n\t\t\"topic_list.moderate_short\":\"Mod\",\n\t\t\"topic_list.moderate_tooltip\":\"Moderate\",\n\t\t\"topic_list.moderate_aria\":\"Moderate Posts\",\n\t\t\"topic_list.cancel_mod\":\"Cancel Mod\",\n\t\t\"topic_list.what_to_do\":\"What do you want to do with these {0} topics?\",\n\t\t\"topic_list.what_to_do_single\":\"What do you want to do with this topic?\",\n\t\t\"topic_list.moderate_delete\":\"Delete them\",\n\t\t\"topic_list.moderate_lock\":\"Lock them\",\n\t\t\"topic_list.moderate_move\":\"Move them\",\n\t\t\"topic_list.moderate_run\":\"Run\",\n\t\t\"topic_list.move_head\":\"Move these topics to?\",\n\t\t\"topic_list.move_button\":\"Move Topics\",\n\t\t\"topic_list.changed_topics\":\"Click to see %d new or changed topics\",\n\t\t\"topic_list.most_recent_filter\":\"Most Recent\",\n\t\t\"topic_list.most_viewed_filter\":\"Most Viewed\",\n\t\t\"topic_list.week_views_filter\":\"Week Views\",\n\t\t\"topic_list.replies_suffix\":\"replies\",\n\t\t\"topic_list.likes_suffix\":\"likes\",\n\t\t\"topic_list.views_suffix\":\"views\",\n\t\t\"status.closed_tooltip\":\"Status: Closed\",\n\t\t\"status.pinned_tooltip\":\"Status: Pinned\",\n\n\t\t\"topics_locked_tooltip\":\"You don't have the permissions needed to create a topic\",\n\t\t\"topics_locked_aria\":\"You don't have the permissions needed to make a topic anywhere\",\n\t\t\"topics_list_aria\":\"A list containing topics from every forum\",\n\t\t\"topics_no_topics\":\"There aren't any topics yet.\",\n\t\t\"topics_start_one\":\"Start one?\",\n\n\t\t\"forum_locked_tooltip\":\"You don't have the permissions needed to create a topic\",\n\t\t\"forum_locked_aria\":\"You don't have the permissions needed to make a topic in this forum\",\n\t\t\"forum_list_aria\":\"A list containing topics for the specified forum\",\n\t\t\"forum_no_topics\":\"There aren't any topics in this forum yet.\",\n\t\t\"forum_start_one\":\"Start one?\",\n\n\t\t\"forums_head\":\"Forums\",\n\t\t\"forums_no_desc\":\"No description\",\n\t\t\"forums_none\":\"None\",\n\t\t\"forums_no_forums\":\"You don't have access to any forums.\",\n\n\t\t\"topic.topic_info_aria\":\"Topic information\",\n\t\t\"topic.opening_post_aria\":\"The opening post for this topic\",\n\t\t\"topic.status_closed_aria\":\"This topic is locked\",\n\t\t\"topic.title_input_aria\":\"Topic Title Input\",\n\t\t\"topic.update_button\":\"Update\",\n\t\t\"topic.userinfo_aria\":\"The information on the poster\",\n\t\t\"topic.poll_aria\":\"The main poll for this topic\",\n\t\t\"topic.poll_vote\":\"Vote\",\n\t\t\"topic.poll_results\":\"Results\",\n\t\t\"topic.poll_cancel\":\"Cancel\",\n\t\t\"topic.poll_no_results\":\"No one has voted yet.\",\n\t\t\"topic.post_controls_aria\":\"Controls and Author Information\",\n\t\t\"topic.unlike_tooltip\":\"Unlike\",\n\t\t\"topic.unlike_aria\":\"Unlike this topic\",\n\t\t\"topic.like_tooltip\":\"Like\",\n\t\t\"topic.like_aria\":\"Like this topic\",\n\t\t\"topic.quote_tooltip\":\"Quote Topic\",\n\t\t\"topic.quote_aria\":\"Quote this topic\",\n\t\t\"topic.edit_tooltip\":\"Edit Topic\",\n\t\t\"topic.edit_aria\":\"Edit this topic\",\n\t\t\"topic.delete_tooltip\":\"Delete Topic\",\n\t\t\"topic.delete_aria\":\"Delete this topic\",\n\t\t\"topic.unlock_tooltip\":\"Unlock Topic\",\n\t\t\"topic.unlock_aria\":\"Unlock this topic\",\n\t\t\"topic.lock_tooltip\":\"Lock Topic\",\n\t\t\"topic.lock_aria\":\"Lock this topic\",\n\t\t\"topic.unpin_tooltip\":\"Unpin Topic\",\n\t\t\"topic.unpin_aria\":\"Unpin this topic\",\n\t\t\"topic.pin_tooltip\":\"Pin Topic\",\n\t\t\"topic.pin_aria\":\"Pin this topic\",\n\t\t\"topic.ip_tooltip\":\"View IP\",\n\t\t\"topic.ip_full_tooltip\":\"IP Address\",\n\t\t\"topic.ip_full_aria\":\"This user's IP Address\",\n\t\t\"topic.flag_tooltip\":\"Flag this topic\",\n\t\t\"topic.flag_aria\":\"Flag this topic\",\n\t\t\"topic.report_tooltip\":\"Report this topic\",\n\t\t\"topic.report_aria\":\"Report this topic\",\n\t\t\"topic.like_count_aria\":\"The number of likes on this topic\",\n\t\t\"topic.like_count_tooltip\":\"Like Count\",\n\t\t\"topic.level_aria\":\"The poster's level\",\n\t\t\"topic.level_tooltip\":\"Level\",\n\t\t\"topic.current_page_aria\":\"The current page for this topic\",\n\t\t\"topic.post_like_tooltip\":\"Like this\",\n\t\t\"topic.post_like_aria\":\"Like this post\",\n\t\t\"topic.post_unlike_tooltip\":\"Unlike this\",\n\t\t\"topic.post_unlike_aria\":\"Unlike this post\",\n\t\t\"topic.post_edit_tooltip\":\"Edit Reply\",\n\t\t\"topic.post_edit_aria\":\"Edit this post\",\n\t\t\"topic.post_delete_tooltip\":\"Delete Reply\",\n\t\t\"topic.post_delete_aria\":\"Delete this post\",\n\t\t\"topic.post_ip_tooltip\":\"View IP\",\n\t\t\"topic.post_flag_tooltip\":\"Flag this reply\",\n\t\t\"topic.post_flag_aria\":\"Flag this reply\",\n\t\t\"topic.post_like_count_tooltip\":\"Like Count\",\n\t\t\"topic.post_level_aria\":\"The poster's level\",\n\t\t\"topic.post_level_tooltip\":\"Level\",\n\t\t\"topic.reply_aria\":\"The quick reply form\",\n\t\t\"topic.reply_content\":\"Insert reply here\",\n\t\t\"topic.reply_content_alt\":\"What do you think?\",\n\t\t\"topic.reply_add_poll_option\":\"Poll option #%d\",\n\t\t\"topic.reply_add_poll_option_first\":\"Poll option #0\",\n\t\t\"topic.reply_button\":\"Create Reply\",\n\t\t\"topic.reply_add_poll_button\":\"Add Poll\",\n\t\t\"topic.reply_add_file_button\":\"Add File\",\n\n\t\t\"topic.action_topic_lock\":\"This topic was locked by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_unlock\":\"This topic was reopened by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_stick\":\"This topic was pinned by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_unstick\":\"This topic was unpinned by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_move\":\"This topic was moved by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_move_dest\":\"This topic was moved to <a href='%s'>%s</a> by <a href='%s'>%s</a>\",\n\t\t\"topic.action_topic_default\":\"%s has happened\",\n\n\t\t\"topic.your_information\":\"Your information\",\n\n\t\t\"paginator.less_than\":\"&lt;\",\n\t\t\"paginator.greater_than\":\"&gt;\",\n\t\t\"paginator.first_page\":\"‹‹\",\n\t\t\"paginator.first_page_aria\":\"Go to the first page\",\n\t\t\"paginator.last_page\":\"››\",\n\t\t\"paginator.last_page_aria\":\"Go to the last page\",\n\t\t\"paginator.prev_page\":\"‹\",\n\t\t\"paginator.prev_page_aria\":\"Go to the previous page\",\n\t\t\"paginator.next_page\":\"›\",\n\t\t\"paginator.next_page_aria\":\"Go to the next page\",\n\n\t\t\"profile.login_for_options\":\"Login for options\",\n\t\t\"profile.send_message\":\"Send Message\",\n\t\t\"profile.add_friend\":\"Add Friend\",\n\t\t\"profile.unban\":\"Unban\",\n\t\t\"profile.ban\":\"Ban\",\n\t\t\"profile.delete_posts\":\"Delete Posts\",\n\t\t\"profile.block\":\"Block\",\n\t\t\"profile.unblock\":\"Unblock\",\n\t\t\"profile.report_user_tooltip\":\"Report User\",\n\t\t\"profile.report_user_aria\":\"Report User\",\n\t\t\"profile.ban_user_head\":\"Ban User\",\n\t\t\"profile.ban_user_notice\":\"If all the fields are left blank, the ban will be permanent.\",\n\t\t\"profile.ban_user_days\":\"Days\",\n\t\t\"profile.ban_user_weeks\":\"Weeks\",\n\t\t\"profile.ban_user_months\":\"Months\",\n\t\t\"profile.ban_user_reason\":\"Reason\",\n\t\t\"profile.ban_user_button\":\"Ban User\",\n\t\t\"profile.ban_delete_posts\":\"Delete Posts\",\n\t\t\"profile.delete_posts_head\":\"Delete Posts\",\n\t\t\"profile.delete_posts_notice\":\"Would you like to delete %d posts?\",\n\t\t\"profile.delete_posts_button\":\"Delete Posts\",\n\t\t\"profile.comments_head\":\"Comments\",\n\t\t\"profile.comments_edit_tooltip\":\"Edit Item\",\n\t\t\"profile.comments_edit_aria\":\"Edit Item\",\n\t\t\"profile.comments_delete_tooltip\":\"Delete Item\",\n\t\t\"profile.comments_delete_aria\":\"Delete Item\",\n\t\t\"profile.comments_report_tooltip\":\"Report Item\",\n\t\t\"profile.comments_report_aria\":\"Report Item\",\n\t\t\"profile.comments_form_content\":\"Insert comment here\",\n\t\t\"profile.comments_form_button\":\"Create Reply\",\n\t\t\"profile.comments_form_guest\":\"You need to login to comment on this profile.\",\n\t\t\"profile.owner_tag\":\"Profile Owner\",\n\n\t\t\"ip_search_head\":\"IP Search\",\n\t\t\"ip_search_search_button\":\"Search\",\n\t\t\"ip_search_no_users\":\"No users found.\",\n\n\t\t\"error_head\":\"An error has occurred\",\n\t\t\"footer_thingymawhatsit\":\"Can you please keep the powered by notice? ;)\",\n\t\t\"footer_powered_by\":\"Powered by Gosora Forum Software\",\n\t\t\"footer_made_with_love\":\"Made with love by Azareal\",\n\t\t\"footer_theme_selector_aria\":\"Change the site's appearance\",\n\n\t\t\"widget.online_name\":\"Online Users\",\n\t\t\"widget.online_none_online\":\"No one is online.\",\n\t\t\"widget.online_some_online\":\"There are %d users online.\",\n\t\t\"widget.online_view_topic_name\":\"In Topic\",\n\n\t\t\"option_yes\":\"Yes\",\n\t\t\"option_no\":\"No\",\n\n\t\t\"panel_hints_reorder\":\"Drag to change the order\",\n\n\t\t\"panel_back_to_site\":\"Back to Site\",\n\t\t\"panel_welcome\":\"Welcome \",\n\t\t\"panel_menu_head\":\"Control Panel\",\n\t\t\"panel_menu_aria\":\"The control panel menu\",\n\t\t\"panel_menu_users\":\"Users\",\n\t\t\"panel_menu_groups\":\"Groups\",\n\t\t\"panel_menu_forums\":\"Forums\",\n\t\t\"panel_menu_pages\":\"Pages\",\n\t\t\"panel_menu_settings\":\"Settings\",\n\t\t\"panel_menu_word_filters\":\"Word Filters\",\n\t\t\"panel_menu_themes\":\"Themes\",\n\t\t\"panel_menu_menus\":\"Menus\",\n\t\t\"panel_menu_widgets\":\"Widgets\",\n\t\t\"panel_menu_events\":\"Events\",\n\t\t\"panel_menu_stats\":\"Statistics\",\n\t\t\"panel_menu_stats_posts\":\"Posts\",\n\t\t\"panel_menu_stats_topics\":\"Topics\",\n\t\t\"panel_menu_stats_forums\":\"Forums\",\n\t\t\"panel_menu_stats_routes\":\"Routes\",\n\t\t\"panel_menu_stats_routes_perf\":\"Routes Perf\",\n\t\t\"panel_menu_stats_agents\":\"Agents\",\n\t\t\"panel_menu_stats_systems\":\"Systems\",\n\t\t\"panel_menu_stats_languages\":\"Languages\",\n\t\t\"panel_menu_stats_referrers\":\"Referrers\",\n\t\t\"panel_menu_stats_memory\":\"Memory\",\n\t\t\"panel_menu_stats_active_memory\":\"Active Memory\",\n\t\t\"panel_menu_stats_perf\":\"Performance\",\n\t\t\"panel_menu_reports\":\"Reports\",\n\t\t\"panel_menu_logs\":\"Logs\",\n\t\t\"panel_menu_logs_registrations\":\"Registrations\",\n\t\t\"panel_menu_logs_moderators\":\"Mod Actions\",\n\t\t\"panel_menu_logs_administrators\":\"Admin Actions\",\n\t\t\"panel_menu_system\":\"System\",\n\t\t\"panel_menu_plugins\":\"Plugins\",\n\t\t\"panel_menu_backups\":\"Backups\",\n\t\t\"panel_menu_debug\":\"Debug\",\n\n\t\t\"panel_dashboard_head\":\"Dashboard\",\n\t\t\"panel_dashboard_cpu\":\"CPU: %s\",\n\t\t\"panel_dashboard_cpu_desc\":\"The global CPU usage of this server\",\n\t\t\"panel_dashboard_ram\":\"RAM: %s\",\n\t\t\"panel_dashboard_ram_desc\":\"The global RAM usage of this server\",\n\t\t\"panel_dashboard_memused\":\"Mem: %.1f%s\",\n\t\t\"panel_dashboard_memused_desc\":\"The amount of memory likely being used by this instance\",\n\t\t\"panel_dashboard_disk\":\"Disk: %.1f%s\",\n\t\t\"panel_dashboard_disk_unknown\":\"Disk: ??\",\n\t\t\"panel_dashboard_disk_desc\":\"The amount of disk space being used by this instance\",\n\t\t\"panel_dashboard_online\": \"%d%s online\",\n\t\t\"panel_dashboard_online_desc\":\"The number of people who are currently online\",\n\t\t\"panel_dashboard_guests_online\":\"%d%s guests online\",\n\t\t\"panel_dashboard_guests_online_desc\":\"The number of guests who are currently online\",\n\t\t\"panel_dashboard_users_online\":\"%d%s users online\",\n\t\t\"panel_dashboard_users_online_desc\":\"The number of logged-in users who are currently online\",\n\t\t\"panel_dashboard_posts\":\"%d posts %s\",\n\t\t\"panel_dashboard_posts_desc\":\"The number of new posts over the last 24 hours\",\n\t\t\"panel_dashboard_topics\":\"%d topics %s\",\n\t\t\"panel_dashboard_topics_desc\":\"The number of new topics over the last 24 hours\",\n\t\t\"panel_dashboard_online_day\": \"?? online / day\",\n\t\t\"panel_dashboard_searches_day\": \"?? searches / week\",\n\t\t\"panel_dashboard_new_users\":\"%d new users %s\",\n\t\t\"panel_dashboard_new_users_desc\":\"The number of new users over the last 7 days\",\n\t\t\"panel_dashboard_reports\":\"%d reports %s\",\n\t\t\"panel_dashboard_reports_desc\":\"The number of reports over the last 7 days\",\n\t\t\"panel_dashboard_day_suffix\":\" / day\",\n\t\t\"panel_dashboard_week_suffix\":\" / week\",\n\t\t\"panel_dashboard_coming_soon\":\"Coming Soon!\",\n\t\t\"panel_dashboard_unknown\":\"Unknown\",\n\n\t\t\"panel_users_head\":\"Users\",\n\t\t\"panel_users_profile\":\"Profile\",\n\t\t\"panel_users_unban\":\"Unban\",\n\t\t\"panel_users_ban\":\"Ban\",\n\t\t\"panel_users_activate\":\"Activate\",\n\n\t\t\"panel_users_search_head\":\"Search\",\n\t\t\"panel_users_search_name\":\"Name\",\n\t\t\"panel_users_search_name_placeholder\":\"John Doe\",\n\t\t\"panel_users_search_email\":\"Email\",\n\t\t\"panel_users_search_email_placeholder\":\"john.doe@example.com\",\n\t\t\"panel_users_search_group\":\"Group\",\n\t\t\"panel_users_search_group_none\":\"None\",\n\t\t\"panel_users_search_button\":\"Search\",\n\t\t\"panel_users_search_title\":\"Search Users\",\n\n\t\t\"panel_user_head\":\"User Editor\",\n\t\t\"panel_user_avatar\":\"Avatar\",\n\t\t\"panel_user_avatar_select\":\"Select\",\n\t\t\"panel_user_avatar_upload\":\"Upload\",\n\t\t\"panel_user_avatar_remove\":\"Remove\",\n\t\t\"panel_user_name\":\"Name\",\n\t\t\"panel_user_name_placeholder\":\"Jane Doe\",\n\t\t\"panel_user_password\":\"Password\",\n\t\t\"panel_user_email\":\"Email\",\n\t\t\"panel_user_show_email\":\"Show Email\",\n\t\t\"panel_user_group\":\"Group\",\n\t\t\"panel_user_update_button\":\"Update User\",\n\n\t\t\"panel.forums_head\":\"Forums\",\n\t\t\"panel.forums_hidden\":\"Hidden\",\n\t\t\"panel.forums_edit_button_tooltip\":\"Edit Forum\",\n\t\t\"panel.forums_edit_button_aria\":\"Edit Forum\",\n\t\t\"panel.forums_update_button\":\"Update\",\n\t\t\"panel.forums_delete_button_tooltip\":\"Delete Forum\",\n\t\t\"panel.forums_delete_button_aria\":\"Delete Forum\",\n\t\t\"panel.forums_full_edit_button\":\"Full Edit\",\n\t\t\"panel.forums_create_head\":\"Add Forum\",\n\t\t\"panel.forums_create_name_label\":\"Name\",\n\t\t\"panel.forums_create_name\":\"Super Secret Forum\",\n\t\t\"panel.forums_create_desc_label\":\"Description\",\n\t\t\"panel.forums_create_desc\":\"Where all the super secret stuff happens\",\n\t\t\"panel.forums_active_label\":\"Active\",\n\t\t\"panel.forums_preset_label\":\"Preset\",\n\t\t\"panel.forums_create_button\":\"Add Forum\",\n\t\t\"panel.forums_update_order_button\":\"Update Order\",\n\t\t\"panel.forums_order_updated\":\"The forums have been successfully updated\",\n\n\t\t\"panel.forum_head_suffix\":\" Forum\",\n\t\t\"panel.forum_name\":\"Name\",\n\t\t\"panel.forum_name_placeholder\":\"General Forum\",\n\t\t\"panel.forum_desc\":\"Description\",\n\t\t\"panel.forum_desc_placeholder\":\"Where the general stuff happens\",\n\t\t\"panel.forum_active\":\"Active\",\n\t\t\"panel.forum_preset\":\"Preset\",\n\t\t\"panel.forum_update_button\":\"Update Forum\",\n\t\t\"panel.forum_permissions_head\":\"Forum Permissions\",\n\t\t\"panel.forum_edit_button\":\"Edit\",\n\t\t\"panel.forum_short_update_button\":\"Update\",\n\t\t\"panel.forum_full_edit_button\":\"Full Edit\",\n\n\t\t\"panel.forum_actions_head\":\"Actions\",\n\t\t\"panel.forum_actions_create_head\":\"Create Action\",\n\t\t\"panel.forum_action_run_on_topic_creation\":\"Run on Topic Creation\",\n\t\t\"panel.forum_action_run_days_after_topic_creation\":\"Run Days After Topic Creation\",\n\t\t\"panel.forum_action_run_days_after_topic_last_reply\":\"Run Days After Topic Last Reply\",\n\t\t\"panel.forum_action_action\":\"Action\",\n\t\t\"panel.forum_action_action_delete\":\"Delete\",\n\t\t\"panel.forum_action_action_lock\":\"Lock\",\n\t\t\"panel.forum_action_action_unlock\":\"Unlock\",\n\t\t\"panel.forum_action_action_move\":\"Move\",\n\t\t\"panel.forum_action_extra\":\"Extra\",\n\t\t\"panel.forum_action_create_button\":\"Create Action\",\n\n\t\t\"panel_forum_delete_are_you_sure\":\"Are you sure you want to delete the '%s' forum?\",\n\n\t\t\"panel_groups_head\":\"Groups\",\n\t\t\"panel_groups_rank_prefix\":\"Rank \",\n\t\t\"panel_groups_rank_guest\":\"Guest\",\n\t\t\"panel_groups_rank_member\":\"Member\",\n\t\t\"panel_groups_rank_mod\":\"Mod\",\n\t\t\"panel_groups_rank_admin\":\"Admin\",\n\t\t\"panel_groups_rank_banned\":\"Banned\",\n\t\t\"panel_groups_edit_group_button_aria\":\"Edit Group\",\n\t\t\"panel_groups_create_head\":\"Create Group\",\n\t\t\"panel_groups_create_name\":\"Name\",\n\t\t\"panel_groups_create_name_placeholder\":\"Administrator\",\n\t\t\"panel_groups_create_type\":\"Type\",\n\t\t\"panel_groups_create_tag\":\"Tag\",\n\t\t\"panel_groups_create_button\":\"Add Group\",\n\n\t\t\"panel_group_menu_head\":\"Group Editor\",\n\t\t\"panel_group_menu_general\":\"General\",\n\t\t\"panel_group_menu_promotions\":\"Promotions\",\n\t\t\"panel_group_menu_permissions\":\"Permissions\",\n\t\t\"panel_group_head_suffix\":\" Group\",\n\t\t\"panel_group_name\":\"Name\",\n\t\t\"panel_group_name_placeholder\":\"Random Group\",\n\t\t\"panel_group_type\":\"Type\",\n\t\t\"panel_group_tag\":\"Tag\",\n\t\t\"panel_group_tag_placeholder\":\"VIP\",\n\t\t\"panel_group_update_button\":\"Update Group\",\n\t\t\"panel_group_extended_permissions\":\"Extended Permissions\",\n\t\t\"panel_group_mod_permissions\":\"Moderator Permissions\",\n\n\t\t\"panel_group_promotions_row_level_prefix\":\"level \",\n\t\t\"panel_group_promotions_row_posts_prefix\":\"posts \",\n\t\t\"panel_group_promotions_row_registered_minutes\":\"registered for %d minutes\",\n\t\t\"panel_group_promotions_row_delete_button\":\"Delete\",\n\n\t\t\"panel_group_promotions_create_head\":\"Add Promotion\",\n\t\t\"panel_group_promotions_from\":\"From\",\n\t\t\"panel_group_promotions_to\":\"To\",\n\t\t\"panel_group_promotions_two_way\":\"Two Way\",\n\t\t\"panel_group_promotions_level\":\"Level\",\n\t\t\"panel_group_promotions_posts\":\"Posts\",\n\t\t\"panel_group_promotion_reg_for\":\"Registered For\",\n\t\t\"panel_group_promotion_reg_months_suffix\":\" months\",\n\t\t\"panel_group_promotion_reg_days_suffix\":\" days\",\n\t\t\"panel_group_promotion_reg_hours_suffix\":\" hours\",\n\t\t\"panel_group_promotions_create_button\":\"Add Promotion\",\n\n\t\t\"panel_word_filters_head\":\"Word Filters\",\n\t\t\"panel_word_filters_to\":\"to\",\n\t\t\"panel_word_filters_edit_button_aria\":\"Edit Word Filter\",\n\t\t\"panel_word_filters_update_button\":\"Update\",\n\t\t\"panel_word_filters_delete_button_aria\":\"Delete Word Filter\",\n\t\t\"panel_word_filters_no_filters\":\"You don't have any word filters yet.\",\n\t\t\"panel_word_filters_create_head\":\"Add Filter\",\n\t\t\"panel_word_filters_create_find\":\"Find\",\n\t\t\"panel_word_filters_create_find_placeholder\":\"fuck\",\n\t\t\"panel_word_filters_create_replacement\":\"Replacement\",\n\t\t\"panel_word_filters_create_replacement_placeholder\":\"fudge\",\n\t\t\"panel_word_filters_create_button\":\"Add Filter\",\n\n\t\t\"panel_pages_head\":\"Page Manager\",\n\t\t\"panel_pages_edit_button_aria\":\"Edit Page\",\n\t\t\"panel_pages_delete_button_aria\":\"Delete Page\",\n\t\t\"panel_pages_no_pages\":\"You don't have any pages yet.\",\n\t\t\"panel_pages_create_head\":\"Create Page\",\n\t\t\"panel_pages_create_name\":\"Name\",\n\t\t\"panel_pages_create_name_placeholder\":\"faq\",\n\t\t\"panel_pages_create_title\":\"Title\",\n\t\t\"panel_pages_create_title_placeholder\":\"Frequently Asked Questions\",\n\t\t\"panel_pages_create_body_placeholder\":\"We understand you have a lot of questions.\",\n\t\t\"panel_pages_create_button\":\"Create Page\",\n\n\t\t\"panel_pages_edit_head\":\"Page Editor\",\n\t\t\"panel_pages_name\":\"Name\",\n\t\t\"panel_pages_title\":\"Title\",\n\t\t\"panel_pages_edit_update_button\":\"Update Page\",\n\n\t\t\"panel_stats_views_head_suffix\":\" Views\",\n\t\t\"panel_stats_user_agents_head\":\"User Agents\",\n\t\t\"panel_stats_forums_head\":\"Forums\",\n\t\t\"panel_stats_languages_head\":\"Languages\",\n\t\t\"panel_stats_post_counts_head\":\"Post Counts\",\n\t\t\"panel_stats_referrers_head\":\"Referrers\",\n\t\t\"panel_stats_routes_head\":\"Routes\",\n\t\t\"panel_stats_routes_perf_head\":\"Routes Performance\",\n\t\t\"panel_stats_operating_systems_head\":\"Operating Systems\",\n\t\t\"panel_stats_topic_counts_head\":\"Topic Counts\",\n\t\t\"panel_stats_requests_head\":\"Requests\",\n\t\t\"panel_stats_memory_head\":\"Memory Usage\",\n\t\t\"panel_stats_active_memory_head\":\"Active Memory\",\n\t\t\"panel_stats_perf_head\":\"Performance\",\n\n\t\t\"panel_stats_spam_hide\":\"Hide Spam\",\n\t\t\"panel_stats_spam_show\":\"Show Spam\",\n\t\t\"panel_stats_memory_type_total\":\"Total\",\n\t\t\"panel_stats_memory_type_stack\":\"Stack\",\n\t\t\"panel_stats_memory_type_heap\":\"Heap\",\n\t\t\"panel_stats_perf_low\":\"Low\",\n\t\t\"panel_stats_perf_high\":\"High\",\n\t\t\"panel_stats_perf_avg\":\"Average\",\n\n\t\t\"panel_stats_time_range_one_year\":\"1 year\",\n\t\t\"panel_stats_time_range_three_months\":\"3 months\",\n\t\t\"panel_stats_time_range_one_month\":\"1 month\",\n\t\t\"panel_stats_time_range_one_week\":\"1 week\",\n\t\t\"panel_stats_time_range_two_days\":\"2 days\",\n\t\t\"panel_stats_time_range_one_day\":\"1 day\",\n\t\t\"panel_stats_time_range_twelve_hours\":\"12 hours\",\n\t\t\"panel_stats_time_range_six_hours\":\"6 hours\",\n\n\t\t\"panel_stats_post_counts_chart_aria\":\"Post Chart\",\n\t\t\"panel_stats_topic_counts_chart_aria\":\"Topic Chart\",\n\t\t\"panel_stats_requests_chart_aria\":\"Requests Chart\",\n\t\t\"panel_stats_memory_chart_aria\":\"Memory Use Chart\",\n\t\t\"panel_stats_details_head\":\"Details\",\n\t\t\"panel_stats_post_counts_table_aria\":\"Post Table, this has the same information as the post chart\",\n\t\t\"panel_stats_topic_counts_table_aria\":\"Topic Table, this has the same information as the topic chart\",\n\t\t\"panel_stats_route_views_table_aria\":\"View Table, this has the same information as the view chart\",\n\t\t\"panel_stats_requests_table_aria\":\"View Table, this has the same information as the view chart\",\n\t\t\"panel_stats_memory_table_aria\":\"Memory Use Table, this has the same information as the memory use chart\",\n\t\t\"panel_stats_views_suffix\":\" views\",\n\t\t\"panel_stats_posts_suffix\":\" posts\",\n\t\t\"panel_stats_topics_suffix\":\" topics\",\n\n\t\t\"panel_stats_user_agents_no_user_agents\":\"No user agents could be found in the selected time range\",\n\t\t\"panel_stats_forums_no_forums\":\"No forum view counts could be found in the selected time range\",\n\t\t\"panel_stats_languages_no_languages\":\"No language could be found in the selected time range\",\n\t\t\"panel_stats_post_counts_no_post_counts\":\"No posts could be found in the selected time range\",\n\t\t\"panel_stats_referrers_no_referrers\":\"No referrers could be found in the selected time range\",\n\t\t\"panel_stats_routes_no_routes\":\"No route view counts could be found in the selected time range\",\n\t\t\"panel_stats_operating_systems_no_operating_systems\":\"No operating systems could be found in the selected time range\",\n\t\t\"panel_stats_memory_no_memory\":\"No memory chunks could be found in the selected time range\",\n\n\t\t\"panel_logs_menu_head\":\"Logs\",\n\t\t\"panel_logs_reg_head\":\"Registrations\",\n\t\t\"panel_logs_reg_attempt\":\"Attempt: \",\n\t\t\"panel_logs_reg_email\":\"email: \",\n\t\t\"panel_logs_reg_reason\":\"reason: \",\n\t\t\"panel_logs_reg_no_logs\":\"There aren't any registrations logged.\",\n\n\t\t\"panel_logs_mod_head\":\"Mod Action Logs\",\n\t\t\"panel_logs_mod_action_topic_stick\":\"<a href='%s'>%s</a> was pinned by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_unstick\":\"<a href='%s'>%s</a> was unpinned by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_lock\":\"<a href='%s'>%s</a> was locked by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_unlock\":\"<a href='%s'>%s</a> was reopened by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_delete\":\"Topic #%d was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_move\":\"<a href='%s'>%s</a> was moved by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_move_dest\":\"<a href='%s'>%s</a> was moved to <a href='%s'>%s</a> by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_topic_unknown\":\"Unknown action '%s' on elementType '%s' by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_reply_delete\":\"A reply in <a href='%s'>%s</a> was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_profile_reply_delete\":\"A reply on <a href='%s'>%s</a>'s profile was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_user_ban\":\"<a href='%s'>%s</a> was banned by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_user_unban\":\"<a href='%s'>%s</a> was unbanned by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_user_delete-posts\":\"<a href='%s'>%s</a> had their posts purged by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_user_activate\":\"<a href='%s'>%s</a> was activated by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_action_unknown\":\"Unknown action '%s' on elementType '%s' by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_mod_no_logs\":\"There aren't any events logged.\",\n\n\t\t\"user_unknown\":\"Unknown\",\n\t\t\"topic_unknown\":\"Unknown\",\n\t\t\"group_unknown\":\"Unknown\",\n\t\t\"forum_unknown\":\"Unknown\",\n\t\t\"page_unknown\":\"Unknown\",\n\t\t\"setting_unknown\":\"unknown\",\n\n\t\t\"panel_logs_admin_head\":\"Admin Action Logs\",\n\t\t\"panel_logs_admin_action_user_edit\":\"User <a href='%s'>%s</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_group_create\":\"Group <a href='%s'>%s</a> was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_group_edit\":\"Group <a href='%s'>%s</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_group_promotion_create\":\"A group promotion was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_group_promotion_delete\":\"A group promotion was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_forum_reorder\":\"Forums were reordered by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_forum_create\":\"Forum <a href='%s'>%s</a> was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_forum_delete\":\"Forum <a href='%s'>%s</a> was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_forum_edit\":\"Forum <a href='%s'>%s</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_page_create\":\"Page <a href='%s'>%s</a> was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_page_delete\":\"Page <a href='%s'>%s</a> was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_page_edit\":\"Page <a href='%s'>%s</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_setting_edit\":\"Setting <a href='%s'>%s</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_word_filter_create\":\"A word filter was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_word_filter_delete\":\"A word filter was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_word_filter_edit\":\"A word filter was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_menu_suborder\":\"Menu #%d was reordered by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_menu_item_edit\":\"Menu item <a href='%s'>#%d</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_menu_item_create\":\"Menu item <a href='%s'>#%d</a> was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_menu_item_delete\":\"Menu item <a href='%s'>#%d</a> was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_widget_edit\":\"Widget <a href='%s'>#%d</a> was modified by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_widget_create\":\"Widget <a href='%s'>#%d</a> was created by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_widget_delete\":\"Widget <a href='%s'>#%d</a> was deleted by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_plugin_activate\":\"The plugin '%s' was activated by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_plugin_deactivate\":\"The plugin '%s' was deactivated by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_plugin_install\":\"The plugin '%s' was installed by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_backup_download\":\"A backup was downloaded by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_action_unknown\":\"Unknown action '%s' on elementType '%s' by <a href='%s'>%s</a>\",\n\t\t\"panel_logs_admin_no_logs\":\"There aren't any events logged.\",\n\n\t\t\"panel_plugins_head\":\"Plugins\",\n\t\t\"panel_plugins_author_prefix\":\"Author: \",\n\t\t\"panel_plugins_settings\":\"Settings\",\n\t\t\"panel_plugins_deactivate\":\"Deactivate\",\n\t\t\"panel_plugins_activate\":\"Activate\",\n\t\t\"panel_plugins_install\":\"Install\",\n\n\t\t\"panel_themes_primary_themes\":\"Primary Themes\",\n\t\t\"panel_themes_variant_themes\":\"Variant Themes\",\n\t\t\"panel_themes_author_prefix\":\"Author: \",\n\t\t\"panel_themes_mobile_friendly_tooltip\":\"Mobile Friendly\",\n\t\t\"panel_themes_mobile_friendly_aria\":\"Mobile Friendly\",\n\t\t\"panel_themes_default\":\"Default\",\n\t\t\"panel_themes_make_default\":\"Make Default\",\n\n\t\t\"panel_themes_menus_head\":\"Menus\",\n\t\t\"panel_themes_menus_main\":\"Main Menu\",\n\t\t\"panel_themes_menus_items_suffix\":\" items\",\n\t\t\"panel_themes_menus_items_head\":\"Menu Items\",\n\t\t\"panel_themes_menus_items_edit_button_aria\":\"Edit menu item\",\n\t\t\"panel_themes_menus_items_delete_button_aria\":\"Delete menu item\",\n\t\t\"panel_themes_menus_items_update_button\":\"Update Order\",\n\t\t\"panel.themes_menus_items_order_updated\":\"The menu has been successfully updated\",\n\n\t\t\"panel_themes_menus_edit_head\":\"Menu Editor\",\n\t\t\"panel_themes_menus_create_head\":\"Create Menu Item\",\n\t\t\"panel_themes_menus_name\":\"Name\",\n\t\t\"panel_themes_menus_name_placeholder\":\"Item Name\",\n\t\t\"panel_themes_menus_htmlid\":\"HTML ID\",\n\t\t\"panel_themes_menus_cssclass\":\"CSS Class\",\n\t\t\"panel_themes_menus_position\":\"Position\",\n\t\t\"panel_themes_menus_path\":\"Path\",\n\t\t\"panel_themes_menus_aria\":\"Aria\",\n\t\t\"panel_themes_menus_aria_placeholder\":\"Short description for helping those with poor vision\",\n\t\t\"panel_themes_menus_tooltip\":\"Tooltip\",\n\t\t\"panel_themes_menus_tooltip_placeholder\":\"A tooltip shown when you hover over it\",\n\t\t\"panel_themes_menus_tmplname\":\"Template\",\n\t\t\"panel_themes_menus_permissions\":\"Who Can See\",\n\t\t\"panel_themes_menus_everyone\": \"Everyone\",\n\t\t\"panel_themes_menus_guestonly\":\"Guests\",\n\t\t\"panel_themes_menus_memberonly\":\"Members\",\n\t\t\"panel_themes_menus_supermodonly\":\"Super Mods\",\n\t\t\"panel_themes_menus_adminonly\":\"Admins\",\n\t\t\"panel_themes_menus_edit_update_button\":\"Update\",\n\t\t\"panel_themes_menus_create_button\":\"Create\",\n\n\t\t\"panel_themes_widgets_head\":\"Widgets\",\n\t\t\"panel_themes_widgets_disabled\":\"disabled\",\n\t\t\"panel_themes_widgets_new\":\"New Widget\",\n\t\t\"panel_themes_widgets_type\":\"Type\",\n\t\t\"panel_themes_widgets_type_about\":\"About\",\n\t\t\"panel_themes_widgets_type_simple\":\"Simple\",\n\t\t\"panel_themes_widgets_type_wol\":\"Online Users\",\n\t\t\"panel_themes_widgets_type_wol_context\":\"Online User Context\",\n\t\t\"panel_themes_widgets_type_search_and_filter\":\"Search & Filter\",\n\t\t\"panel_themes_widgets_enabled\":\"Enabled\",\n\t\t\"panel_themes_widgets_location\":\"Location\",\n\t\t\"panel_themes_widgets_name\":\"Name\",\n\t\t\"panel_themes_widgets_body\":\"Body\",\n\t\t\"panel_themes_widgets_raw_body\":\"Body\",\n\t\t\"panel_themes_widgets_save\":\"Save\",\n\t\t\"panel_themes_widgets_delete\":\"Delete\",\n\n\t\t\"panel_settings_head\":\"Settings\",\n\t\t\"panel_setting_head\":\"Edit Setting\",\n\t\t\"panel_setting_name\":\"Setting Name\",\n\t\t\"panel_setting_value\":\"Setting Value\",\n\t\t\"panel_setting_update_button\":\"Update Setting\",\n\n\t\t\"panel_backups_head\":\"Backups\",\n\t\t\"panel_backups_download\":\"Download\",\n\t\t\"panel_backups_no_backups\":\"There aren't any backups available at this time.\",\n\n\t\t\"panel_debug_head\":\"Debug\",\n\t\t\"panel_debug_go_version_label\":\"Go Version\",\n\t\t\"panel_debug_database_version_label\":\"DB Version\",\n\t\t\"panel_debug_uptime_label\":\"Uptime\",\n\n\t\t\"panel_debug_open_database_connections_label\":\"Open DB Conns\",\n\t\t\"panel_debug_adapter_label\":\"Adapter\",\n\n\t\t\"panel_debug_goroutine_count_label\":\"Goroutines\",\n\t\t\"panel_debug_cpu_count_label\":\"CPUs\",\n\t\t\"panel_debug_http_conns_label\":\"HTTP Conns\",\n\n\t\t\"panel_debug_tasks\":\"Tasks\",\n\t\t\"panel_debug_tasks_half_second\":\"Half Second\",\n\t\t\"panel_debug_tasks_second\":\"Second\",\n\t\t\"panel_debug_tasks_fifteen_minute\":\"Fifteen Minute\",\n\t\t\"panel_debug_tasks_hour\":\"Hourly\",\n\t\t\"panel_debug_tasks_shutdown\":\"Shutdown\",\n\n\t\t\"panel_debug_memory_stats\":\"Memory Statistics\",\n\t\t\"panel_debug_memory_stats_sys\":\"Sys\",\n\t\t\"panel_debug_memory_stats_heapsys\":\"HeapSys\",\n\t\t\"panel_debug_memory_stats_heapalloc\":\"HeapAlloc\",\n\t\t\"panel_debug_memory_stats_heapidle\":\"HeapIdle\",\n\t\t\"panel_debug_memory_stats_heapobjects\":\"HeapObjects\",\n\t\t\"panel_debug_memory_stats_stackinuse\":\"StackInuse\",\n\t\t\"panel_debug_memory_stats_mspaninuse\":\"MSpanInuse\",\n\t\t\"panel_debug_memory_stats_mcacheinuse\":\"MCacheInuse\",\n\t\t\"panel_debug_memory_stats_mspansys\":\"MSpanSys\",\n\t\t\"panel_debug_memory_stats_mcachesys\":\"MCacheSys\",\n\t\t\"panel_debug_memory_stats_gcsys\":\"GCSys\",\n\t\t\"panel_debug_memory_stats_othersys\":\"OtherSys\",\n\n\t\t\"panel_debug_caches\":\"Caches\",\n\t\t\"panel_debug_caches_topic\":\"Topic Cache\",\n\t\t\"panel_debug_caches_user\":\"User Cache\",\n\t\t\"panel_debug_caches_reply\":\"Reply Cache\",\n\t\t\"panel_debug_caches_topic_list\":\"Topic List\",\n\n\t\t\"panel_debug_database\":\"Database\",\n\t\t\"panel_debug_database_topics\":\"Topics\",\n\t\t\"panel_debug_database_users\":\"Users\",\n\t\t\"panel_debug_database_replies\":\"Replies\",\n\t\t\"panel_debug_database_profile_replies\":\"Profile Replies\",\n\t\t\"panel_debug_database_activity_stream\":\"Activity Stream\",\n\t\t\"panel_debug_database_likes\":\"Likes\",\n\t\t\"panel_debug_database_attachments\":\"Attachments\",\n\t\t\"panel_debug_database_polls\":\"Polls\",\n\t\t\"panel_debug_database_login_logs\":\"Login Logs\",\n\t\t\"panel_debug_database_reg_logs\":\"Reg Logs\",\n\t\t\"panel_debug_database_mod_logs\":\"Mod Logs\",\n\t\t\"panel_debug_database_admin_logs\":\"Admin Logs\",\n\t\t\"panel_debug_database_views\":\"Views\",\n\t\t\"panel_debug_database_views_agents\":\"Views Agents\",\n\t\t\"panel_debug_database_views_forums\":\"Views Forums\",\n\t\t\"panel_debug_database_views_langs\":\"Views Langs\",\n\t\t\"panel_debug_database_views_referrers\":\"Views Referrers\",\n\t\t\"panel_debug_database_views_systems\":\"Views Systems\",\n\t\t\"panel_debug_database_post_analytics\":\"Post Analytics\",\n\t\t\"panel_debug_database_topic_analytics\":\"Topic Analytics\",\n\n\t\t\"panel_debug_disk\":\"Disk\",\n\t\t\"panel_debug_disk_static_files\":\"Static Files\",\n\t\t\"panel_debug_disk_attachments\":\"Attachments\",\n\t\t\"panel_debug_disk_avatars\":\"Avatars\",\n\t\t\"panel_debug_disk_log_files\":\"Log Files\",\n\t\t\"panel_debug_disk_backups\":\"Backups\",\n\t\t\"panel_debug_disk_git\":\"Git\"\n\t}\n}"
  },
  {
    "path": "last_version.txt",
    "content": "0.1.0-dev\n"
  },
  {
    "path": "logs/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "main.go",
    "content": "/*\n*\n*\tGosora Main File\n*\tCopyright Azareal 2016 - 2020\n*\n */\n// Package main contains the main initialisation logic for Gosora\npackage main // import \"github.com/Azareal/Gosora\"\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"mime\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"runtime/pprof\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\tmeta \"github.com/Azareal/Gosora/common/meta\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\t_ \"github.com/Azareal/Gosora/extend\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/Gosora/routes\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/fsnotify/fsnotify\"\n\n\t//\"github.com/lucas-clemente/quic-go/http3\"\n\t\"github.com/pkg/errors\"\n)\n\nvar router *GenRouter\n\n// TODO: Wrap the globals in here so we can pass pointers to them to subpackages\nvar globs *Globs\n\ntype Globs struct {\n\tstmts *Stmts\n}\n\n// Temporary alias for renderTemplate\nfunc init() {\n\tc.RenderTemplateAlias = routes.RenderTemplate\n}\n\nfunc afterDBInit() (err error) {\n\tif err := storeInit(); err != nil {\n\t\treturn err\n\t}\n\tlog.Print(\"Exitted storeInit\")\n\n\tc.GzipStartEtag = \"\\\"\" + strconv.FormatInt(c.StartTime.Unix(), 10) + \"-ng\\\"\"\n\tc.StartEtag = \"\\\"\" + strconv.FormatInt(c.StartTime.Unix(), 10) + \"-n\\\"\"\n\n\tvar uids []int\n\ttc := c.Topics.GetCache()\n\tif tc != nil {\n\t\tlog.Print(\"Preloading topics\")\n\t\t// Preload ten topics to get the wheels going\n\t\tvar count = 10\n\t\tif tc.GetCapacity() <= 10 {\n\t\t\tcount = 2\n\t\t\tif tc.GetCapacity() <= 2 {\n\t\t\t\tcount = 0\n\t\t\t}\n\t\t}\n\t\tgroup, err := c.Groups.Get(c.GuestUser.Group)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// TODO: Use the same cached data for both the topic list and the topic fetches...\n\t\ttList, _, _, err := c.TopicList.GetListByCanSee(group.CanSee, 1, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tctList := make([]*c.TopicsRow, len(tList))\n\t\tcopy(ctList, tList)\n\n\t\ttList, _, _, err = c.TopicList.GetListByCanSee(group.CanSee, 2, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, tItem := range tList {\n\t\t\tctList = append(ctList, tItem)\n\t\t}\n\n\t\ttList, _, _, err = c.TopicList.GetListByCanSee(group.CanSee, 3, 0, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, tItem := range tList {\n\t\t\tctList = append(ctList, tItem)\n\t\t}\n\n\t\tif count > len(ctList) {\n\t\t\tcount = len(ctList)\n\t\t}\n\t\tfor i := 0; i < count; i++ {\n\t\t\t_, _ = c.Topics.Get(ctList[i].ID)\n\t\t}\n\t}\n\n\tuc := c.Users.GetCache()\n\tif uc != nil {\n\t\t// Preload associated users too...\n\t\tfor _, uid := range uids {\n\t\t\t_, _ = c.Users.Get(uid)\n\t\t}\n\t}\n\n\tlog.Print(\"Exitted afterDBInit\")\n\treturn nil\n}\n\n// Experimenting with a new error package here to try to reduce the amount of debugging we have to do\n// TODO: Dynamically register these items to avoid maintaining as much code here?\nfunc storeInit() (e error) {\n\tacc := qgen.NewAcc()\n\tws := errors.WithStack\n\tvar rcache c.ReplyCache\n\tif c.Config.ReplyCache == \"static\" {\n\t\trcache = c.NewMemoryReplyCache(c.Config.ReplyCacheCapacity)\n\t}\n\tc.Rstore, e = c.NewSQLReplyStore(acc, rcache)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Prstore, e = c.NewSQLProfileReplyStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Likes, e = c.NewDefaultLikeStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.ForumActionStore, e = c.NewDefaultForumActionStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Convos, e = c.NewDefaultConversationStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.UserBlocks, e = c.NewDefaultBlockStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.GroupPromotions, e = c.NewDefaultGroupPromotionStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\n\tif e = p.InitPhrases(c.Site.Language); e != nil {\n\t\treturn ws(e)\n\t}\n\tif e = c.InitEmoji(); e != nil {\n\t\treturn ws(e)\n\t}\n\tif e = c.InitWeakPasswords(); e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Loading the static files.\")\n\tif e = c.Themes.LoadStaticFiles(); e != nil {\n\t\treturn ws(e)\n\t}\n\tif e = c.StaticFiles.Init(); e != nil {\n\t\treturn ws(e)\n\t}\n\tif e = c.StaticFiles.JSTmplInit(); e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Initialising the widgets\")\n\tc.Widgets = c.NewDefaultWidgetStore()\n\tif e = c.InitWidgets(); e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Initialising the menu item list\")\n\tc.Menus = c.NewDefaultMenuStore()\n\tif e = c.Menus.Load(1); e != nil { // 1 = the default menu\n\t\treturn ws(e)\n\t}\n\tmenuHold, e := c.Menus.Get(1)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tfmt.Printf(\"menuHold: %+v\\n\", menuHold)\n\tvar b bytes.Buffer\n\tmenuHold.Build(&b, &c.GuestUser, \"/\")\n\tfmt.Println(\"menuHold output: \", string(b.Bytes()))\n\n\tlog.Print(\"Initialising the authentication system\")\n\tc.Auth, e = c.NewDefaultAuth()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Initialising the stores\")\n\tc.WordFilters, e = c.NewDefaultWordFilterStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.MFAstore, e = c.NewSQLMFAStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Pages, e = c.NewDefaultPageStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Reports, e = c.NewDefaultReportStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Emails, e = c.NewDefaultEmailStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.LoginLogs, e = c.NewLoginLogStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.RegLogs, e = c.NewRegLogStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.ModLogs, e = c.NewModLogStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.AdminLogs, e = c.NewAdminLogStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.IPSearch, e = c.NewDefaultIPSearcher()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tif c.Config.Search == \"\" || c.Config.Search == \"sql\" {\n\t\tc.RepliesSearch, e = c.NewSQLSearcher(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t}\n\tc.Subscriptions, e = c.NewDefaultSubscriptionStore()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Attachments, e = c.NewDefaultAttachmentStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Polls, e = c.NewDefaultPollStore(c.NewMemoryPollCache(100)) // TODO: Max number of polls held in cache, make this a config item\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.TopicList, e = c.NewDefaultTopicList(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.PasswordResetter, e = c.NewDefaultPasswordResetter(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.Analytics = c.NewDefaultAnalytics()\n\tc.Activity, e = c.NewDefaultActivityStream(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tc.ActivityMatches, e = c.NewDefaultActivityStreamMatches(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\t// TODO: Let the admin choose other thumbnailers, maybe ones defined in plugins\n\tc.Thumbnailer = c.NewCaireThumbnailer()\n\tc.Recalc, e = c.NewDefaultRecalc(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Initialising the meta store\")\n\tc.Meta, e = meta.NewDefaultMetaStore(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\n\tlog.Print(\"Initialising the view counters\")\n\tif !c.Config.DisableAnalytics {\n\t\tco.GlobalViewCounter, e = co.NewGlobalViewCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t\tco.AgentViewCounter, e = co.NewDefaultAgentViewCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t\tco.OSViewCounter, e = co.NewDefaultOSViewCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t\tco.LangViewCounter, e = co.NewDefaultLangViewCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t\tif !c.Config.RefNoTrack {\n\t\t\tco.ReferrerTracker, e = co.NewDefaultReferrerTracker()\n\t\t\tif e != nil {\n\t\t\t\treturn ws(e)\n\t\t\t}\n\t\t}\n\t\tco.MemoryCounter, e = co.NewMemoryCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t\tco.PerfCounter, e = co.NewDefaultPerfCounter(acc)\n\t\tif e != nil {\n\t\t\treturn ws(e)\n\t\t}\n\t}\n\tco.RouteViewCounter, e = co.NewDefaultRouteViewCounter(acc)\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tco.PostCounter, e = co.NewPostCounter()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tco.TopicCounter, e = co.NewTopicCounter()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tco.TopicViewCounter, e = co.NewDefaultTopicViewCounter()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\tco.ForumViewCounter, e = co.NewDefaultForumViewCounter()\n\tif e != nil {\n\t\treturn ws(e)\n\t}\n\n\treturn nil\n}\n\n// TODO: Split this function up\nfunc main() {\n\t// TODO: Recover from panics\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tlog.Print(r)\n\t\t\tdebug.PrintStack()\n\t\t\tlog.Fatal(\"Fatal error.\")\n\t\t}\n\t}()\n\tc.StartTime = time.Now()\n\n\t// TODO: Have a file for each run with the time/date the server started as the file name?\n\t// TODO: Log panics with recover()\n\tf, err := os.OpenFile(\"./logs/ops-\"+strconv.FormatInt(c.StartTime.Unix(), 10)+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\t//c.LogWriter = io.MultiWriter(os.Stderr, f)\n\tc.LogWriter = io.MultiWriter(os.Stdout, f)\n\tc.ErrLogWriter = io.MultiWriter(os.Stderr, f)\n\tlog.SetOutput(c.LogWriter)\n\tc.ErrLogger = log.New(c.ErrLogWriter, \"\", log.LstdFlags)\n\tlog.Print(\"Running Gosora v\" + c.SoftwareVersion.String())\n\tfmt.Println(\"\")\n\n\t// TODO: Add a flag for enabling the profiler\n\tif false {\n\t\tf, err := os.Create(c.Config.LogDir + \"cpu.prof\")\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tpprof.StartCPUProfile(f)\n\t}\n\n\terr = mime.AddExtensionType(\".avif\", \"image/avif\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tjsToken, err := c.GenerateSafeString(80)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tc.JSTokenBox.Store(jsToken)\n\n\tlog.Print(\"Loading the configuration data\")\n\terr = c.LoadConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tlog.Print(\"Processing configuration data\")\n\terr = c.ProcessConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tif c.Config.DisableStdout {\n\t\tc.LogWriter = f\n\t\tlog.SetOutput(c.LogWriter)\n\t}\n\tif c.Config.DisableStderr {\n\t\tc.ErrLogWriter = f\n\t\tc.ErrLogger = log.New(c.ErrLogWriter, \"\", log.LstdFlags)\n\t}\n\tc.Tasks = c.NewScheduledTasks()\n\n\terr = c.InitTemplates()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tc.Themes, err = c.NewThemeList()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tc.TopicListThaw = c.NewSingleServerThaw()\n\n\terr = InitDatabase()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer db.Close()\n\n\tbuildTemplates := flag.Bool(\"build-templates\", false, \"build the templates\")\n\tflag.Parse()\n\tif *buildTemplates {\n\t\tif err = c.CompileTemplates(); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tif err = c.CompileJSTemplates(); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\treturn\n\t}\n\n\terr = afterDBInit()\n\tif err != nil {\n\t\tlog.Fatalf(\"%+v\", err)\n\t}\n\terr = c.VerifyConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif !c.Dev.NoFsnotify {\n\t\tlog.Print(\"Initialising the file watcher\")\n\t\twatcher, err := fsnotify.NewWatcher()\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tdefer watcher.Close()\n\n\t\tgo func() {\n\t\t\tdefer c.EatPanics()\n\t\t\tvar ErrFileSkip = errors.New(\"skip mod file\")\n\t\t\tmodifiedFileEvent := func(path string) error {\n\t\t\t\tpathBits := strings.Split(path, \"\\\\\")\n\t\t\t\tif len(pathBits) == 0 {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif pathBits[0] == \"themes\" {\n\t\t\t\t\tvar themeName string\n\t\t\t\t\tif len(pathBits) >= 2 {\n\t\t\t\t\t\tthemeName = pathBits[1]\n\t\t\t\t\t}\n\t\t\t\t\tif len(pathBits) >= 3 && pathBits[2] == \"public\" {\n\t\t\t\t\t\t// TODO: Handle new themes freshly plopped into the folder?\n\t\t\t\t\t\ttheme, ok := c.Themes[themeName]\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\treturn theme.LoadStaticFiles()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn ErrFileSkip\n\t\t\t}\n\n\t\t\t// TODO: Expand this to more types of files\n\t\t\tvar err error\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase ev := <-watcher.Events:\n\t\t\t\t\t// TODO: Handle file deletes (and renames more graciously by removing the old version of it)\n\t\t\t\t\tif ev.Op&fsnotify.Write == fsnotify.Write {\n\t\t\t\t\t\terr = modifiedFileEvent(ev.Name)\n\t\t\t\t\t\tif err != ErrFileSkip {\n\t\t\t\t\t\t\tlog.Println(\"modified file:\", ev.Name)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\terr = nil\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if ev.Op&fsnotify.Create == fsnotify.Create {\n\t\t\t\t\t\tlog.Println(\"new file:\", ev.Name)\n\t\t\t\t\t\terr = modifiedFileEvent(ev.Name)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Println(\"unknown event:\", ev)\n\t\t\t\t\t\terr = nil\n\t\t\t\t\t}\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tc.LogError(err)\n\t\t\t\t\t}\n\t\t\t\tcase err = <-watcher.Errors:\n\t\t\t\t\tc.LogWarning(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\t// TODO: Keep tabs on the (non-resource) theme stuff, and the langpacks\n\t\terr = watcher.Add(\"./public\")\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\terr = watcher.Add(\"./templates\")\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tfor _, theme := range c.Themes {\n\t\t\terr = watcher.Add(\"./themes/\" + theme.Name + \"/public\")\n\t\t\tif err != nil {\n\t\t\t\tlog.Fatal(err)\n\t\t\t}\n\t\t}\n\t}\n\n\t/*if err = c.StaticFiles.GenJS(); err != nil {\n\t\tc.LogError(err)\n\t}*/\n\n\tlog.Print(\"Checking for init tasks\")\n\tif err = sched(); err != nil {\n\t\tc.LogError(err)\n\t}\n\n\tlog.Print(\"Initialising the task system\")\n\n\t// Thumbnailer goroutine, we only want one image being thumbnailed at a time, otherwise they might wind up consuming all the CPU time and leave no resources left to service the actual requests\n\t// TODO: Could we expand this to attachments and other things too?\n\tthumbChan := make(chan bool)\n\tgo c.ThumbTask(thumbChan)\n\tif err = tickLoop(thumbChan); err != nil {\n\t\tc.LogError(err)\n\t}\n\tgo TickLoop.Loop()\n\n\t// Resource Management Goroutine\n\tgo func() {\n\t\tdefer c.EatPanics()\n\t\tuc, tc := c.Users.GetCache(), c.Topics.GetCache()\n\t\tif uc == nil && tc == nil {\n\t\t\treturn\n\t\t}\n\n\t\tvar lastEvictedCount int\n\t\tvar couldNotDealloc bool\n\t\tsecondTicker := time.NewTicker(time.Second)\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-secondTicker.C:\n\t\t\t\t// TODO: Add a LastRequested field to cached User structs to avoid evicting the same things which wind up getting loaded again anyway?\n\t\t\t\tif uc != nil {\n\t\t\t\t\tucap := uc.GetCapacity()\n\t\t\t\t\tif uc.Length() <= ucap || c.Users.Count() <= ucap {\n\t\t\t\t\t\tcouldNotDealloc = false\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tlastEvictedCount = uc.DeallocOverflow(couldNotDealloc)\n\t\t\t\t\tcouldNotDealloc = (lastEvictedCount == 0)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\tlog.Print(\"Initialising the router\")\n\trouter, err = NewGenRouter(&RouterConfig{\n\t\tUploads: http.FileServer(http.Dir(\"./uploads\")),\n\t})\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tlog.Print(\"Initialising the plugins\")\n\tc.InitPlugins()\n\n\tlog.Print(\"Setting up the signal handler\")\n\tsigs := make(chan os.Signal, 1)\n\tsignal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)\n\tgo func() {\n\t\tdefer c.EatPanics()\n\t\tsig := <-sigs\n\t\tlog.Print(\"Received a signal to shutdown: \", sig)\n\t\t// TODO: Gracefully shutdown the HTTP server\n\t\ttw, cn := c.NewTickWatch(), uutils.Nanotime()\n\t\ttw.Name = \"shutdown\"\n\t\ttw.Set(&tw.Start, cn)\n\t\ttw.Set(&tw.DBCheck, cn)\n\t\ttw.Run()\n\t\tn, e := func() (string, error) {\n\t\t\tif e := runHook(\"before_shutdown_tick\"); e != nil {\n\t\t\t\treturn \"before_shutdown_tick \", e\n\t\t\t}\n\t\t\ttw.Set(&tw.StartHook, uutils.Nanotime())\n\t\t\tlog.Print(\"Running shutdown tasks\")\n\t\t\tif e := c.Tasks.Shutdown.Run(); e != nil {\n\t\t\t\treturn \"shutdown tasks \", e\n\t\t\t}\n\t\t\ttw.Set(&tw.Tasks, uutils.Nanotime())\n\t\t\tlog.Print(\"Ran shutdown tasks\")\n\t\t\tif e := runHook(\"after_shutdown_tick\"); e != nil {\n\t\t\t\treturn \"after_shutdown_tick \", e\n\t\t\t}\n\t\t\ttw.Set(&tw.EndHook, uutils.Nanotime())\n\t\t\treturn \"\", nil\n\t\t}()\n\t\tif e != nil {\n\t\t\tlog.Print(n+\" err:\", e)\n\t\t}\n\t\ttw.Stop()\n\t\tlog.Print(\"Stopping server\")\n\t\tc.StoppedServer(\"Stopped server\")\n\t}()\n\n\t// Start up the WebSocket ticks\n\tc.WsHub.Start()\n\n\tif false {\n\t\tf, e := os.Create(c.Config.LogDir + \"cpu.prof\")\n\t\tif e != nil {\n\t\t\tlog.Fatal(e)\n\t\t}\n\t\tpprof.StartCPUProfile(f)\n\t}\n\n\t//if profiling {\n\t//\tpprof.StopCPUProfile()\n\t//}\n\tstartServer()\n\targs := <-c.StopServerChan\n\tif false {\n\t\tpprof.StopCPUProfile()\n\t\tf, err := os.Create(c.Config.LogDir + \"mem.prof\")\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\tdefer f.Close()\n\n\t\truntime.GC()\n\t\terr = pprof.WriteHeapProfile(f)\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}\n\t// Why did the server stop?\n\tlog.Fatal(args...)\n}\n\nfunc startServer() {\n\t// We might not need timeouts, if we're behind a reverse-proxy like Nginx\n\tnewServer := func(addr string, h http.Handler) *http.Server {\n\t\tf := func(timeout, dval int) int {\n\t\t\tif timeout == 0 {\n\t\t\t\ttimeout = dval\n\t\t\t} else if timeout == -1 {\n\t\t\t\ttimeout = 0\n\t\t\t}\n\t\t\treturn timeout\n\t\t}\n\t\trtime := f(c.Config.ReadTimeout, 8)\n\t\twtime := f(c.Config.WriteTimeout, 10)\n\t\titime := f(c.Config.IdleTimeout, 120)\n\t\treturn &http.Server{\n\t\t\tAddr:      addr,\n\t\t\tHandler:   h,\n\t\t\tConnState: c.ConnWatch.StateChange,\n\n\t\t\tReadTimeout:  time.Duration(rtime) * time.Second,\n\t\t\tWriteTimeout: time.Duration(wtime) * time.Second,\n\t\t\tIdleTimeout:  time.Duration(itime) * time.Second,\n\n\t\t\tTLSConfig: &tls.Config{\n\t\t\t\tPreferServerCipherSuites: true,\n\t\t\t\tCurvePreferences: []tls.CurveID{\n\t\t\t\t\ttls.CurveP256,\n\t\t\t\t\ttls.X25519,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\t// TODO: Let users run *both* HTTP and HTTPS\n\tlog.Print(\"Initialising the HTTP server\")\n\t/*if c.Dev.QuicPort != 0 {\n\t\tsQuicPort := strconv.Itoa(c.Dev.QuicPort)\n\t\tlog.Print(\"Listening on quic port \" + sQuicPort)\n\t\tgo func() {\n\t\t\tdefer c.EatPanics()\n\t\t\tc.StoppedServer(http3.ListenAndServeQUIC(\":\"+sQuicPort, c.Config.SslFullchain, c.Config.SslPrivkey, router))\n\t\t}()\n\t}*/\n\n\tif !c.Site.EnableSsl {\n\t\tif c.Site.Port == \"\" {\n\t\t\tc.Site.Port = \"80\"\n\t\t}\n\t\tlog.Print(\"Listening on port \" + c.Site.Port)\n\t\tgo func() {\n\t\t\tdefer c.EatPanics()\n\t\t\tc.StoppedServer(newServer(\":\"+c.Site.Port, router).ListenAndServe())\n\t\t}()\n\t\treturn\n\t}\n\n\tif c.Site.Port == \"\" {\n\t\tc.Site.Port = \"443\"\n\t}\n\tif c.Site.Port == \"80\" || c.Site.Port == \"443\" {\n\t\t// We should also run the server on port 80\n\t\t// TODO: Redirect to port 443\n\t\tgo func() {\n\t\t\tdefer c.EatPanics()\n\t\t\tlog.Print(\"Listening on port 80\")\n\t\t\tc.StoppedServer(newServer(\":80\", &HTTPSRedirect{}).ListenAndServe())\n\t\t}()\n\t}\n\tlog.Printf(\"Listening on port %s\", c.Site.Port)\n\tgo func() {\n\t\tdefer c.EatPanics()\n\t\tc.StoppedServer(newServer(\":\"+c.Site.Port, router).ListenAndServeTLS(c.Config.SslFullchain, c.Config.SslPrivkey))\n\t}()\n}\n"
  },
  {
    "path": "migrations/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "misc_test.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/gauth\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\t\"github.com/Azareal/Gosora/routes\"\n\t\"github.com/pkg/errors\"\n)\n\nfunc miscinit(t *testing.T) {\n\tif err := gloinit(); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc recordMustExist(t *testing.T, err error, errmsg string, args ...interface{}) {\n\tif err == ErrNoRows {\n\t\tdebug.PrintStack()\n\t\tt.Errorf(errmsg, args...)\n\t} else if err != nil {\n\t\tdebug.PrintStack()\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc recordMustNotExist(t *testing.T, err error, errmsg string, args ...interface{}) {\n\tif err == nil {\n\t\tdebug.PrintStack()\n\t\tt.Errorf(errmsg, args...)\n\t} else if err != ErrNoRows {\n\t\tdebug.PrintStack()\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestUserStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\n\tvar err error\n\tuc := c.NewMemoryUserCache(c.Config.UserCacheCapacity)\n\tc.Users, err = c.NewDefaultUserStore(uc)\n\texpectNilErr(t, err)\n\tuc.Flush()\n\tuserStoreTest(t, 2)\n\tc.Users, err = c.NewDefaultUserStore(nil)\n\texpectNilErr(t, err)\n\tuserStoreTest(t, 5)\n}\nfunc userStoreTest(t *testing.T, newUserID int) {\n\tuc := c.Users.GetCache()\n\t// Go doesn't have short-circuiting, so this'll allow us to do one liner tests\n\tcacheLength := func(uc c.UserCache) int {\n\t\tif uc == nil {\n\t\t\treturn 0\n\t\t}\n\t\treturn uc.Length()\n\t}\n\tisCacheLengthZero := func(uc c.UserCache) bool {\n\t\treturn cacheLength(uc) == 0\n\t}\n\tex, exf := exp(t), expf(t)\n\texf(isCacheLengthZero(uc), \"The initial ucache length should be zero, not %d\", cacheLength(uc))\n\n\t_, err := c.Users.Get(-1)\n\trecordMustNotExist(t, err, \"UID #-1 shouldn't exist\")\n\texf(isCacheLengthZero(uc), \"We found %d items in the user cache and it's supposed to be empty\", cacheLength(uc))\n\t_, err = c.Users.Get(0)\n\trecordMustNotExist(t, err, \"UID #0 shouldn't exist\")\n\texf(isCacheLengthZero(uc), \"We found %d items in the user cache and it's supposed to be empty\", cacheLength(uc))\n\n\tuser, err := c.Users.Get(1)\n\trecordMustExist(t, err, \"Couldn't find UID #1\")\n\n\texpectW := func(cond, expec bool, prefix, suffix string) {\n\t\tmidfix := \"should not be\"\n\t\tif expec {\n\t\t\tmidfix = \"should be\"\n\t\t}\n\t\tex(cond, prefix+\" \"+midfix+\" \"+suffix)\n\t}\n\n\t// TODO: Add email checks too? Do them separately?\n\texpectUser := func(u *c.User, uid int, name string, group int, super, admin, mod, banned bool) {\n\t\texf(u.ID == uid, \"u.ID should be %d. Got '%d' instead.\", uid, u.ID)\n\t\texf(u.Name == name, \"u.Name should be '%s', not '%s'\", name, u.Name)\n\t\texpectW(u.Group == group, true, u.Name, \"in group\"+strconv.Itoa(group))\n\t\texpectW(u.IsSuperAdmin == super, super, u.Name, \"a super admin\")\n\t\texpectW(u.IsAdmin == admin, admin, u.Name, \"an admin\")\n\t\texpectW(u.IsSuperMod == mod, mod, u.Name, \"a super mod\")\n\t\texpectW(u.IsMod == mod, mod, u.Name, \"a mod\")\n\t\texpectW(u.IsBanned == banned, banned, u.Name, \"banned\")\n\t}\n\texpectUser(user, 1, \"Admin\", 1, true, true, true, false)\n\n\tuser, err = c.Users.GetByName(\"Admin\")\n\trecordMustExist(t, err, \"Couldn't find user 'Admin'\")\n\texpectUser(user, 1, \"Admin\", 1, true, true, true, false)\n\tus, err := c.Users.BulkGetByName([]string{\"Admin\"})\n\trecordMustExist(t, err, \"Couldn't find user 'Admin'\")\n\texf(len(us) == 1, \"len(us) should be 1, not %d\", len(us))\n\texpectUser(us[0], 1, \"Admin\", 1, true, true, true, false)\n\n\t_, err = c.Users.Get(newUserID)\n\trecordMustNotExist(t, err, fmt.Sprintf(\"UID #%d shouldn't exist\", newUserID))\n\n\t// TODO: GetByName tests for newUserID\n\n\tif uc != nil {\n\t\texpectIntToBeX(t, uc.Length(), 1, \"User cache length should be 1, not %d\")\n\t\t_, err = uc.Get(-1)\n\t\trecordMustNotExist(t, err, \"UID #-1 shouldn't exist, even in the cache\")\n\t\t_, err = uc.Get(0)\n\t\trecordMustNotExist(t, err, \"UID #0 shouldn't exist, even in the cache\")\n\t\tuser, err = uc.Get(1)\n\t\trecordMustExist(t, err, \"Couldn't find UID #1 in the cache\")\n\n\t\texf(user.ID == 1, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t\texf(user.Name == \"Admin\", \"user.Name should be 'Admin', not '%s'\", user.Name)\n\n\t\t_, err = uc.Get(newUserID)\n\t\trecordMustNotExist(t, err, \"UID #%d shouldn't exist, even in the cache\", newUserID)\n\t\tuc.Flush()\n\t\texpectIntToBeX(t, uc.Length(), 0, \"User cache length should be 0, not %d\")\n\t}\n\n\t// TODO: Lock onto the specific error type. Is this even possible without sacrificing the detailed information in the error message?\n\tbulkGetMapEmpty := func(id int) {\n\t\tuserList, _ := c.Users.BulkGetMap([]int{id})\n\t\texf(len(userList) == 0, \"The userList length should be 0, not %d\", len(userList))\n\t\texf(isCacheLengthZero(uc), \"User cache length should be 0, not %d\", cacheLength(uc))\n\t}\n\tbulkGetMapEmpty(-1)\n\tbulkGetMapEmpty(0)\n\n\tuserList, _ := c.Users.BulkGetMap([]int{1})\n\texf(len(userList) == 1, \"Returned map should have one result (UID #1), not %d\", len(userList))\n\tuser, ok := userList[1]\n\tif !ok {\n\t\tt.Error(\"We couldn't find UID #1 in the returned map\")\n\t\tt.Error(\"userList\", userList)\n\t\treturn\n\t}\n\texf(user.ID == 1, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\n\tif uc != nil {\n\t\texpectIntToBeX(t, uc.Length(), 1, \"User cache length should be 1, not %d\")\n\t\tuser, err = uc.Get(1)\n\t\trecordMustExist(t, err, \"Couldn't find UID #1 in the cache\")\n\n\t\texf(user.ID == 1, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t\tuc.Flush()\n\t}\n\n\tex(!c.Users.Exists(-1), \"UID #-1 shouldn't exist\")\n\tex(!c.Users.Exists(0), \"UID #0 shouldn't exist\")\n\tex(c.Users.Exists(1), \"UID #1 should exist\")\n\texf(!c.Users.Exists(newUserID), \"UID #%d shouldn't exist\", newUserID)\n\n\texf(isCacheLengthZero(uc), \"User cache length should be 0, not %d\", cacheLength(uc))\n\texpectIntToBeX(t, c.Users.Count(), 1, \"The number of users should be 1, not %d\")\n\tsearchUser := func(name, email string, gid, count int) {\n\t\tf := func(name, email string, gid, count int, m string) {\n\t\t\texpectIntToBeX(t, c.Users.CountSearch(name, email, gid), count, \"The number of users for \"+m+\", not %d\")\n\t\t}\n\t\tf(name, email, 0, count, fmt.Sprintf(\"name '%s' and email '%s' should be %d\", name, email, count))\n\t\tf(name, \"\", 0, count, fmt.Sprintf(\"name '%s' should be %d\", name, count))\n\t\tf(\"\", email, 0, count, fmt.Sprintf(\"email '%s' should be %d\", email, count))\n\n\t\tf2 := func(name, email string, gid, offset int, m string, args ...interface{}) {\n\t\t\tulist, err := c.Users.SearchOffset(name, email, gid, offset, 15)\n\t\t\texpectNilErr(t, err)\n\t\t\texpectIntToBeX(t, len(ulist), count, \"The number of users for \"+fmt.Sprintf(m, args...)+\", not %d\")\n\t\t}\n\t\tf2(name, email, 0, 0, \"name '%s' and email '%s' should be %d\", name, email, count)\n\t\tf2(name, \"\", 0, 0, \"name '%s' should be %d\", name, count)\n\t\tf2(\"\", email, 0, 0, \"email '%s' should be %d\", email, count)\n\n\t\tcount = 0\n\t\tf2(name, email, 0, 10, \"name '%s' and email '%s' should be %d\", name, email, count)\n\t\tf2(name, \"\", 0, 10, \"name '%s' should be %d\", name, count)\n\t\tf2(\"\", email, 0, 10, \"email '%s' should be %d\", email, count)\n\n\t\tf2(name, email, 999, 0, \"name '%s' and email '%s' should be %d\", name, email, 0)\n\t\tf2(name, \"\", 999, 0, \"name '%s' should be %d\", name, 0)\n\t\tf2(\"\", email, 999, 0, \"email '%s' should be %d\", email, 0)\n\n\t\tf2(name, email, 999, 10, \"name '%s' and email '%s' should be %d\", name, email, 0)\n\t\tf2(name, \"\", 999, 10, \"name '%s' should be %d\", name, 0)\n\t\tf2(\"\", email, 999, 10, \"email '%s' should be %d\", email, 0)\n\t}\n\tsearchUser(\"Sam\", \"sam@localhost.loc\", 0, 0)\n\t// TODO: CountSearch gid test\n\n\tawaitingActivation := 5\n\t// TODO: Write tests for the registration validators\n\tuid, err := c.Users.Create(\"Sam\", \"ReallyBadPassword\", \"sam@localhost.loc\", awaitingActivation, false)\n\texpectNilErr(t, err)\n\texf(uid == newUserID, \"The UID of the new user should be %d not %d\", newUserID, uid)\n\texf(c.Users.Exists(newUserID), \"UID #%d should exist\", newUserID)\n\texpectIntToBeX(t, c.Users.Count(), 2, \"The number of users should be 2, not %d\")\n\tsearchUser(\"Sam\", \"sam@localhost.loc\", 0, 1)\n\t// TODO: CountSearch gid test\n\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texpectUser(user, newUserID, \"Sam\", 5, false, false, false, false)\n\n\tif uc != nil {\n\t\texpectIntToBeX(t, uc.Length(), 1, \"User cache length should be 1, not %d\")\n\t\tuser, err = uc.Get(newUserID)\n\t\trecordMustExist(t, err, \"Couldn't find UID #%d in the cache\", newUserID)\n\t\texf(user.ID == newUserID, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t}\n\n\tuserList, _ = c.Users.BulkGetMap([]int{1, uid})\n\texf(len(userList) == 2, \"Returned map should have 2 results, not %d\", len(userList))\n\t// TODO: More tests on userList\n\n\t{\n\t\tuserList, _ := c.Users.BulkGetByName([]string{\"Admin\", \"Sam\"})\n\t\texf(len(userList) == 2, \"Returned list should have 2 results, not %d\", len(userList))\n\t}\n\n\tif uc != nil {\n\t\texpectIntToBeX(t, uc.Length(), 2, \"User cache length should be 2, not %d\")\n\t\tuser, err = uc.Get(1)\n\t\trecordMustExist(t, err, \"Couldn't find UID #%d in the cache\", 1)\n\t\texf(user.ID == 1, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t\tuser, err = uc.Get(newUserID)\n\t\trecordMustExist(t, err, \"Couldn't find UID #%d in the cache\", newUserID)\n\t\texf(user.ID == newUserID, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t\tuc.Flush()\n\t}\n\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texpectUser(user, newUserID, \"Sam\", 5, false, false, false, false)\n\n\tif uc != nil {\n\t\texpectIntToBeX(t, uc.Length(), 1, \"User cache length should be 1, not %d\")\n\t\tuser, err = uc.Get(newUserID)\n\t\trecordMustExist(t, err, \"Couldn't find UID #%d in the cache\", newUserID)\n\t\texf(user.ID == newUserID, \"user.ID does not match the requested UID. Got '%d' instead.\", user.ID)\n\t}\n\n\texpectNilErr(t, user.Activate())\n\texpectIntToBeX(t, user.Group, 5, \"Sam should still be in group 5 in this copy\")\n\n\t// ? - What if we change the caching mechanism so it isn't hard purged and reloaded? We'll deal with that when we come to it, but for now, this is a sign of a cache bug\n\tafterUserFlush := func(uid int) {\n\t\tif uc != nil {\n\t\t\texpectIntToBeX(t, uc.Length(), 0, \"User cache length should be 0, not %d\")\n\t\t\t_, err = uc.Get(uid)\n\t\t\trecordMustNotExist(t, err, \"UID #%d shouldn't be in the cache\", uid)\n\t\t}\n\t}\n\tafterUserFlush(newUserID)\n\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texpectUser(user, newUserID, \"Sam\", c.Config.DefaultGroup, false, false, false, false)\n\n\t// Permanent ban\n\tduration, _ := time.ParseDuration(\"0\")\n\n\t// TODO: Attempt a double ban, double activation, and double unban\n\texpectNilErr(t, user.Ban(duration, 1))\n\texf(user.Group == c.Config.DefaultGroup, \"Sam should be in group %d, not %d\", c.Config.DefaultGroup, user.Group)\n\tafterUserFlush(newUserID)\n\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texpectUser(user, newUserID, \"Sam\", c.BanGroup, false, false, false, true)\n\n\t// TODO: Do tests against the scheduled updates table and the task system to make sure the ban exists there and gets revoked when it should\n\n\texpectNilErr(t, user.Unban())\n\texpectIntToBeX(t, user.Group, c.BanGroup, \"Sam should still be in the ban group in this copy\")\n\tafterUserFlush(newUserID)\n\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texpectUser(user, newUserID, \"Sam\", c.Config.DefaultGroup, false, false, false, false)\n\n\treportsForumID := 1 // TODO: Use the constant in common?\n\tgeneralForumID := 2\n\tdummyResponseRecorder := httptest.NewRecorder()\n\tbytesBuffer := bytes.NewBuffer([]byte(\"\"))\n\tdummyRequest1 := httptest.NewRequest(\"\", \"/forum/\"+strconv.Itoa(reportsForumID), bytesBuffer)\n\tdummyRequest2 := httptest.NewRequest(\"\", \"/forum/\"+strconv.Itoa(generalForumID), bytesBuffer)\n\tvar user2 *c.User\n\n\tchangeGroupTest := func(oldGroup, newGroup int) {\n\t\texpectNilErr(t, user.ChangeGroup(newGroup))\n\t\t// ! I don't think ChangeGroup should be changing the value of user... Investigate this.\n\t\tex(oldGroup == user.Group, \"Someone's mutated this pointer elsewhere\")\n\n\t\tuser, err = c.Users.Get(newUserID)\n\t\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\t\tuser2 = c.BlankUser()\n\t\t*user2 = *user\n\t}\n\n\tchangeGroupTest2 := func(rank string, firstShouldBe, secondShouldBe bool) {\n\t\thead, e := c.UserCheck(dummyResponseRecorder, dummyRequest1, user)\n\t\tif e != nil {\n\t\t\tt.Fatal(e)\n\t\t}\n\t\thead2, e := c.UserCheck(dummyResponseRecorder, dummyRequest2, user2)\n\t\tif e != nil {\n\t\t\tt.Fatal(e)\n\t\t}\n\t\tferr := c.ForumUserCheck(head, dummyResponseRecorder, dummyRequest1, user, reportsForumID)\n\t\tex(ferr == nil, \"There shouldn't be any errors in forumUserCheck\")\n\t\tex(user.Perms.ViewTopic == firstShouldBe, rank+\" should be able to access the reports forum\")\n\t\tferr = c.ForumUserCheck(head2, dummyResponseRecorder, dummyRequest2, user2, generalForumID)\n\t\tex(ferr == nil, \"There shouldn't be any errors in forumUserCheck\")\n\t\tex(user2.Perms.ViewTopic == secondShouldBe, \"Sam should be able to access the general forum\")\n\t}\n\n\tchangeGroupTest(c.Config.DefaultGroup, 1)\n\texpectUser(user, newUserID, \"Sam\", 1, false, true, true, false)\n\tchangeGroupTest2(\"Admins\", true, true)\n\n\tchangeGroupTest(1, 2)\n\texpectUser(user, newUserID, \"Sam\", 2, false, false, true, false)\n\tchangeGroupTest2(\"Mods\", true, true)\n\n\tchangeGroupTest(2, 3)\n\texpectUser(user, newUserID, \"Sam\", 3, false, false, false, false)\n\tchangeGroupTest2(\"Members\", false, true)\n\tex(user.Perms.ViewTopic != user2.Perms.ViewTopic, \"user.Perms.ViewTopic and user2.Perms.ViewTopic should never match\")\n\n\tchangeGroupTest(3, 4)\n\texpectUser(user, newUserID, \"Sam\", 4, false, false, false, true)\n\tchangeGroupTest2(\"Members\", false, true)\n\n\tchangeGroupTest(4, 5)\n\texpectUser(user, newUserID, \"Sam\", 5, false, false, false, false)\n\tchangeGroupTest2(\"Members\", false, true)\n\n\tchangeGroupTest(5, 6)\n\texpectUser(user, newUserID, \"Sam\", 6, false, false, false, false)\n\tchangeGroupTest2(\"Members\", false, true)\n\n\terr = user.ChangeGroup(c.Config.DefaultGroup)\n\texpectNilErr(t, err)\n\tex(user.Group == 6, \"Someone's mutated this pointer elsewhere\")\n\n\texf(user.LastIP == \"\", \"user.LastIP should be blank not %s\", user.LastIP)\n\texpectNilErr(t, user.UpdateIP(\"::1\"))\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texf(user.LastIP == \"::1\", \"user.LastIP should be %s not %s\", \"::1\", user.LastIP)\n\texpectNilErr(t, c.Users.ClearLastIPs())\n\texpectNilErr(t, c.Users.Reload(newUserID))\n\tuser, err = c.Users.Get(newUserID)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID)\n\texf(user.LastIP == \"\", \"user.LastIP should be blank not %s\", user.LastIP)\n\n\texpectNilErr(t, user.Delete())\n\texf(!c.Users.Exists(newUserID), \"UID #%d should no longer exist\", newUserID)\n\tafterUserFlush(newUserID)\n\texpectIntToBeX(t, c.Users.Count(), 1, \"The number of users should be 1, not %d\")\n\tsearchUser(\"Sam\", \"sam@localhost.loc\", 0, 0)\n\t// TODO: CountSearch gid test\n\n\t_, err = c.Users.Get(newUserID)\n\trecordMustNotExist(t, err, \"UID #%d shouldn't exist\", newUserID)\n\n\t// And a unicode test, even though I doubt it'll fail\n\tuid, err = c.Users.Create(\"サム\", \"😀😀😀\", \"sam@localhost.loc\", awaitingActivation, false)\n\texpectNilErr(t, err)\n\texf(uid == newUserID+1, \"The UID of the new user should be %d\", newUserID+1)\n\texf(c.Users.Exists(newUserID+1), \"UID #%d should exist\", newUserID+1)\n\n\tuser, err = c.Users.Get(newUserID + 1)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID+1)\n\texpectUser(user, newUserID+1, \"サム\", 5, false, false, false, false)\n\n\texpectNilErr(t, user.Delete())\n\texf(!c.Users.Exists(newUserID+1), \"UID #%d should no longer exist\", newUserID+1)\n\n\t// MySQL utf8mb4 username test\n\tuid, err = c.Users.Create(\"😀😀😀\", \"😀😀😀\", \"sam@localhost.loc\", awaitingActivation, false)\n\texpectNilErr(t, err)\n\texf(uid == newUserID+2, \"The UID of the new user should be %d\", newUserID+2)\n\texf(c.Users.Exists(newUserID+2), \"UID #%d should exist\", newUserID+2)\n\n\tuser, err = c.Users.Get(newUserID + 2)\n\trecordMustExist(t, err, \"Couldn't find UID #%d\", newUserID+1)\n\texpectUser(user, newUserID+2, \"😀😀😀\", 5, false, false, false, false)\n\n\texpectNilErr(t, user.Delete())\n\texf(!c.Users.Exists(newUserID+2), \"UID #%d should no longer exist\", newUserID+2)\n\n\t// TODO: Add unicode login tests somewhere? Probably with the rest of the auth tests\n\t// TODO: Add tests for the Cache* methods\n}\n\n// TODO: Add an error message to this?\nfunc expectNilErr(t *testing.T, item error) {\n\tif item != nil {\n\t\tdebug.PrintStack()\n\t\tt.Fatal(item)\n\t}\n}\n\nfunc expectIntToBeX(t *testing.T, item, expect int, errmsg string) {\n\tif item != expect {\n\t\tdebug.PrintStack()\n\t\tt.Fatalf(errmsg, item)\n\t}\n}\n\nfunc expect(t *testing.T, item bool, errmsg string) {\n\tif !item {\n\t\tdebug.PrintStack()\n\t\tt.Fatal(errmsg)\n\t}\n}\n\nfunc expectf(t *testing.T, item bool, errmsg string, args ...interface{}) {\n\tif !item {\n\t\tdebug.PrintStack()\n\t\tt.Fatalf(errmsg, args...)\n\t}\n}\n\nfunc exp(t *testing.T) func(bool, string) {\n\treturn func(val bool, errmsg string) {\n\t\tif !val {\n\t\t\tdebug.PrintStack()\n\t\t\tt.Fatal(errmsg)\n\t\t}\n\t}\n}\n\nfunc expf(t *testing.T) func(bool, string, ...interface{}) {\n\treturn func(val bool, errmsg string, params ...interface{}) {\n\t\tif !val {\n\t\t\tdebug.PrintStack()\n\t\t\tt.Fatalf(errmsg, params...)\n\t\t}\n\t}\n}\n\nfunc TestPermsMiddleware(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\n\tdummyResponseRecorder := httptest.NewRecorder()\n\tbytesBuffer := bytes.NewBuffer([]byte(\"\"))\n\tdummyRequest := httptest.NewRequest(\"\", \"/forum/1\", bytesBuffer)\n\tuser := c.BlankUser()\n\tex := exp(t)\n\n\tf := func(ff func(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError) bool {\n\t\tferr := ff(dummyResponseRecorder, dummyRequest, user)\n\t\treturn ferr == nil\n\t}\n\n\tex(!f(c.SuperModOnly), \"Blank users shouldn't be supermods\")\n\tuser.IsSuperMod = false\n\tex(!f(c.SuperModOnly), \"Non-supermods shouldn't be allowed through supermod gates\")\n\tuser.IsSuperMod = true\n\tex(f(c.SuperModOnly), \"Supermods should be allowed through supermod gates\")\n\n\t// TODO: Loop over the Control Panel routes and make sure only supermods can get in\n\n\tuser = c.BlankUser()\n\n\tex(!f(c.MemberOnly), \"Blank users shouldn't be considered loggedin\")\n\tuser.Loggedin = false\n\tex(!f(c.MemberOnly), \"Guests shouldn't be able to access member areas\")\n\tuser.Loggedin = true\n\tex(f(c.MemberOnly), \"Logged in users should be able to access member areas\")\n\n\t// TODO: Loop over the /user/ routes and make sure only members can access the ones other than /user/username\n\n\tuser = c.BlankUser()\n\n\tex(!f(c.AdminOnly), \"Blank users shouldn't be considered admins\")\n\tuser.IsAdmin = false\n\tex(!f(c.AdminOnly), \"Non-admins shouldn't be able to access admin areas\")\n\tuser.IsAdmin = true\n\tex(f(c.AdminOnly), \"Admins should be able to access admin areas\")\n\n\tuser = c.BlankUser()\n\n\tex(!f(c.SuperAdminOnly), \"Blank users shouldn't be considered super admins\")\n\tuser.IsSuperAdmin = false\n\tex(!f(c.SuperAdminOnly), \"Non-super admins shouldn't be allowed through the super admin gate\")\n\tuser.IsSuperAdmin = true\n\tex(f(c.SuperAdminOnly), \"Super admins should be allowed through super admin gates\")\n\n\t// TODO: Make sure only super admins can access the backups route\n\n\t//dummyResponseRecorder = httptest.NewRecorder()\n\t//bytesBuffer = bytes.NewBuffer([]byte(\"\"))\n\t//dummyRequest = httptest.NewRequest(\"\", \"/panel/backups/\", bytesBuffer)\n}\n\nfunc TestTopicStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\n\tvar err error\n\ttcache := c.NewMemoryTopicCache(c.Config.TopicCacheCapacity)\n\tc.Topics, err = c.NewDefaultTopicStore(tcache)\n\texpectNilErr(t, err)\n\tc.Config.DisablePostIP = false\n\ttopicStoreTest(t, 2, \"::1\")\n\tc.Config.DisablePostIP = true\n\ttopicStoreTest(t, 3, \"\")\n\n\tc.Topics, err = c.NewDefaultTopicStore(nil)\n\texpectNilErr(t, err)\n\tc.Config.DisablePostIP = false\n\ttopicStoreTest(t, 4, \"::1\")\n\tc.Config.DisablePostIP = true\n\ttopicStoreTest(t, 5, \"\")\n}\nfunc topicStoreTest(t *testing.T, newID int, ip string) {\n\tvar topic *c.Topic\n\tvar err error\n\tex, exf := exp(t), expf(t)\n\n\t_, err = c.Topics.Get(-1)\n\trecordMustNotExist(t, err, \"TID #-1 shouldn't exist\")\n\t_, err = c.Topics.Get(0)\n\trecordMustNotExist(t, err, \"TID #0 shouldn't exist\")\n\n\ttopic, err = c.Topics.Get(1)\n\trecordMustExist(t, err, \"Couldn't find TID #1\")\n\texf(topic.ID == 1, \"topic.ID does not match the requested TID. Got '%d' instead.\", topic.ID)\n\n\t// TODO: Add BulkGetMap() to the TopicStore\n\n\tex(!c.Topics.Exists(-1), \"TID #-1 shouldn't exist\")\n\tex(!c.Topics.Exists(0), \"TID #0 shouldn't exist\")\n\tex(c.Topics.Exists(1), \"TID #1 should exist\")\n\n\tcount := c.Topics.Count()\n\texf(count == 1, \"Global count for topics should be 1, not %d\", count)\n\n\t//Create(fid int, topicName string, content string, uid int, ip string) (tid int, err error)\n\ttid, err := c.Topics.Create(2, \"Test Topic\", \"Topic Content\", 1, ip)\n\texpectNilErr(t, err)\n\texf(tid == newID, \"TID for the new topic should be %d, not %d\", newID, tid)\n\texf(c.Topics.Exists(newID), \"TID #%d should exist\", newID)\n\n\tcount = c.Topics.Count()\n\texf(count == 2, \"Global count for topics should be 2, not %d\", count)\n\n\tiFrag := func(cond bool) string {\n\t\tif !cond {\n\t\t\treturn \"n't\"\n\t\t}\n\t\treturn \"\"\n\t}\n\n\ttestTopic := func(tid int, title, content string, createdBy int, ip string, parentID int, isClosed, sticky bool) {\n\t\ttopic, err = c.Topics.Get(tid)\n\t\trecordMustExist(t, err, fmt.Sprintf(\"Couldn't find TID #%d\", tid))\n\t\texf(topic.ID == tid, \"topic.ID does not match the requested TID. Got '%d' instead.\", topic.ID)\n\t\texf(topic.GetID() == tid, \"topic.ID does not match the requested TID. Got '%d' instead.\", topic.GetID())\n\t\texf(topic.Title == title, \"The topic's name should be '%s', not %s\", title, topic.Title)\n\t\texf(topic.Content == content, \"The topic's body should be '%s', not %s\", content, topic.Content)\n\t\texf(topic.CreatedBy == createdBy, \"The topic's creator should be %d, not %d\", createdBy, topic.CreatedBy)\n\t\texf(topic.IP == ip, \"The topic's IP should be '%s', not %s\", ip, topic.IP)\n\t\texf(topic.ParentID == parentID, \"The topic's parent forum should be %d, not %d\", parentID, topic.ParentID)\n\t\texf(topic.IsClosed == isClosed, \"This topic should%s be locked\", iFrag(topic.IsClosed))\n\t\texf(topic.Sticky == sticky, \"This topic should%s be sticky\", iFrag(topic.Sticky))\n\t\texf(topic.GetTable() == \"topics\", \"The topic's table should be 'topics', not %s\", topic.GetTable())\n\t}\n\n\ttc := c.Topics.GetCache()\n\tshouldNotBeIn := func(tid int) {\n\t\tif tc != nil {\n\t\t\t_, err = tc.Get(tid)\n\t\t\trecordMustNotExist(t, err, \"Topic cache should be empty\")\n\t\t}\n\t}\n\tif tc != nil {\n\t\t_, err = tc.Get(newID)\n\t\texpectNilErr(t, err)\n\t}\n\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 2, false, false)\n\n\texpectNilErr(t, topic.Lock())\n\tshouldNotBeIn(newID)\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 2, true, false)\n\n\texpectNilErr(t, topic.Unlock())\n\tshouldNotBeIn(newID)\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 2, false, false)\n\n\texpectNilErr(t, topic.Stick())\n\tshouldNotBeIn(newID)\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 2, false, true)\n\n\texpectNilErr(t, topic.Unstick())\n\tshouldNotBeIn(newID)\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 2, false, false)\n\n\texpectNilErr(t, topic.MoveTo(1))\n\tshouldNotBeIn(newID)\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, ip, 1, false, false)\n\t// TODO: Add more tests for more *Topic methods\n\n\texpectNilErr(t, c.Topics.ClearIPs())\n\texpectNilErr(t, c.Topics.Reload(topic.ID))\n\ttestTopic(newID, \"Test Topic\", \"Topic Content\", 1, \"\", 1, false, false)\n\t// TODO: Add more tests for more *Topic methods\n\n\texpectNilErr(t, topic.Delete())\n\tshouldNotBeIn(newID)\n\n\t_, err = c.Topics.Get(newID)\n\trecordMustNotExist(t, err, fmt.Sprintf(\"TID #%d shouldn't exist\", newID))\n\texf(!c.Topics.Exists(newID), \"TID #%d shouldn't exist\", newID)\n\n\t// TODO: Test topic creation and retrieving that created topic plus reload and inspecting the cache\n}\n\nfunc TestForumStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\t// TODO: Test ForumStore.Reload\n\n\tfcache, ok := c.Forums.(c.ForumCache)\n\tex(ok, \"Unable to cast ForumStore to ForumCache\")\n\tex(c.Forums.Count() == 2, \"The forumstore global count should be 2\")\n\tex(fcache.Length() == 2, \"The forum cache length should be 2\")\n\n\t_, err := c.Forums.Get(-1)\n\trecordMustNotExist(t, err, \"FID #-1 shouldn't exist\")\n\t_, err = c.Forums.Get(0)\n\trecordMustNotExist(t, err, \"FID #0 shouldn't exist\")\n\n\ttestForum := func(f *c.Forum, fid int, name string, active bool, desc string) {\n\t\texf(f.ID == fid, \"forum.ID should be %d, not %d.\", fid, f.ID)\n\t\t// TODO: Check the preset and forum permissions\n\t\texf(f.Name == name, \"forum.Name should be %s, not %s\", name, f.Name)\n\t\tstr := \"\"\n\t\tif !active {\n\t\t\tstr = \"n't\"\n\t\t}\n\t\texf(f.Active == active, \"The reports forum should%s be active\", str)\n\t\texf(f.Desc == desc, \"forum.Desc should be '%s' not '%s'\", desc, f.Desc)\n\t}\n\n\tforum, err := c.Forums.Get(1)\n\trecordMustExist(t, err, \"Couldn't find FID #1\")\n\texpectDesc := \"All the reports go here\"\n\ttestForum(forum, 1, \"Reports\", false, expectDesc)\n\tforum, err = c.Forums.BypassGet(1)\n\trecordMustExist(t, err, \"Couldn't find FID #1\")\n\n\tforum, err = c.Forums.Get(2)\n\trecordMustExist(t, err, \"Couldn't find FID #2\")\n\tforum, err = c.Forums.BypassGet(2)\n\trecordMustExist(t, err, \"Couldn't find FID #2\")\n\texpectDesc = \"A place for general discussions which don't fit elsewhere\"\n\ttestForum(forum, 2, \"General\", true, expectDesc)\n\n\t// Forum reload test, kind of hacky but gets the job done\n\t/*\n\t\tCacheGet(id int) (*Forum, error)\n\t\tCacheSet(forum *Forum) error\n\t*/\n\tex(ok, \"ForumCache should be available\")\n\tforum.Name = \"nanana\"\n\tfcache.CacheSet(forum)\n\tforum, err = c.Forums.Get(2)\n\trecordMustExist(t, err, \"Couldn't find FID #2\")\n\texf(forum.Name == \"nanana\", \"The faux name should be nanana not %s\", forum.Name)\n\texpectNilErr(t, c.Forums.Reload(2))\n\tforum, err = c.Forums.Get(2)\n\trecordMustExist(t, err, \"Couldn't find FID #2\")\n\texf(forum.Name == \"General\", \"The proper name should be 2 not %s\", forum.Name)\n\n\tex(!c.Forums.Exists(-1), \"FID #-1 shouldn't exist\")\n\tex(!c.Forums.Exists(0), \"FID #0 shouldn't exist\")\n\tex(c.Forums.Exists(1), \"FID #1 should exist\")\n\tex(c.Forums.Exists(2), \"FID #2 should exist\")\n\tex(!c.Forums.Exists(3), \"FID #3 shouldn't exist\")\n\n\t_, err = c.Forums.Create(\"\", \"\", true, \"all\")\n\tex(err != nil, \"A forum shouldn't be successfully created, if it has a blank name\")\n\n\tfid, err := c.Forums.Create(\"Test Forum\", \"\", true, \"all\")\n\texpectNilErr(t, err)\n\tex(fid == 3, \"The first forum we create should have an ID of 3\")\n\tex(c.Forums.Exists(3), \"FID #2 should exist\")\n\n\tex(c.Forums.Count() == 3, \"The forumstore global count should be 3\")\n\tex(fcache.Length() == 3, \"The forum cache length should be 3\")\n\n\tforum, err = c.Forums.Get(3)\n\trecordMustExist(t, err, \"Couldn't find FID #3\")\n\tforum, err = c.Forums.BypassGet(3)\n\trecordMustExist(t, err, \"Couldn't find FID #3\")\n\ttestForum(forum, 3, \"Test Forum\", true, \"\")\n\n\t// TODO: More forum creation tests\n\n\texpectNilErr(t, c.Forums.Delete(3))\n\tex(forum.ID == 3, \"forum pointer shenanigans\")\n\tex(c.Forums.Count() == 2, \"The forumstore global count should be 2\")\n\tex(fcache.Length() == 2, \"The forum cache length should be 2\")\n\tex(!c.Forums.Exists(3), \"FID #3 shouldn't exist after being deleted\")\n\t_, err = c.Forums.Get(3)\n\trecordMustNotExist(t, err, \"FID #3 shouldn't exist after being deleted\")\n\t_, err = c.Forums.BypassGet(3)\n\trecordMustNotExist(t, err, \"FID #3 shouldn't exist after being deleted\")\n\n\tex(c.Forums.Delete(c.ReportForumID) != nil, \"The reports forum shouldn't be deletable\")\n\texf(c.Forums.Exists(c.ReportForumID), \"FID #%d should still exist\", c.ReportForumID)\n\t_, err = c.Forums.Get(c.ReportForumID)\n\texf(err == nil, \"FID #%d should still exist\", c.ReportForumID)\n\t_, err = c.Forums.BypassGet(c.ReportForumID)\n\texf(err == nil, \"FID #%d should still exist\", c.ReportForumID)\n\n\teforums := map[int]bool{1: true, 2: true}\n\t{\n\t\tforums, err := c.Forums.GetAll()\n\t\texpectNilErr(t, err)\n\t\tfound := make(map[int]*c.Forum)\n\t\tfor _, forum := range forums {\n\t\t\t_, ok := eforums[forum.ID]\n\t\t\texf(ok, \"unknown forum #%d in forums\", forum.ID)\n\t\t\tfound[forum.ID] = forum\n\t\t}\n\t\tfor fid, _ := range eforums {\n\t\t\t_, ok := found[fid]\n\t\t\texf(ok, \"unable to find expected forum #%d in forums\", fid)\n\t\t}\n\t}\n\n\t{\n\t\tfids, err := c.Forums.GetAllIDs()\n\t\texpectNilErr(t, err)\n\t\tfound := make(map[int]bool)\n\t\tfor _, fid := range fids {\n\t\t\t_, ok := eforums[fid]\n\t\t\texf(ok, \"unknown fid #%d in fids\", fid)\n\t\t\tfound[fid] = true\n\t\t}\n\t\tfor fid, _ := range eforums {\n\t\t\t_, ok := found[fid]\n\t\t\texf(ok, \"unable to find expected fid #%d in fids\", fid)\n\t\t}\n\t}\n\n\tvforums := map[int]bool{2: true}\n\t{\n\t\tforums, err := c.Forums.GetAllVisible()\n\t\texpectNilErr(t, err)\n\t\tfound := make(map[int]*c.Forum)\n\t\tfor _, forum := range forums {\n\t\t\t_, ok := vforums[forum.ID]\n\t\t\texf(ok, \"unknown forum #%d in forums\", forum.ID)\n\t\t\tfound[forum.ID] = forum\n\t\t}\n\t\tfor fid, _ := range vforums {\n\t\t\t_, ok := found[fid]\n\t\t\texf(ok, \"unable to find expected forum #%d in forums\", fid)\n\t\t}\n\t}\n\n\t{\n\t\tfids, err := c.Forums.GetAllVisibleIDs()\n\t\texpectNilErr(t, err)\n\t\tfound := make(map[int]bool)\n\t\tfor _, fid := range fids {\n\t\t\t_, ok := vforums[fid]\n\t\t\texf(ok, \"unknown fid #%d in fids\", fid)\n\t\t\tfound[fid] = true\n\t\t}\n\t\tfor fid, _ := range vforums {\n\t\t\t_, ok := found[fid]\n\t\t\texf(ok, \"unable to find expected fid #%d in fids\", fid)\n\t\t}\n\t}\n\n\tforum, err = c.Forums.Get(2)\n\texpectNilErr(t, err)\n\tprevTopicCount := forum.TopicCount\n\ttid, err := c.Topics.Create(forum.ID, \"Forum Meta Test\", \"Forum Meta Test\", 1, \"\")\n\texpectNilErr(t, err)\n\tforum, err = c.Forums.Get(2)\n\texpectNilErr(t, err)\n\texf(forum.TopicCount == (prevTopicCount+1), \"forum.TopicCount should be %d not %d\", prevTopicCount+1, forum.TopicCount)\n\texf(forum.LastTopicID == tid, \"forum.LastTopicID should be %d not %d\", tid, forum.LastTopicID)\n\texf(forum.LastPage == 1, \"forum.LastPage should be %d not %d\", 1, forum.LastPage)\n\n\t// TODO: Test topic creation and forum topic metadata\n\n\t// TODO: Test forum update\n\t// TODO: Other forumstore stuff and forumcache?\n}\n\n// TODO: Implement this\nfunc TestForumPermsStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex := exp(t)\n\n\tf := func(fid, gid int, msg string, inv ...bool) {\n\t\tfp, err := c.FPStore.Get(fid, gid)\n\t\tif err == ErrNoRows {\n\t\t\tfp = c.BlankForumPerms()\n\t\t} else {\n\t\t\texpectNilErr(t, err)\n\t\t}\n\t\tvt := fp.ViewTopic\n\t\tif len(inv) > 0 && inv[0] == true {\n\t\t\tvt = !vt\n\t\t}\n\t\tex(vt, msg)\n\t}\n\n\t// TODO: Test reporting\n\tinitialState := func() {\n\t\tf(1, 1, \"admins should be able to see reports\")\n\t\tf(1, 2, \"mods should be able to see reports\")\n\t\tf(1, 3, \"members should not be able to see reports\", true)\n\t\tf(1, 4, \"banned users should not be able to see reports\", true)\n\t\tf(2, 1, \"admins should be able to see general\")\n\t\tf(2, 3, \"members should be able to see general\")\n\t\tf(2, 6, \"guests should be able to see general\")\n\t}\n\tinitialState()\n\n\texpectNilErr(t, c.FPStore.Reload(1))\n\tinitialState()\n\texpectNilErr(t, c.FPStore.Reload(2))\n\tinitialState()\n\n\tgid, err := c.Groups.Create(\"FP Test\", \"FP Test\", false, false, false)\n\texpectNilErr(t, err)\n\tfid, err := c.Forums.Create(\"FP Test\", \"FP Test\", true, \"\")\n\texpectNilErr(t, err)\n\n\tu := c.GuestUser.Copy()\n\trt := func(gid, fid int, shouldSucceed bool) {\n\t\tw := httptest.NewRecorder()\n\t\tbytesBuffer := bytes.NewBuffer([]byte(\"\"))\n\t\tsfid := strconv.Itoa(fid)\n\t\treq := httptest.NewRequest(\"\", \"/forum/\"+sfid, bytesBuffer)\n\t\tu.Group = gid\n\t\th, err := c.UserCheck(w, req, &u)\n\t\texpectNilErr(t, err)\n\t\trerr := routes.ViewForum(w, req, &u, h, sfid)\n\t\tif shouldSucceed {\n\t\t\tex(rerr == nil, \"ViewForum should succeed\")\n\t\t} else {\n\t\t\tex(rerr != nil, \"ViewForum should not succeed\")\n\t\t}\n\t}\n\trt(1, fid, false)\n\trt(2, fid, false)\n\trt(3, fid, false)\n\trt(4, fid, false)\n\trt(gid, fid, false)\n\n\tfp, err := c.FPStore.GetCopy(fid, gid)\n\tif err == sql.ErrNoRows {\n\t\tfp = *c.BlankForumPerms()\n\t} else if err != nil {\n\t\texpectNilErr(t, err)\n\t}\n\tfmt.Printf(\"fp: %+v\\n\", fp)\n\n\tf(fid, 1, \"admins should not be able to see fp test\", true)\n\tf(fid, 2, \"mods should not be able to see fp test\", true)\n\tf(fid, 3, \"members should not be able to see fp test\", true)\n\tf(fid, 4, \"banned users should not be able to see fp test\", true)\n\tf(fid, gid, \"fp test should not be able to see fp test\", true)\n\n\tfp.ViewTopic = true\n\n\tforum, err := c.Forums.Get(fid)\n\texpectNilErr(t, err)\n\texpectNilErr(t, forum.SetPerms(&fp, \"custom\", gid))\n\n\trt(1, fid, false)\n\trt(2, fid, false)\n\trt(3, fid, false)\n\trt(4, fid, false)\n\trt(gid, fid, true)\n\n\tfp, err = c.FPStore.GetCopy(fid, gid)\n\tif err == sql.ErrNoRows {\n\t\tfp = *c.BlankForumPerms()\n\t} else if err != nil {\n\t\texpectNilErr(t, err)\n\t}\n\n\tf(fid, 1, \"admins should not be able to see fp test\", true)\n\tf(fid, 2, \"mods should not be able to see fp test\", true)\n\tf(fid, 3, \"members should not be able to see fp test\", true)\n\tf(fid, 4, \"banned users should not be able to see fp test\", true)\n\tf(fid, gid, \"fp test should be able to see fp test\")\n\n\texpectNilErr(t, c.Forums.Delete(fid))\n\trt(1, fid, false)\n\trt(2, fid, false)\n\trt(3, fid, false)\n\trt(4, fid, false)\n\trt(gid, fid, false)\n\n\t// TODO: Test changing forum permissions\n}\n\n// TODO: Test the group permissions\n// TODO: Test group.CanSee for forum presets + group perms\nfunc TestGroupStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\n\t_, err := c.Groups.Get(-1)\n\trecordMustNotExist(t, err, \"GID #-1 shouldn't exist\")\n\n\t// TODO: Refactor the group store to remove GID #0\n\tg, err := c.Groups.Get(0)\n\trecordMustExist(t, err, \"Couldn't find GID #0\")\n\texf(g.ID == 0, \"g.ID doesn't not match the requested GID. Got '%d' instead.\", g.ID)\n\texf(g.Name == \"Unknown\", \"GID #0 is named '%s' and not 'Unknown'\", g.Name)\n\n\tg, err = c.Groups.Get(1)\n\trecordMustExist(t, err, \"Couldn't find GID #1\")\n\texf(g.ID == 1, \"g.ID doesn't not match the requested GID. Got '%d' instead.'\", g.ID)\n\tex(len(g.CanSee) > 0, \"g.CanSee should not be zero\")\n\n\tex(!c.Groups.Exists(-1), \"GID #-1 shouldn't exist\")\n\t// 0 aka Unknown, for system posts and other oddities\n\tex(c.Groups.Exists(0), \"GID #0 should exist\")\n\tex(c.Groups.Exists(1), \"GID #1 should exist\")\n\n\tisAdmin, isMod, isBanned := true, true, false\n\tgid, err := c.Groups.Create(\"Testing\", \"Test\", isAdmin, isMod, isBanned)\n\texpectNilErr(t, err)\n\tex(c.Groups.Exists(gid), \"The group we just made doesn't exist\")\n\n\tff := func(i bool) string {\n\t\tif !i {\n\t\t\treturn \"n't\"\n\t\t}\n\t\treturn \"\"\n\t}\n\tf := func(gid int, isBanned, isMod, isAdmin bool) {\n\t\tex(g.ID == gid, \"The group ID should match the requested ID\")\n\t\texf(g.IsAdmin == isAdmin, \"This should%s be an admin group\", ff(isAdmin))\n\t\texf(g.IsMod == isMod, \"This should%s be a mod group\", ff(isMod))\n\t\texf(g.IsBanned == isBanned, \"This should%s be a ban group\", ff(isBanned))\n\t}\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, false, true, true)\n\tex(len(g.CanSee) == 0, \"g.CanSee should be empty\")\n\n\tisAdmin, isMod, isBanned = false, true, true\n\tgid, err = c.Groups.Create(\"Testing 2\", \"Test\", isAdmin, isMod, isBanned)\n\texpectNilErr(t, err)\n\tex(c.Groups.Exists(gid), \"The group we just made doesn't exist\")\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, false, true, false)\n\n\t// TODO: Make sure this pointer doesn't change once we refactor the group store to stop updating the pointer\n\texpectNilErr(t, g.ChangeRank(false, false, true))\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, true, false, false)\n\n\texpectNilErr(t, g.ChangeRank(true, true, true))\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, false, true, true)\n\tex(len(g.CanSee) == 0, \"len(g.CanSee) should be 0\")\n\n\texpectNilErr(t, g.ChangeRank(false, true, true))\n\n\tforum, err := c.Forums.Get(2)\n\texpectNilErr(t, err)\n\tforumPerms, err := c.FPStore.GetCopy(2, gid)\n\tif err == sql.ErrNoRows {\n\t\tforumPerms = *c.BlankForumPerms()\n\t} else if err != nil {\n\t\texpectNilErr(t, err)\n\t}\n\tforumPerms.ViewTopic = true\n\n\terr = forum.SetPerms(&forumPerms, \"custom\", gid)\n\texpectNilErr(t, err)\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, false, true, false)\n\tex(g.CanSee != nil, \"g.CanSee must not be nil\")\n\tex(len(g.CanSee) == 1, \"len(g.CanSee) should not be one\")\n\tex(g.CanSee[0] == 2, \"g.CanSee[0] should be 2\")\n\tcanSee := g.CanSee\n\n\t// Make sure the data is static\n\texpectNilErr(t, c.Groups.Reload(gid))\n\n\tg, err = c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\tf(gid, false, true, false)\n\n\t// TODO: Don't enforce a specific order here\n\tcanSeeTest := func(a, b []int) bool {\n\t\tif (a == nil) != (b == nil) {\n\t\t\treturn false\n\t\t}\n\t\tif len(a) != len(b) {\n\t\t\treturn false\n\t\t}\n\t\tfor i := range a {\n\t\t\tif a[i] != b[i] {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\tex(canSeeTest(g.CanSee, canSee), \"g.CanSee is not being reused\")\n\n\t// TODO: Test group deletion\n\t// TODO: Test group reload\n\t// TODO: Test group cache set\n}\n\nfunc TestGroupPromotions(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\n\t_, err := c.GroupPromotions.Get(-1)\n\trecordMustNotExist(t, err, \"GP #-1 shouldn't exist\")\n\t_, err = c.GroupPromotions.Get(0)\n\trecordMustNotExist(t, err, \"GP #0 shouldn't exist\")\n\t_, err = c.GroupPromotions.Get(1)\n\trecordMustNotExist(t, err, \"GP #1 shouldn't exist\")\n\texpectNilErr(t, c.GroupPromotions.Delete(1))\n\n\t//GetByGroup(gid int) (gps []*GroupPromotion, err error)\n\n\ttestPromo := func(exid, from, to, level, posts, registeredFor int, shouldFail bool) {\n\t\tgpid, err := c.GroupPromotions.Create(from, to, false, level, posts, registeredFor)\n\t\texf(gpid == exid, \"gpid should be %d not %d\", exid, gpid)\n\t\t//fmt.Println(\"gpid:\", gpid)\n\t\tgp, err := c.GroupPromotions.Get(gpid)\n\t\texpectNilErr(t, err)\n\t\texf(gp.ID == gpid, \"gp.ID should be %d not %d\", gpid, gp.ID)\n\t\texf(gp.From == from, \"gp.From should be %d not %d\", from, gp.From)\n\t\texf(gp.To == to, \"gp.To should be %d not %d\", to, gp.To)\n\t\tex(!gp.TwoWay, \"gp.TwoWay should be false not true\")\n\t\texf(gp.Level == level, \"gp.Level should be %d not %d\", level, gp.Level)\n\t\texf(gp.Posts == posts, \"gp.Posts should be %d not %d\", posts, gp.Posts)\n\t\texf(gp.MinTime == 0, \"gp.MinTime should be %d not %d\", 0, gp.MinTime)\n\t\texf(gp.RegisteredFor == registeredFor, \"gp.RegisteredFor should be %d not %d\", registeredFor, gp.RegisteredFor)\n\n\t\tuid, err := c.Users.Create(\"Lord_\"+strconv.Itoa(gpid), \"I_Rule\", \"\", from, false)\n\t\texpectNilErr(t, err)\n\t\tu, err := c.Users.Get(uid)\n\t\texpectNilErr(t, err)\n\t\texf(u.ID == uid, \"u.ID should be %d not %d\", uid, u.ID)\n\t\texf(u.Group == from, \"u.Group should be %d not %d\", from, u.Group)\n\t\terr = c.GroupPromotions.PromoteIfEligible(u, u.Level, u.Posts, u.CreatedAt)\n\t\texpectNilErr(t, err)\n\t\tu.CacheRemove()\n\t\tu, err = c.Users.Get(uid)\n\t\texpectNilErr(t, err)\n\t\texf(u.ID == uid, \"u.ID should be %d not %d\", uid, u.ID)\n\t\tif shouldFail {\n\t\t\texf(u.Group == from, \"u.Group should be (from-group) %d not %d\", from, u.Group)\n\t\t} else {\n\t\t\texf(u.Group == to, \"u.Group should be (to-group)%d not %d\", to, u.Group)\n\t\t}\n\n\t\texpectNilErr(t, c.GroupPromotions.Delete(gpid))\n\t\t_, err = c.GroupPromotions.Get(gpid)\n\t\trecordMustNotExist(t, err, fmt.Sprintf(\"GP #%d should no longer exist\", gpid))\n\t}\n\ttestPromo(1, 1, 2, 0, 0, 0, false)\n\ttestPromo(2, 1, 2, 5, 5, 0, true)\n\ttestPromo(3, 1, 2, 0, 0, 1, true)\n}\n\nfunc TestReplyStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\t_, e := c.Rstore.Get(-1)\n\trecordMustNotExist(t, e, \"RID #-1 shouldn't exist\")\n\t_, e = c.Rstore.Get(0)\n\trecordMustNotExist(t, e, \"RID #0 shouldn't exist\")\n\n\tc.Config.DisablePostIP = false\n\ttestReplyStore(t, 2, \"::1\")\n\tc.Config.DisablePostIP = true\n\ttestReplyStore(t, 5, \"\")\n}\n\nfunc testReplyStore(t *testing.T, newID int, ip string) {\n\tex, exf := exp(t), expf(t)\n\treplyTest2 := func(r *c.Reply, e error, rid, parentID, createdBy int, content, ip string) {\n\t\texpectNilErr(t, e)\n\t\texf(r.ID == rid, \"RID #%d has the wrong ID. It should be %d not %d\", rid, rid, r.ID)\n\t\texf(r.ParentID == parentID, \"The parent topic of RID #%d should be %d not %d\", rid, parentID, r.ParentID)\n\t\texf(r.CreatedBy == createdBy, \"The creator of RID #%d should be %d not %d\", rid, createdBy, r.CreatedBy)\n\t\texf(r.Content == content, \"The contents of RID #%d should be '%s' not %s\", rid, content, r.Content)\n\t\texf(r.IP == ip, \"The IP of RID#%d should be '%s' not %s\", rid, ip, r.IP)\n\t}\n\n\treplyTest := func(rid, parentID, createdBy int, content, ip string) {\n\t\tr, e := c.Rstore.Get(rid)\n\t\treplyTest2(r, e, rid, parentID, createdBy, content, ip)\n\t\tr, e = c.Rstore.GetCache().Get(rid)\n\t\treplyTest2(r, e, rid, parentID, createdBy, content, ip)\n\t}\n\treplyTest(1, 1, 1, \"A reply!\", \"\")\n\n\t// ! This is hard to do deterministically as the system may pre-load certain items but let's give it a try:\n\t//_, err = c.Rstore.GetCache().Get(1)\n\t//recordMustNotExist(t, err, \"RID #1 shouldn't be in the cache\")\n\n\t_, err := c.Rstore.Get(newID)\n\trecordMustNotExist(t, err, \"RID #2 shouldn't exist\")\n\n\tnewPostCount := 1\n\ttid, err := c.Topics.Create(2, \"Reply Test Topic\", \"Reply Test Topic\", 1, \"\")\n\texpectNilErr(t, err)\n\n\ttopic, err := c.Topics.Get(tid)\n\texpectNilErr(t, err)\n\texf(topic.PostCount == newPostCount, \"topic.PostCount should be %d, not %d\", newPostCount, topic.PostCount)\n\texf(topic.LastReplyID == 0, \"topic.LastReplyID should be %d not %d\", 0, topic.LastReplyID)\n\tex(topic.CreatedAt == topic.LastReplyAt, \"topic.LastReplyAt should equal it's topic.CreatedAt\")\n\texf(topic.LastReplyBy == 1, \"topic.LastReplyBy should be %d not %d\", 1, topic.LastReplyBy)\n\n\t_, err = c.Rstore.GetCache().Get(newID)\n\trecordMustNotExist(t, err, \"RID #%d shouldn't be in the cache\", newID)\n\n\ttime.Sleep(2 * time.Second)\n\n\tuid, err := c.Users.Create(\"Reply Topic Test User\"+strconv.Itoa(newID), \"testpassword\", \"\", 2, true)\n\texpectNilErr(t, err)\n\trid, err := c.Rstore.Create(topic, \"Fofofo\", ip, uid)\n\texpectNilErr(t, err)\n\texf(rid == newID, \"The next reply ID should be %d not %d\", newID, rid)\n\texf(topic.PostCount == newPostCount, \"The old topic in memory's post count should be %d, not %d\", newPostCount+1, topic.PostCount)\n\t// TODO: Test the reply count on the topic\n\texf(topic.LastReplyID == 0, \"topic.LastReplyID should be %d not %d\", 0, topic.LastReplyID)\n\tex(topic.CreatedAt == topic.LastReplyAt, \"topic.LastReplyAt should equal it's topic.CreatedAt\")\n\n\treplyTest(newID, tid, uid, \"Fofofo\", ip)\n\n\ttopic, err = c.Topics.Get(tid)\n\texpectNilErr(t, err)\n\texf(topic.PostCount == newPostCount+1, \"topic.PostCount should be %d, not %d\", newPostCount+1, topic.PostCount)\n\texf(topic.LastReplyID == rid, \"topic.LastReplyID should be %d not %d\", rid, topic.LastReplyID)\n\tex(topic.CreatedAt != topic.LastReplyAt, \"topic.LastReplyAt should not equal it's topic.CreatedAt\")\n\texf(topic.LastReplyBy == uid, \"topic.LastReplyBy should be %d not %d\", uid, topic.LastReplyBy)\n\n\texpectNilErr(t, topic.CreateActionReply(\"destroy\", ip, 1))\n\texf(topic.PostCount == newPostCount+1, \"The old topic in memory's post count should be %d, not %d\", newPostCount+1, topic.PostCount)\n\treplyTest(newID+1, tid, 1, \"\", ip)\n\t// TODO: Check the actionType field of the reply, this might not be loaded by TopicStore, maybe we should add it there?\n\n\ttopic, err = c.Topics.Get(tid)\n\texpectNilErr(t, err)\n\texf(topic.PostCount == newPostCount+2, \"topic.PostCount should be %d, not %d\", newPostCount+2, topic.PostCount)\n\texf(topic.LastReplyID != rid, \"topic.LastReplyID should not be %d\", rid)\n\tarid := topic.LastReplyID\n\n\t// TODO: Expand upon this\n\trid, err = c.Rstore.Create(topic, \"hiii\", ip, 1)\n\texpectNilErr(t, err)\n\treplyTest(rid, topic.ID, 1, \"hiii\", ip)\n\n\treply, err := c.Rstore.Get(rid)\n\texpectNilErr(t, err)\n\texpectNilErr(t, reply.SetPost(\"huuu\"))\n\texf(reply.Content == \"hiii\", \"topic.Content should be hiii, not %s\", reply.Content)\n\n\treply, err = c.Rstore.Get(rid)\n\treplyTest2(reply, err, rid, topic.ID, 1, \"huuu\", ip)\n\texpectNilErr(t, c.Rstore.ClearIPs())\n\t_ = c.Rstore.GetCache().Remove(rid)\n\treplyTest(rid, topic.ID, 1, \"huuu\", \"\")\n\n\texpectNilErr(t, reply.Delete())\n\t// No pointer shenanigans x.x\n\t// TODO: Log reply.ID and rid in cases of pointer shenanigans?\n\tex(reply.ID == rid, \"pointer shenanigans\")\n\n\t_, err = c.Rstore.GetCache().Get(rid)\n\trecordMustNotExist(t, err, fmt.Sprintf(\"RID #%d shouldn't be in the cache\", rid))\n\t_, err = c.Rstore.Get(rid)\n\trecordMustNotExist(t, err, fmt.Sprintf(\"RID #%d shouldn't exist\", rid))\n\n\ttopic, err = c.Topics.Get(tid)\n\texpectNilErr(t, err)\n\texf(topic.LastReplyID == arid, \"topic.LastReplyID should be %d not %d\", arid, topic.LastReplyID)\n\n\t// TODO: Write a test for this\n\t//(topic *TopicUser) Replies(offset int, pFrag int, user *User) (rlist []*ReplyUser, ogdesc string, err error)\n\n\t// TODO: Add tests for *Reply\n\t// TODO: Add tests for ReplyCache\n}\n\nfunc TestLikes(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\t_, exf := exp(t), expf(t)\n\tbulkExists := func(iids []int, sentBy int, targetType string, expCount int) {\n\t\tids, e := c.Likes.BulkExists(iids, sentBy, \"replies\")\n\t\t//recordMustNotExist(t, e, \"no likes should be found\")\n\t\texpectNilErr(t, e)\n\t\texf(len(ids) == expCount, \"len ids should be %d\", expCount)\n\n\t\tidMap := make(map[int]struct{})\n\t\tfor _, id := range ids {\n\t\t\tidMap[id] = struct{}{}\n\t\t}\n\t\tfor _, iid := range iids {\n\t\t\t_, ok := idMap[iid]\n\t\t\texf(ok, \"missing iid %d in idMap\", iid)\n\t\t}\n\n\t\tidCount := 0\n\t\texpectNilErr(t, c.Likes.BulkExistsFunc(iids, sentBy, targetType, func(_ int) error {\n\t\t\tidCount++\n\t\t\treturn nil\n\t\t}))\n\t\texf(idCount == expCount, \"idCount should be %d not %d\", expCount, idCount)\n\t}\n\n\tuid := 1\n\tbulkExists([]int{}, uid, \"replies\", 0)\n\n\ttopic, e := c.Topics.Get(1)\n\texpectNilErr(t, e)\n\trid, e := c.Rstore.Create(topic, \"hiii\", \"\", uid)\n\texpectNilErr(t, e)\n\tr, e := c.Rstore.Get(rid)\n\texpectNilErr(t, e)\n\texpectNilErr(t, r.Like(uid))\n\tbulkExists([]int{rid}, uid, \"replies\", 1)\n\n\trid2, e := c.Rstore.Create(topic, \"hi 2 u 2\", \"\", uid)\n\texpectNilErr(t, e)\n\tr2, e := c.Rstore.Get(rid2)\n\texpectNilErr(t, e)\n\texpectNilErr(t, r2.Like(uid))\n\tbulkExists([]int{rid, rid2}, uid, \"replies\", 2)\n\n\texpectNilErr(t, r.Unlike(uid))\n\tbulkExists([]int{rid2}, uid, \"replies\", 1)\n\texpectNilErr(t, r2.Unlike(uid))\n\tbulkExists([]int{}, uid, \"replies\", 0)\n\n\t//BulkExists(ids []int, sentBy int, targetType string) (eids []int, err error)\n\n\texpectNilErr(t, topic.Like(1, uid))\n\texpectNilErr(t, topic.Unlike(uid))\n}\n\nfunc TestAttachments(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\n\tfilename := \"n0-48.png\"\n\tsrcFile := \"./test_data/\" + filename\n\tdestFile := \"./attachs/\" + filename\n\n\tft := func(e error) {\n\t\tif e != nil && e != sql.ErrNoRows {\n\t\t\tt.Error(e)\n\t\t}\n\t}\n\n\tex(c.Attachments.Count() == 0, \"the number of attachments should be 0\")\n\tex(c.Attachments.CountIn(\"topics\", 1) == 0, \"the number of attachments in topic 1 should be 0\")\n\texf(c.Attachments.CountInPath(filename) == 0, \"the number of attachments with path '%s' should be 0\", filename)\n\t_, e := c.Attachments.FGet(1)\n\tft(e)\n\tex(e == sql.ErrNoRows, \".FGet should have no results\")\n\t_, e = c.Attachments.Get(1)\n\tft(e)\n\tex(e == sql.ErrNoRows, \".Get should have no results\")\n\t_, e = c.Attachments.MiniGetList(\"topics\", 1)\n\tft(e)\n\tex(e == sql.ErrNoRows, \".MiniGetList should have no results\")\n\t_, e = c.Attachments.BulkMiniGetList(\"topics\", []int{1})\n\tft(e)\n\tex(e == sql.ErrNoRows, \".BulkMiniGetList should have no results\")\n\n\tsimUpload := func() {\n\t\t// Sim an upload, try a proper upload through the proper pathway later on\n\t\t_, e = os.Stat(destFile)\n\t\tif e != nil && !os.IsNotExist(e) {\n\t\t\texpectNilErr(t, e)\n\t\t} else if e == nil {\n\t\t\texpectNilErr(t, os.Remove(destFile))\n\t\t}\n\n\t\tinput, e := ioutil.ReadFile(srcFile)\n\t\texpectNilErr(t, e)\n\t\texpectNilErr(t, ioutil.WriteFile(destFile, input, 0644))\n\t}\n\tsimUpload()\n\n\ttid, e := c.Topics.Create(2, \"Attach Test\", \"Filler Body\", 1, \"\")\n\texpectNilErr(t, e)\n\taid, e := c.Attachments.Add(2, \"forums\", tid, \"topics\", 1, filename, \"\")\n\texpectNilErr(t, e)\n\texf(aid == 1, \"aid should be 1 not %d\", aid)\n\texpectNilErr(t, c.Attachments.AddLinked(\"topics\", tid))\n\tex(c.Attachments.Count() == 1, \"the number of attachments should be 1\")\n\texf(c.Attachments.CountIn(\"topics\", tid) == 1, \"the number of attachments in topic %d should be 1\", tid)\n\texf(c.Attachments.CountInPath(filename) == 1, \"the number of attachments with path '%s' should be 1\", filename)\n\n\tet := func(a *c.MiniAttachment, aid, sid, oid, uploadedBy int, path, extra, ext string) {\n\t\texf(a.ID == aid, \"ID should be %d not %d\", aid, a.ID)\n\t\texf(a.SectionID == sid, \"SectionID should be %d not %d\", sid, a.SectionID)\n\t\texf(a.OriginID == oid, \"OriginID should be %d not %d\", oid, a.OriginID)\n\t\texf(a.UploadedBy == uploadedBy, \"UploadedBy should be %d not %d\", uploadedBy, a.UploadedBy)\n\t\texf(a.Path == path, \"Path should be %s not %s\", path, a.Path)\n\t\texf(a.Extra == extra, \"Extra should be %s not %s\", extra, a.Extra)\n\t\tex(a.Image, \"Image should be true\")\n\t\texf(a.Ext == ext, \"Ext should be %s not %s\", ext, a.Ext)\n\t}\n\tet2 := func(a *c.Attachment, aid, sid, oid, uploadedBy int, path, extra, ext string) {\n\t\texf(a.ID == aid, \"ID should be %d not %d\", aid, a.ID)\n\t\texf(a.SectionID == sid, \"SectionID should be %d not %d\", sid, a.SectionID)\n\t\texf(a.OriginID == oid, \"OriginID should be %d not %d\", oid, a.OriginID)\n\t\texf(a.UploadedBy == uploadedBy, \"UploadedBy should be %d not %d\", uploadedBy, a.UploadedBy)\n\t\texf(a.Path == path, \"Path should be %s not %s\", path, a.Path)\n\t\texf(a.Extra == extra, \"Extra should be %s not %s\", extra, a.Extra)\n\t\tex(a.Image, \"Image should be true\")\n\t\texf(a.Ext == ext, \"Ext should be %s not %s\", ext, a.Ext)\n\t}\n\n\tf2 := func(aid, sid, oid int, extra string, topic bool) {\n\t\tvar tbl string\n\t\tif topic {\n\t\t\ttbl = \"topics\"\n\t\t} else {\n\t\t\ttbl = \"replies\"\n\t\t}\n\t\tfa, e := c.Attachments.FGet(aid)\n\t\texpectNilErr(t, e)\n\t\tet2(fa, aid, sid, oid, 1, filename, extra, \"png\")\n\n\t\ta, e := c.Attachments.Get(aid)\n\t\texpectNilErr(t, e)\n\t\tet(a, aid, sid, oid, 1, filename, extra, \"png\")\n\n\t\talist, e := c.Attachments.MiniGetList(tbl, oid)\n\t\texpectNilErr(t, e)\n\t\texf(len(alist) == 1, \"len(alist) should be 1 not %d\", len(alist))\n\t\ta = alist[0]\n\t\tet(a, aid, sid, oid, 1, filename, extra, \"png\")\n\n\t\tamap, e := c.Attachments.BulkMiniGetList(tbl, []int{oid})\n\t\texpectNilErr(t, e)\n\t\texf(len(amap) == 1, \"len(amap) should be 1 not %d\", len(amap))\n\t\talist, ok := amap[oid]\n\t\tif !ok {\n\t\t\tt.Logf(\"key %d not found in amap\", oid)\n\t\t}\n\t\texf(len(alist) == 1, \"len(alist) should be 1 not %d\", len(alist))\n\t\ta = alist[0]\n\t\tet(a, aid, sid, oid, 1, filename, extra, \"png\")\n\t}\n\n\ttopic, e := c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\texf(topic.AttachCount == 1, \"topic.AttachCount should be 1 not %d\", topic.AttachCount)\n\tf2(aid, 2, tid, \"\", true)\n\texpectNilErr(t, topic.MoveTo(1))\n\tf2(aid, 1, tid, \"\", true)\n\texpectNilErr(t, c.Attachments.MoveTo(2, tid, \"topics\"))\n\tf2(aid, 2, tid, \"\", true)\n\n\t// TODO: ShowAttachment test\n\n\tdeleteTest := func(aid, oid int, topic bool) {\n\t\tvar tbl string\n\t\tif topic {\n\t\t\ttbl = \"topics\"\n\t\t} else {\n\t\t\ttbl = \"replies\"\n\t\t}\n\t\t//expectNilErr(t, c.Attachments.Delete(aid))\n\t\texpectNilErr(t, c.DeleteAttachment(aid))\n\t\tex(c.Attachments.Count() == 0, \"the number of attachments should be 0\")\n\t\texf(c.Attachments.CountIn(tbl, oid) == 0, \"the number of attachments in topic %d should be 0\", tid)\n\t\texf(c.Attachments.CountInPath(filename) == 0, \"the number of attachments with path '%s' should be 0\", filename)\n\t\t_, e = c.Attachments.FGet(aid)\n\t\tft(e)\n\t\tex(e == sql.ErrNoRows, \".FGet should have no results\")\n\t\t_, e = c.Attachments.Get(aid)\n\t\tft(e)\n\t\tex(e == sql.ErrNoRows, \".Get should have no results\")\n\t\t_, e = c.Attachments.MiniGetList(tbl, oid)\n\t\tft(e)\n\t\tex(e == sql.ErrNoRows, \".MiniGetList should have no results\")\n\t\t_, e = c.Attachments.BulkMiniGetList(tbl, []int{oid})\n\t\tft(e)\n\t\tex(e == sql.ErrNoRows, \".BulkMiniGetList should have no results\")\n\t}\n\tdeleteTest(aid, tid, true)\n\ttopic, e = c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\texf(topic.AttachCount == 0, \"topic.AttachCount should be 0 not %d\", topic.AttachCount)\n\n\tsimUpload()\n\trid, e := c.Rstore.Create(topic, \"Reply Filler\", \"\", 1)\n\texpectNilErr(t, e)\n\taid, e = c.Attachments.Add(2, \"forums\", rid, \"replies\", 1, filename, strconv.Itoa(topic.ID))\n\texpectNilErr(t, e)\n\texf(aid == 2, \"aid should be 2 not %d\", aid)\n\texpectNilErr(t, c.Attachments.AddLinked(\"replies\", rid))\n\tr, e := c.Rstore.Get(rid)\n\texpectNilErr(t, e)\n\texf(r.AttachCount == 1, \"r.AttachCount should be 1 not %d\", r.AttachCount)\n\tf2(aid, 2, rid, strconv.Itoa(topic.ID), false)\n\texpectNilErr(t, c.Attachments.MoveTo(1, rid, \"replies\"))\n\tf2(aid, 1, rid, strconv.Itoa(topic.ID), false)\n\tdeleteTest(aid, rid, false)\n\tr, e = c.Rstore.Get(rid)\n\texpectNilErr(t, e)\n\texf(r.AttachCount == 0, \"r.AttachCount should be 0 not %d\", r.AttachCount)\n\n\t// TODO: Path overlap tests\n}\n\nfunc TestPolls(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\n\tshouldNotExist := func(id int) {\n\t\texf(!c.Polls.Exists(id), \"poll %d should not exist\", id)\n\t\t_, e := c.Polls.Get(id)\n\t\trecordMustNotExist(t, e, fmt.Sprintf(\"poll %d shouldn't exist\", id))\n\t}\n\tshouldNotExist(-1)\n\tshouldNotExist(0)\n\tshouldNotExist(1)\n\texf(c.Polls.Count() == 0, \"count should be %d not %d\", 0, c.Polls.Count())\n\n\ttid, e := c.Topics.Create(2, \"Poll Test\", \"Filler Body\", 1, \"\")\n\texpectNilErr(t, e)\n\ttopic, e := c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == 0, \"t.Poll should be %d not %d\", 0, topic.Poll)\n\t/*Options      map[int]string\n\t\tResults      map[int]int  // map[optionIndex]points\n\t\tQuickOptions []PollOption // TODO: Fix up the template transpiler so we don't need to use this hack anymore\n\t}*/\n\tpollType := 0 // Basic single choice\n\tpid, e := c.Polls.Create(topic, pollType, map[int]string{0: \"item 1\", 1: \"item 2\", 2: \"item 3\"})\n\texpectNilErr(t, e)\n\texf(pid == 1, \"poll id should be 1 not %d\", pid)\n\tex(c.Polls.Exists(1), \"poll 1 should exist\")\n\texf(c.Polls.Count() == 1, \"count should be %d not %d\", 1, c.Polls.Count())\n\ttopic, e = c.Topics.BypassGet(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == pid, \"t.Poll should be %d not %d\", pid, topic.Poll)\n\n\ttestPoll := func(p *c.Poll, id, parentID int, parentTable string, ptype int, antiCheat bool, voteCount int) {\n\t\tef := exf\n\t\tef(p.ID == id, \"p.ID should be %d not %d\", id, p.ID)\n\t\tef(p.ParentID == parentID, \"p.ParentID should be %d not %d\", parentID, p.ParentID)\n\t\tef(p.ParentTable == parentTable, \"p.ParentID should be %s not %s\", parentTable, p.ParentTable)\n\t\tef(p.Type == ptype, \"p.ParentID should be %d not %d\", ptype, p.Type)\n\t\ts := \"false\"\n\t\tif p.AntiCheat {\n\t\t\ts = \"true\"\n\t\t}\n\t\tef(p.AntiCheat == antiCheat, \"p.AntiCheat should be \", s)\n\t\t// TODO: More fields\n\t\tef(p.VoteCount == voteCount, \"p.VoteCount should be %d not %d\", voteCount, p.VoteCount)\n\t}\n\n\tp, e := c.Polls.Get(1)\n\texpectNilErr(t, e)\n\ttestPoll(p, 1, tid, \"topics\", 0, false, 0)\n\n\texpectNilErr(t, p.CastVote(0, 1, \"\"))\n\texpectNilErr(t, c.Polls.Reload(p.ID))\n\tp, e = c.Polls.Get(1)\n\texpectNilErr(t, e)\n\ttestPoll(p, 1, tid, \"topics\", 0, false, 1)\n\n\tvar vslice []int\n\texpectNilErr(t, p.Resultsf(func(votes int) error {\n\t\tvslice = append(vslice, votes)\n\t\treturn nil\n\t}))\n\t//fmt.Printf(\"vslice: %+v\\n\", vslice)\n\texf(vslice[0] == 1, \"vslice[0] should be %d not %d\", 0, vslice[0])\n\texf(vslice[1] == 0, \"vslice[1] should be %d not %d\", 1, vslice[1])\n\texf(vslice[2] == 0, \"vslice[2] should be %d not %d\", 0, vslice[2])\n\n\texpectNilErr(t, p.CastVote(2, 1, \"\"))\n\texpectNilErr(t, c.Polls.Reload(p.ID))\n\tp, e = c.Polls.Get(1)\n\texpectNilErr(t, e)\n\ttestPoll(p, 1, tid, \"topics\", 0, false, 2)\n\n\tvslice = nil\n\texpectNilErr(t, p.Resultsf(func(votes int) error {\n\t\tvslice = append(vslice, votes)\n\t\treturn nil\n\t}))\n\t//fmt.Printf(\"vslice: %+v\\n\", vslice)\n\texf(vslice[0] == 1, \"vslice[0] should be %d not %d\", 1, vslice[0])\n\texf(vslice[1] == 0, \"vslice[1] should be %d not %d\", 0, vslice[1])\n\texf(vslice[2] == 1, \"vslice[2] should be %d not %d\", 1, vslice[2])\n\n\texpectNilErr(t, c.Polls.ClearIPs())\n\t// TODO: Test to see if it worked\n\n\texpectNilErr(t, p.Delete())\n\tex(!c.Polls.Exists(1), \"poll 1 should no longer exist\")\n\t_, e = c.Polls.Get(1)\n\trecordMustNotExist(t, e, \"poll 1 should no longer exist\")\n\texf(c.Polls.Count() == 0, \"count should be %d not %d\", 0, c.Polls.Count())\n\ttopic, e = c.Topics.BypassGet(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == pid, \"t.Poll should be %d not %d\", pid, topic.Poll)\n\n\texpectNilErr(t, topic.SetPoll(999))\n\ttopic, e = c.Topics.BypassGet(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == pid, \"t.Poll should be %d not %d\", pid, topic.Poll)\n\n\texpectNilErr(t, topic.SetPoll(0))\n\ttopic, e = c.Topics.BypassGet(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == pid, \"t.Poll should be %d not %d\", pid, topic.Poll)\n\n\texpectNilErr(t, topic.RemovePoll())\n\ttopic, e = c.Topics.BypassGet(tid)\n\texpectNilErr(t, e)\n\texf(topic.Poll == 0, \"t.Poll should be %d not %d\", 0, topic.Poll)\n}\n\nfunc TestSearch(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\texf := expf(t)\n\n\ttitle := \"search\"\n\tbody := \"bab bab bab bab\"\n\tq := \"search\"\n\ttid, e := c.Topics.Create(2, title, body, 1, \"\")\n\texpectNilErr(t, e)\n\n\ttids, e := c.RepliesSearch.Query(q, []int{2})\n\t//fmt.Printf(\"tids: %+v\\n\", tids)\n\texpectNilErr(t, e)\n\texf(len(tids) == 1, \"len(tids) should be 1 not %d\", len(tids))\n\n\ttopic, e := c.Topics.Get(tids[0])\n\texpectNilErr(t, e)\n\texf(topic.ID == tid, \"topic.ID should be %d not %d\", tid, topic.ID)\n\texf(topic.Title == title, \"topic.Title should be %s not %s\", title, topic.Title)\n\n\ttids, e = c.RepliesSearch.Query(q, []int{1, 2})\n\t//fmt.Printf(\"tids: %+v\\n\", tids)\n\texpectNilErr(t, e)\n\texf(len(tids) == 1, \"len(tids) should be 1 not %d\", len(tids))\n\n\tq = \"bab\"\n\ttids, e = c.RepliesSearch.Query(q, []int{1, 2})\n\t//fmt.Printf(\"tids: %+v\\n\", tids)\n\texpectNilErr(t, e)\n\texf(len(tids) == 1, \"len(tids) should be 1 not %d\", len(tids))\n}\n\nfunc TestProfileReplyStore(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\n\t_, e := c.Prstore.Get(-1)\n\trecordMustNotExist(t, e, \"PRID #-1 shouldn't exist\")\n\t_, e = c.Prstore.Get(0)\n\trecordMustNotExist(t, e, \"PRID #0 shouldn't exist\")\n\t_, e = c.Prstore.Get(1)\n\trecordMustNotExist(t, e, \"PRID #1 shouldn't exist\")\n\n\tc.Config.DisablePostIP = false\n\ttestProfileReplyStore(t, 1, \"::1\")\n\tc.Config.DisablePostIP = true\n\ttestProfileReplyStore(t, 2, \"\")\n}\nfunc testProfileReplyStore(t *testing.T, newID int, ip string) {\n\texf := expf(t)\n\t// ? - Commented this one out as strong constraints like this put an unreasonable load on the database, we only want errors if a delete which should succeed fails\n\t//profileReply := c.BlankProfileReply(1)\n\t//e = profileReply.Delete()\n\t//expect(t,e != nil,\"You shouldn't be able to delete profile replies which don't exist\")\n\n\tprofileID := 1\n\tprid, e := c.Prstore.Create(profileID, \"Haha\", 1, ip)\n\texpectNilErr(t, e)\n\texf(prid == newID, \"The first profile reply should have an ID of %d\", newID)\n\n\tpr, e := c.Prstore.Get(newID)\n\texpectNilErr(t, e)\n\texf(pr.ID == newID, \"The profile reply should have an ID of %d not %d\", newID, pr.ID)\n\texf(pr.ParentID == 1, \"The parent ID of the profile reply should be 1 not %d\", pr.ParentID)\n\texf(pr.Content == \"Haha\", \"The profile reply's contents should be 'Haha' not '%s'\", pr.Content)\n\texf(pr.CreatedBy == 1, \"The profile reply's creator should be 1 not %d\", pr.CreatedBy)\n\texf(pr.IP == ip, \"The profile reply's IP should be '%s' not '%s'\", ip, pr.IP)\n\n\texpectNilErr(t, c.Prstore.ClearIPs())\n\n\tpr, e = c.Prstore.Get(newID)\n\texpectNilErr(t, e)\n\texf(pr.ID == newID, \"The profile reply should have an ID of %d not %d\", newID, pr.ID)\n\texf(pr.ParentID == 1, \"The parent ID of the profile reply should be 1 not %d\", pr.ParentID)\n\texf(pr.Content == \"Haha\", \"The profile reply's contents should be 'Haha' not '%s'\", pr.Content)\n\texf(pr.CreatedBy == 1, \"The profile reply's creator should be 1 not %d\", pr.CreatedBy)\n\tip = \"\"\n\texf(pr.IP == ip, \"The profile reply's IP should be '%s' not '%s'\", ip, pr.IP)\n\n\texpectNilErr(t, pr.Delete())\n\t_, e = c.Prstore.Get(newID)\n\texf(e != nil, \"PRID #%d shouldn't exist after being deleted\", newID)\n\n\t// TODO: Test pr.SetBody() and pr.Creator()\n}\n\nfunc TestConvos(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\n\tsf := func(i interface{}, e error) error {\n\t\treturn e\n\t}\n\tmf := func(e error, msg string, exists bool) {\n\t\tif !exists {\n\t\t\trecordMustNotExist(t, e, msg)\n\t\t} else {\n\t\t\trecordMustExist(t, e, msg)\n\t\t}\n\t}\n\tgu := func(uid, offset int, exists bool) {\n\t\ts := \"\"\n\t\tif !exists {\n\t\t\ts = \" not\"\n\t\t}\n\t\tmf(sf(c.Convos.GetUser(uid, offset)), fmt.Sprintf(\"convo getuser %d %d should%s exist\", uid, offset, s), exists)\n\t}\n\tgue := func(uid, offset int, exists bool) {\n\t\ts := \"\"\n\t\tif !exists {\n\t\t\ts = \" not\"\n\t\t}\n\t\tmf(sf(c.Convos.GetUserExtra(uid, offset)), fmt.Sprintf(\"convo getuserextra %d %d should%s exist\", uid, offset, s), exists)\n\t}\n\n\tex(c.Convos.GetUserCount(-1) == 0, \"getusercount should be 0\")\n\tex(c.Convos.GetUserCount(0) == 0, \"getusercount should be 0\")\n\tmf(sf(c.Convos.Get(-1)), \"convo -1 should not exist\", false)\n\tmf(sf(c.Convos.Get(0)), \"convo 0 should not exist\", false)\n\tgu(-1, -1, false)\n\tgu(-1, 0, false)\n\tgu(0, 0, false)\n\tgue(-1, -1, false)\n\tgue(-1, 0, false)\n\tgue(0, 0, false)\n\n\tnf := func(cid, count int) {\n\t\tex := count > 0\n\t\ts := \"\"\n\t\tif !ex {\n\t\t\ts = \" not\"\n\t\t}\n\t\tmf(sf(c.Convos.Get(cid)), fmt.Sprintf(\"convo %d should%s exist\", cid, s), ex)\n\t\tgu(1, 0, ex)\n\t\tgu(1, 5, false) // invariant may change in future tests\n\n\t\texf(c.Convos.GetUserCount(1) == count, \"getusercount should be %d\", count)\n\t\tgue(1, 0, ex)\n\t\tgue(1, 5, false) // invariant may change in future tests\n\t\texf(c.Convos.Count() == count, \"convos count should be %d\", count)\n\t}\n\tnf(1, 0)\n\n\tawaitingActivation := 5\n\tuid, err := c.Users.Create(\"Saturn\", \"ReallyBadPassword\", \"\", awaitingActivation, false)\n\texpectNilErr(t, err)\n\n\tcid, err := c.Convos.Create(\"hehe\", 1, []int{uid})\n\texpectNilErr(t, err)\n\tex(cid == 1, \"cid should be 1\")\n\tex(c.Convos.Count() == 1, \"convos count should be 1\")\n\n\tco, err := c.Convos.Get(cid)\n\texpectNilErr(t, err)\n\tex(co.ID == 1, \"co.ID should be 1\")\n\tex(co.CreatedBy == 1, \"co.CreatedBy should be 1\")\n\t// TODO: CreatedAt test\n\tex(co.LastReplyBy == 1, \"co.LastReplyBy should be 1\")\n\t// TODO: LastReplyAt test\n\texpectIntToBeX(t, co.PostsCount(), 1, \"postscount should be 1, not %d\")\n\tex(co.Has(uid), \"saturn should be in the conversation\")\n\tex(!co.Has(9999), \"uid 9999 should not be in the conversation\")\n\tuids, err := co.Uids()\n\texpectNilErr(t, err)\n\texpectIntToBeX(t, len(uids), 2, \"uids length should be 2, not %d\")\n\texf(uids[0] == uid, \"uids[0] should be %d, not %d\", uid, uids[0])\n\texf(uids[1] == 1, \"uids[1] should be %d, not %d\", 1, uids[1])\n\tnf(cid, 1)\n\n\texpectNilErr(t, c.Convos.Delete(cid))\n\texpectIntToBeX(t, co.PostsCount(), 0, \"postscount should be 0, not %d\")\n\tex(!co.Has(uid), \"saturn should not be in a deleted conversation\")\n\tuids, err = co.Uids()\n\texpectNilErr(t, err)\n\texpectIntToBeX(t, len(uids), 0, \"uids length should be 0, not %d\")\n\tnf(cid, 0)\n\n\t// TODO: More tests\n\n\t// Block tests\n\n\tok, err := c.UserBlocks.IsBlockedBy(1, 1)\n\texpectNilErr(t, err)\n\tex(!ok, \"there shouldn't be any blocks\")\n\tok, err = c.UserBlocks.BulkIsBlockedBy([]int{1}, 1)\n\texpectNilErr(t, err)\n\tex(!ok, \"there shouldn't be any blocks\")\n\tbf := func(blocker, offset, perPage, expectLen, blockee int) {\n\t\tl, err := c.UserBlocks.BlockedByOffset(blocker, offset, perPage)\n\t\texpectNilErr(t, err)\n\t\texf(len(l) == expectLen, \"there should be %d users blocked by %d not %d\", expectLen, blocker, len(l))\n\t\tif len(l) > 0 {\n\t\t\texf(l[0] == blockee, \"blocked uid should be %d not %d\", blockee, l[0])\n\t\t}\n\t}\n\tnbf := func(blocker, blockee int) {\n\t\tok, err := c.UserBlocks.IsBlockedBy(1, 2)\n\t\texpectNilErr(t, err)\n\t\tex(!ok, \"there shouldn't be any blocks\")\n\t\tok, err = c.UserBlocks.BulkIsBlockedBy([]int{1}, 2)\n\t\texpectNilErr(t, err)\n\t\tex(!ok, \"there shouldn't be any blocks\")\n\t\texpectIntToBeX(t, c.UserBlocks.BlockedByCount(1), 0, \"blockedbycount for 1 should be 1, not %d\")\n\t\tbf(1, 0, 1, 0, 0)\n\t\tbf(1, 0, 15, 0, 0)\n\t\tbf(1, 1, 15, 0, 0)\n\t\tbf(1, 5, 15, 0, 0)\n\t}\n\tnbf(1, 2)\n\n\texpectNilErr(t, c.UserBlocks.Add(1, 2))\n\tok, err = c.UserBlocks.IsBlockedBy(1, 2)\n\texpectNilErr(t, err)\n\tex(ok, \"2 should be blocked by 1\")\n\texpectIntToBeX(t, c.UserBlocks.BlockedByCount(1), 1, \"blockedbycount for 1 should be 1, not %d\")\n\tbf(1, 0, 1, 1, 2)\n\tbf(1, 0, 15, 1, 2)\n\tbf(1, 1, 15, 0, 0)\n\tbf(1, 5, 15, 0, 0)\n\n\t// Double add test\n\texpectNilErr(t, c.UserBlocks.Add(1, 2))\n\tok, err = c.UserBlocks.IsBlockedBy(1, 2)\n\texpectNilErr(t, err)\n\tex(ok, \"2 should be blocked by 1\")\n\t//expectIntToBeX(t, c.UserBlocks.BlockedByCount(1), 1, \"blockedbycount for 1 should be 1, not %d\") // todo: fix this\n\t//bf(1, 0, 1, 1, 2) // todo: fix this\n\t//bf(1, 0, 15, 1, 2) // todo: fix this\n\t//bf(1, 1, 15, 0, 0) // todo: fix this\n\tbf(1, 5, 15, 0, 0)\n\n\texpectNilErr(t, c.UserBlocks.Remove(1, 2))\n\tnbf(1, 2)\n\t// Double remove test\n\texpectNilErr(t, c.UserBlocks.Remove(1, 2))\n\tnbf(1, 2)\n\n\t// TODO: Self-block test\n\n\t// TODO: More Block tests\n}\n\nfunc TestActivityStream(t *testing.T) {\n\tmiscinit(t)\n\tex, exf := exp(t), expf(t)\n\n\tex(c.Activity.Count() == 0, \"activity stream count should be 0\")\n\tgNone := func(id int) {\n\t\t_, e := c.Activity.Get(id)\n\t\trecordMustNotExist(t, e, \"activity item \"+strconv.Itoa(id)+\" shouldn't exist\")\n\t}\n\tgNone(-1)\n\tgNone(0)\n\tgNone(1)\n\tcountAsid := func(asid, count int) {\n\t\texf(c.ActivityMatches.CountAsid(asid) == count, \"activity stream matches count for asid %d should be %d not %d\", asid, count, c.ActivityMatches.CountAsid(asid))\n\t}\n\tcountAsid(-1, 0)\n\tcountAsid(0, 0)\n\tcountAsid(1, 0)\n\n\ta := c.Alert{ActorID: 1, TargetUserID: 1, Event: \"like\", ElementType: \"topic\", ElementID: 1}\n\tid, e := c.Activity.Add(a)\n\texpectNilErr(t, e)\n\tex(id == 1, \"new activity item id should be 1\")\n\n\tex(c.Activity.Count() == 1, \"activity stream count should be 1\")\n\tal, e := c.Activity.Get(1)\n\texpectNilErr(t, e)\n\texf(al.ASID == id, \"alert asid should be %d not %d\", id, al.ASID)\n\tex(al.ActorID == 1, \"alert actorid should be 1\")\n\tex(al.TargetUserID == 1, \"alert targetuserid should be 1\")\n\tex(al.Event == \"like\", \"alert event type should be like\")\n\tex(al.ElementType == \"topic\", \"alert element type should be topic\")\n\tex(al.ElementID == 1, \"alert element id should be 1\")\n\n\tcountAsid(id, 0)\n\n\ttuid, e := c.Users.Create(\"Activity Target\", \"Activity Target\", \"\", 1, true)\n\texpectNilErr(t, e)\n\texpectNilErr(t, c.ActivityMatches.Add(tuid, 1))\n\tcountAsid(id, 1)\n\texpectNilErr(t, c.ActivityMatches.Delete(tuid, id))\n\tcountAsid(id, 0)\n\n\texpectNilErr(t, c.ActivityMatches.Add(tuid, 1))\n\tcountAsid(id, 1)\n\tchanged, e := c.ActivityMatches.DeleteAndCountChanged(tuid, id)\n\texpectNilErr(t, e)\n\texf(changed == 1, \"changed should be %d not %d\", 1, changed)\n\tcountAsid(id, 0)\n\n\texpectNilErr(t, c.ActivityMatches.Add(tuid, 1))\n\tcountAsid(id, 1)\n\n\t// TODO: Add more tests\n\n\texpectNilErr(t, c.Activity.Delete(id))\n\tex(c.Activity.Count() == 0, \"activity stream count should be 0\")\n\tgNone(id)\n\tcountAsid(id, 0)\n\n\t// TODO: More tests\n}\n\nfunc TestLogs(t *testing.T) {\n\tex, exf := exp(t), expf(t)\n\tmiscinit(t)\n\tgTests := func(s c.LogStore, phrase string) {\n\t\tex(s.Count() == 0, \"There shouldn't be any \"+phrase)\n\t\tlogs, err := s.GetOffset(0, 25)\n\t\texpectNilErr(t, err)\n\t\tex(len(logs) == 0, \"The log slice should be empty\")\n\t}\n\tgTests(c.ModLogs, \"modlogs\")\n\tgTests(c.AdminLogs, \"adminlogs\")\n\n\tgTests2 := func(s c.LogStore, phrase string) {\n\t\terr := s.Create(\"something\", 0, \"bumblefly\", \"::1\", 1)\n\t\texpectNilErr(t, err)\n\t\tcount := s.Count()\n\t\texf(count == 1, \"store.Count should return one, not %d\", count)\n\t\tlogs, err := s.GetOffset(0, 25)\n\t\trecordMustExist(t, err, \"We should have at-least one \"+phrase)\n\t\tex(len(logs) == 1, \"The length of the log slice should be one\")\n\n\t\tl := logs[0]\n\t\tex(l.Action == \"something\", \"l.Action is not something\")\n\t\tex(l.ElementID == 0, \"l.ElementID is not 0\")\n\t\tex(l.ElementType == \"bumblefly\", \"l.ElementType is not bumblefly\")\n\t\tex(l.IP == \"::1\", \"l.IP is not ::1\")\n\t\tex(l.ActorID == 1, \"l.ActorID is not 1\")\n\t\t// TODO: Add a test for log.DoneAt? Maybe throw in some dates and times which are clearly impossible but which may occur due to timezone bugs?\n\t}\n\tgTests2(c.ModLogs, \"modlog\")\n\tgTests2(c.AdminLogs, \"adminlog\")\n}\n\nfunc TestRegLogs(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\texf := expf(t)\n\n\tmustNone := func() {\n\t\texf(c.RegLogs.Count() == 0, \"count should be %d not %d\", 0, c.RegLogs.Count())\n\t\titems, e := c.RegLogs.GetOffset(0, 10)\n\t\texpectNilErr(t, e)\n\t\texf(len(items) == 0, \"len(items) should be %d not %d\", 0, len(items))\n\t\texpectNilErr(t, c.RegLogs.Purge())\n\t\texf(c.RegLogs.Count() == 0, \"count should be %d not %d\", 0, c.RegLogs.Count())\n\t\titems, e = c.RegLogs.GetOffset(0, 10)\n\t\texpectNilErr(t, e)\n\t\texf(len(items) == 0, \"len(items) should be %d not %d\", 0, len(items))\n\t}\n\tmustNone()\n\n\tregLog := &c.RegLogItem{Username: \"Aa\", Email: \"aa@example.com\", FailureReason: \"fake\", Success: false, IP: \"\"}\n\tid, e := regLog.Create()\n\texf(id == 1, \"id should be %d not %d\", 1, id)\n\texpectNilErr(t, e)\n\n\texf(c.RegLogs.Count() == 1, \"count should be %d not %d\", 1, c.RegLogs.Count())\n\titems, e := c.RegLogs.GetOffset(0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: Add more tests\n\n\texpectNilErr(t, c.RegLogs.DeleteOlderThanDays(2))\n\n\texf(c.RegLogs.Count() == 1, \"count should be %d not %d\", 1, c.RegLogs.Count())\n\titems, e = c.RegLogs.GetOffset(0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: Add more tests\n\n\t// TODO: Commit() test?\n\tdayAgo := time.Now().AddDate(0, 0, -5)\n\titems[0].DoneAt = dayAgo.Format(\"2006-01-02 15:04:05\")\n\texpectNilErr(t, items[0].Commit())\n\n\texf(c.RegLogs.Count() == 1, \"count should be %d not %d\", 1, c.RegLogs.Count())\n\titems, e = c.RegLogs.GetOffset(0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: Add more tests\n\n\texpectNilErr(t, c.RegLogs.DeleteOlderThanDays(2))\n\tmustNone()\n\n\tregLog = &c.RegLogItem{Username: \"Aa\", Email: \"aa@example.com\", FailureReason: \"fake\", Success: false, IP: \"\"}\n\tid, e = regLog.Create()\n\texf(id == 2, \"id should be %d not %d\", 2, id)\n\texpectNilErr(t, e)\n\n\texf(c.RegLogs.Count() == 1, \"count should be %d not %d\", 1, c.RegLogs.Count())\n\titems, e = c.RegLogs.GetOffset(0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: Add more tests\n\n\texpectNilErr(t, c.RegLogs.Purge())\n\tmustNone()\n\n\t// TODO: Add more tests\n}\n\nfunc TestLoginLogs(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex, exf := exp(t), expf(t)\n\tuid, e := c.Users.Create(\"Log Test\", \"Log Test\", \"\", 1, true)\n\texpectNilErr(t, e)\n\n\texf(c.LoginLogs.CountUser(-1) == 0, \"countuser(-1) should be %d not %d\", 0, c.LoginLogs.CountUser(-1))\n\texf(c.LoginLogs.CountUser(0) == 0, \"countuser(0) should be %d not %d\", 0, c.LoginLogs.CountUser(0))\n\texf(c.LoginLogs.CountUser(1) == 0, \"countuser(1) should be %d not %d\", 0, c.LoginLogs.CountUser(1))\n\tgoNone := func(uid, offset, perPage int) {\n\t\titems, e := c.LoginLogs.GetOffset(uid, offset, perPage)\n\t\texpectNilErr(t, e)\n\t\texf(len(items) == 0, \"len(items) should be %d not %d\", 0, len(items))\n\t}\n\tgoNone(-1, 0, 10)\n\tgoNone(0, 0, 10)\n\tgoNone(1, 0, 10)\n\tgoNone(1, 1, 10)\n\tgoNone(1, 0, 0)\n\n\tmustNone := func() {\n\t\texf(c.LoginLogs.Count() == 0, \"count should be %d not %d\", 0, c.LoginLogs.Count())\n\t\texf(c.LoginLogs.CountUser(uid) == 0, \"countuser(%d) should be %d not %d\", uid, 0, c.LoginLogs.CountUser(uid))\n\t\tgoNone(uid, 0, 10)\n\t\tgoNone(uid, 1, 10)\n\t\tgoNone(uid, 0, 0)\n\t}\n\tmustNone()\n\n\tlogItem := &c.LoginLogItem{UID: uid, Success: true, IP: \"\"}\n\t_, e = logItem.Create()\n\texpectNilErr(t, e)\n\n\texf(c.LoginLogs.Count() == 1, \"count should be %d not %d\", 1, c.LoginLogs.Count())\n\texf(c.LoginLogs.CountUser(uid) == 1, \"countuser(%d) should be %d not %d\", uid, 1, c.LoginLogs.CountUser(uid))\n\titems, e := c.LoginLogs.GetOffset(uid, 0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: More tests\n\texf(items[0].UID == uid, \"UID should be %d not %d\", uid, items[0].UID)\n\tex(items[0].Success, \"Success should be true\")\n\tex(items[0].IP == \"\", \"IP should be blank\")\n\tgoNone(uid, 1, 10)\n\tgoNone(uid, 0, 0)\n\n\tdayAgo := time.Now().AddDate(0, 0, -5)\n\titems[0].DoneAt = dayAgo.Format(\"2006-01-02 15:04:05\")\n\tprevDoneAt := items[0].DoneAt\n\texpectNilErr(t, items[0].Commit())\n\n\titems, e = c.LoginLogs.GetOffset(uid, 0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: More tests\n\texf(items[0].UID == uid, \"UID should be %d not %d\", uid, items[0].UID)\n\tex(items[0].Success, \"Success should be true\")\n\tex(items[0].IP == \"\", \"IP should be blank\")\n\texf(items[0].DoneAt == prevDoneAt, \"DoneAt should be %s not %s\", prevDoneAt, items[0].DoneAt)\n\tgoNone(uid, 1, 10)\n\tgoNone(uid, 0, 0)\n\n\texpectNilErr(t, c.LoginLogs.DeleteOlderThanDays(2))\n\tmustNone()\n\n\tlogItem = &c.LoginLogItem{UID: uid, Success: true, IP: \"\"}\n\t_, e = logItem.Create()\n\texpectNilErr(t, e)\n\n\texf(c.LoginLogs.Count() == 1, \"count should be %d not %d\", 1, c.LoginLogs.Count())\n\texf(c.LoginLogs.CountUser(uid) == 1, \"countuser(%d) should be %d not %d\", uid, 1, c.LoginLogs.CountUser(uid))\n\titems, e = c.LoginLogs.GetOffset(uid, 0, 10)\n\texpectNilErr(t, e)\n\texf(len(items) == 1, \"len(items) should be %d not %d\", 1, len(items))\n\t// TODO: More tests\n\texf(items[0].UID == uid, \"UID should be %d not %d\", uid, items[0].UID)\n\tex(items[0].Success, \"Success should be true\")\n\tex(items[0].IP == \"\", \"IP should be blank\")\n\tgoNone(uid, 1, 10)\n\tgoNone(uid, 0, 0)\n\n\texpectNilErr(t, c.LoginLogs.Purge())\n\tmustNone()\n}\n\nfunc TestPluginManager(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tex := exp(t)\n\n\t_, ok := c.Plugins[\"fairy-dust\"]\n\tex(!ok, \"Plugin fairy-dust shouldn't exist\")\n\tpl, ok := c.Plugins[\"bbcode\"]\n\tex(ok, \"Plugin bbcode should exist\")\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e := pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database either\")\n\thasPlugin, e := pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(!hasPlugin, \"Plugin bbcode shouldn't exist in the database\")\n\t// TODO: Add some test cases for SetActive and SetInstalled before calling AddToDatabase\n\n\texpectNilErr(t, pl.AddToDatabase(true, false))\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(pl.Active, \"Plugin bbcode should be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(active, \"Plugin bbcode should be active in the database too\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should exist in the database\")\n\tex(pl.Init != nil, \"Plugin bbcode should have an init function\")\n\texpectNilErr(t, pl.Init(pl))\n\n\texpectNilErr(t, pl.SetActive(true))\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(pl.Active, \"Plugin bbcode should still be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(active, \"Plugin bbcode should still be active in the database too\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\n\texpectNilErr(t, pl.SetActive(false))\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\tex(pl.Deactivate != nil, \"Plugin bbcode should have an init function\")\n\tpl.Deactivate(pl) // Returns nothing\n\n\t// Not installable, should not be mutated\n\tex(pl.SetInstalled(true) == c.ErrPluginNotInstallable, \"Plugin was set as installed despite not being installable\")\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database either\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\n\tex(pl.SetInstalled(false) == c.ErrPluginNotInstallable, \"Plugin was set as not installed despite not being installable\")\n\tex(!pl.Installable, \"Plugin bbcode shouldn't be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database either\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\n\t// This isn't really installable, but we want to get a few tests done before getting plugins which are stateful\n\tpl.Installable = true\n\texpectNilErr(t, pl.SetInstalled(true))\n\tex(pl.Installable, \"Plugin bbcode should be installable\")\n\tex(pl.Installed, \"Plugin bbcode should be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database either\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\n\texpectNilErr(t, pl.SetInstalled(false))\n\tex(pl.Installable, \"Plugin bbcode should be installable\")\n\tex(!pl.Installed, \"Plugin bbcode shouldn't be 'installed'\")\n\tex(!pl.Active, \"Plugin bbcode shouldn't be active\")\n\tactive, e = pl.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin bbcode shouldn't be active in the database either\")\n\thasPlugin, e = pl.InDatabase()\n\texpectNilErr(t, e)\n\tex(hasPlugin, \"Plugin bbcode should still exist in the database\")\n\n\t// Bugs sometimes arise when we try to delete a hook when there are multiple, so test for that\n\t// TODO: Do a finer grained test for that case...? A bigger test might catch more odd cases with multiple plugins\n\tpl2, ok := c.Plugins[\"markdown\"]\n\tex(ok, \"Plugin markdown should exist\")\n\tex(!pl2.Installable, \"Plugin markdown shouldn't be installable\")\n\tex(!pl2.Installed, \"Plugin markdown shouldn't be 'installed'\")\n\tex(!pl2.Active, \"Plugin markdown shouldn't be active\")\n\tactive, e = pl2.BypassActive()\n\texpectNilErr(t, e)\n\tex(!active, \"Plugin markdown shouldn't be active in the database either\")\n\thasPlugin, e = pl2.InDatabase()\n\texpectNilErr(t, e)\n\tex(!hasPlugin, \"Plugin markdown shouldn't exist in the database\")\n\n\texpectNilErr(t, pl2.AddToDatabase(true, false))\n\texpectNilErr(t, pl2.Init(pl2))\n\texpectNilErr(t, pl.SetActive(true))\n\texpectNilErr(t, pl.Init(pl))\n\tpl2.Deactivate(pl2)\n\texpectNilErr(t, pl2.SetActive(false))\n\tpl.Deactivate(pl)\n\texpectNilErr(t, pl.SetActive(false))\n\n\t// Hook tests\n\tht := func() *c.HookTable {\n\t\treturn c.GetHookTable()\n\t}\n\tex(ht().Sshook(\"haha\", \"ho\") == \"ho\", \"Sshook shouldn't have anything bound to it yet\")\n\thandle := func(in string) (out string) {\n\t\treturn in + \"hi\"\n\t}\n\tpl.AddHook(\"haha\", handle)\n\tex(ht().Sshook(\"haha\", \"ho\") == \"hohi\", \"Sshook didn't give hohi\")\n\tpl.RemoveHook(\"haha\", handle)\n\tex(ht().Sshook(\"haha\", \"ho\") == \"ho\", \"Sshook shouldn't have anything bound to it anymore\")\n\n\t/*ex(ht().Hook(\"haha\", \"ho\") == \"ho\", \"Hook shouldn't have anything bound to it yet\")\n\thandle2 := func(inI interface{}) (out interface{}) {\n\t\treturn inI.(string) + \"hi\"\n\t}\n\tpl.AddHook(\"hehe\", handle2)\n\tex(ht().Hook(\"hehe\", \"ho\").(string) == \"hohi\", \"Hook didn't give hohi\")\n\tpl.RemoveHook(\"hehe\", handle2)\n\tex(ht().Hook(\"hehe\", \"ho\").(string) == \"ho\", \"Hook shouldn't have anything bound to it anymore\")*/\n\n\t// TODO: Add tests for more hook types\n}\n\nfunc TestPhrases(t *testing.T) {\n\tgetPhrase := phrases.GetPermPhrase\n\ttp := func(name, expects string) {\n\t\tres := getPhrase(name)\n\t\texpect(t, res == expects, \"Not the expected phrase, got '\"+res+\"' instead\")\n\t}\n\ttp(\"BanUsers\", \"Can ban users\")\n\ttp(\"NoSuchPerm\", \"{lang.perms[NoSuchPerm]}\")\n\ttp(\"ViewTopic\", \"Can view topics\")\n\ttp(\"NoSuchPerm\", \"{lang.perms[NoSuchPerm]}\")\n\n\t// TODO: Cover the other phrase types, also try switching between languages to see if anything strange happens\n}\n\nfunc TestMetaStore(t *testing.T) {\n\tex, exf := exp(t), expf(t)\n\tm, e := c.Meta.Get(\"magic\")\n\tex(m == \"\", \"meta var magic should be empty\")\n\trecordMustNotExist(t, e, \"meta var magic should not exist\")\n\n\texpectVal := func(name, expect string) {\n\t\tm, e = c.Meta.Get(name)\n\t\texpectNilErr(t, e)\n\t\texf(m == expect, \"meta var %s should be %s\", name, expect)\n\t}\n\n\texpectNilErr(t, c.Meta.Set(\"magic\", \"lol\"))\n\texpectVal(\"magic\", \"lol\")\n\texpectNilErr(t, c.Meta.Set(\"magic\", \"wha\"))\n\texpectVal(\"magic\", \"wha\")\n\n\tm, e = c.Meta.Get(\"giggle\")\n\tex(m == \"\", \"meta var giggle should be empty\")\n\trecordMustNotExist(t, e, \"meta var giggle should not exist\")\n\n\texpectNilErr(t, c.Meta.SetInt(\"magic\", 1))\n\texpectVal(\"magic\", \"1\")\n\texpectNilErr(t, c.Meta.SetInt64(\"magic\", 5))\n\texpectVal(\"magic\", \"5\")\n}\n\nfunc TestPages(t *testing.T) {\n\tex := exp(t)\n\tex(c.Pages.Count() == 0, \"Page count should be 0\")\n\t_, e := c.Pages.Get(1)\n\trecordMustNotExist(t, e, \"Page 1 should not exist yet\")\n\texpectNilErr(t, c.Pages.Delete(-1))\n\texpectNilErr(t, c.Pages.Delete(0))\n\texpectNilErr(t, c.Pages.Delete(1))\n\t_, e = c.Pages.Get(1)\n\trecordMustNotExist(t, e, \"Page 1 should not exist yet\")\n\t//e = c.Pages.Reload(1)\n\t//recordMustNotExist(t,e,\"Page 1 should not exist yet\")\n\n\tipage := c.BlankCustomPage()\n\tipage.Name = \"test\"\n\tipage.Title = \"Test\"\n\tipage.Body = \"A test page\"\n\tpid, e := ipage.Create()\n\texpectNilErr(t, e)\n\tex(pid == 1, \"The first page should have an ID of 1\")\n\tex(c.Pages.Count() == 1, \"Page count should be 1\")\n\n\ttest := func(pid int, ep *c.CustomPage) {\n\t\tp, e := c.Pages.Get(pid)\n\t\texpectNilErr(t, e)\n\t\tex(p.Name == ep.Name, \"The page name should be \"+ep.Name)\n\t\tex(p.Title == ep.Title, \"The page title should be \"+ep.Title)\n\t\tex(p.Body == ep.Body, \"The page body should be \"+ep.Body)\n\t}\n\ttest(1, ipage)\n\n\topage, err := c.Pages.Get(1)\n\texpectNilErr(t, err)\n\topage.Name = \"t\"\n\topage.Title = \"T\"\n\topage.Body = \"testing\"\n\texpectNilErr(t, opage.Commit())\n\n\ttest(1, opage)\n\n\texpectNilErr(t, c.Pages.Delete(1))\n\tex(c.Pages.Count() == 0, \"Page count should be 0\")\n\t_, e = c.Pages.Get(1)\n\trecordMustNotExist(t, e, \"Page 1 should not exist\")\n\t//e = c.Pages.Reload(1)\n\t//recordMustNotExist(t,e,\"Page 1 should not exist\")\n\n\t// TODO: More tests\n}\n\nfunc TestWordFilters(t *testing.T) {\n\tex, exf := exp(t), expf(t)\n\t// TODO: Test the word filters and their store\n\tex(c.WordFilters.Length() == 0, \"Word filter list should be empty\")\n\tex(c.WordFilters.EstCount() == 0, \"Word filter list should be empty\")\n\tex(c.WordFilters.Count() == 0, \"Word filter list should be empty\")\n\tfilters, err := c.WordFilters.GetAll()\n\texpectNilErr(t, err) // TODO: Slightly confusing that we don't get ErrNoRow here\n\tex(len(filters) == 0, \"Word filter map should be empty\")\n\t// TODO: Add a test for ParseMessage relating to word filters\n\t_, err = c.WordFilters.Get(1)\n\trecordMustNotExist(t, err, \"filter 1 should not exist\")\n\n\twfid, err := c.WordFilters.Create(\"imbecile\", \"lovely\")\n\texpectNilErr(t, err)\n\tex(wfid == 1, \"The first word filter should have an ID of 1\")\n\tex(c.WordFilters.Length() == 1, \"Word filter list should not be empty\")\n\tex(c.WordFilters.EstCount() == 1, \"Word filter list should not be empty\")\n\tex(c.WordFilters.Count() == 1, \"Word filter list should not be empty\")\n\n\tftest := func(f *c.WordFilter, id int, find, replace string) {\n\t\texf(f.ID == id, \"Word filter ID should be %d, not %d\", id, f.ID)\n\t\texf(f.Find == find, \"Word filter needle should be '%s', not '%s'\", find, f.Find)\n\t\texf(f.Replace == replace, \"Word filter replacement should be '%s', not '%s'\", replace, f.Replace)\n\t}\n\n\tfilters, err = c.WordFilters.GetAll()\n\texpectNilErr(t, err)\n\tex(len(filters) == 1, \"Word filter map should not be empty\")\n\tftest(filters[1], 1, \"imbecile\", \"lovely\")\n\n\tfilter, err := c.WordFilters.Get(1)\n\texpectNilErr(t, err)\n\tftest(filter, 1, \"imbecile\", \"lovely\")\n\n\t// Update\n\texpectNilErr(t, c.WordFilters.Update(1, \"b\", \"a\"))\n\n\tex(c.WordFilters.Length() == 1, \"Word filter list should not be empty\")\n\tex(c.WordFilters.EstCount() == 1, \"Word filter list should not be empty\")\n\tex(c.WordFilters.Count() == 1, \"Word filter list should not be empty\")\n\n\tfilters, err = c.WordFilters.GetAll()\n\texpectNilErr(t, err)\n\tex(len(filters) == 1, \"Word filter map should not be empty\")\n\tftest(filters[1], 1, \"b\", \"a\")\n\n\tfilter, err = c.WordFilters.Get(1)\n\texpectNilErr(t, err)\n\tftest(filter, 1, \"b\", \"a\")\n\n\t// TODO: Add a test for ParseMessage relating to word filters\n\n\texpectNilErr(t, c.WordFilters.Delete(1))\n\n\tex(c.WordFilters.Length() == 0, \"Word filter list should be empty\")\n\tex(c.WordFilters.EstCount() == 0, \"Word filter list should be empty\")\n\tex(c.WordFilters.Count() == 0, \"Word filter list should be empty\")\n\tfilters, err = c.WordFilters.GetAll()\n\texpectNilErr(t, err) // TODO: Slightly confusing that we don't get ErrNoRow here\n\tex(len(filters) == 0, \"Word filter map should be empty\")\n\t_, err = c.WordFilters.Get(1)\n\trecordMustNotExist(t, err, \"filter 1 should not exist\")\n\n\t// TODO: Any more tests we could do?\n}\n\nfunc TestMFAStore(t *testing.T) {\n\texf := expf(t)\n\n\tmustNone := func() {\n\t\t_, e := c.MFAstore.Get(-1)\n\t\trecordMustNotExist(t, e, \"mfa uid -1 should not exist\")\n\t\t_, e = c.MFAstore.Get(0)\n\t\trecordMustNotExist(t, e, \"mfa uid 0 should not exist\")\n\t\t_, e = c.MFAstore.Get(1)\n\t\trecordMustNotExist(t, e, \"mfa uid 1 should not exist\")\n\t}\n\tmustNone()\n\n\tsecret, e := c.GenerateGAuthSecret()\n\texpectNilErr(t, e)\n\texpectNilErr(t, c.MFAstore.Create(secret, 1))\n\t_, e = c.MFAstore.Get(0)\n\trecordMustNotExist(t, e, \"mfa uid 0 should not exist\")\n\tvar scratches []string\n\tit, e := c.MFAstore.Get(1)\n\ttest := func(j int) {\n\t\texpectNilErr(t, e)\n\t\texf(it.UID == 1, \"UID should be 1 not %d\", it.UID)\n\t\texf(it.Secret == secret, \"Secret should be '%s' not %s\", secret, it.Secret)\n\t\texf(len(it.Scratch) == 8, \"Scratch should be 8 not %d\", len(it.Scratch))\n\t\tfor i, scratch := range it.Scratch {\n\t\t\texf(scratch != \"\", \"scratch %d should not be empty\", i)\n\t\t\tif scratches != nil {\n\t\t\t\tif j == i {\n\t\t\t\t\texf(scratches[i] != scratch, \"scratches[%d] should not be %s\", i, scratches[i])\n\t\t\t\t} else {\n\t\t\t\t\texf(scratches[i] == scratch, \"scratches[%d] should be %s not %s\", i, scratches[i], scratch)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tscratches = make([]string, 8)\n\t\tcopy(scratches, it.Scratch)\n\t}\n\ttest(0)\n\tfor i := 0; i < len(scratches); i++ {\n\t\texpectNilErr(t, it.BurnScratch(i))\n\t\tit, e = c.MFAstore.Get(1)\n\t\ttest(i)\n\t}\n\ttoken, e := gauth.GetTOTPToken(secret)\n\texpectNilErr(t, e)\n\texpectNilErr(t, c.Auth.ValidateMFAToken(token, 1))\n\texpectNilErr(t, it.Delete())\n\tmustNone()\n}\n\n// TODO: Expand upon the valid characters which can go in URLs?\nfunc TestSlugs(t *testing.T) {\n\tl := &MEPairList{nil}\n\tc.Config.BuildSlugs = true // Flip this switch, otherwise all the tests will fail\n\n\tl.Add(\"Unknown\", \"unknown\")\n\tl.Add(\"Unknown2\", \"unknown2\")\n\tl.Add(\"Unknown \", \"unknown\")\n\tl.Add(\"Unknown 2\", \"unknown-2\")\n\tl.Add(\"Unknown  2\", \"unknown-2\")\n\tl.Add(\"Admin Alice\", \"admin-alice\")\n\tl.Add(\"Admin_Alice\", \"adminalice\")\n\tl.Add(\"Admin_Alice-\", \"adminalice\")\n\tl.Add(\"-Admin_Alice-\", \"adminalice\")\n\tl.Add(\"-Admin@Alice-\", \"adminalice\")\n\tl.Add(\"-Admin😀Alice-\", \"adminalice\")\n\tl.Add(\"u\", \"u\")\n\tl.Add(\"\", \"untitled\")\n\tl.Add(\" \", \"untitled\")\n\tl.Add(\"-\", \"untitled\")\n\tl.Add(\"--\", \"untitled\")\n\tl.Add(\"é\", \"é\")\n\tl.Add(\"-é-\", \"é\")\n\tl.Add(\"-你好-\", \"untitled\")\n\tl.Add(\"-こにちは-\", \"untitled\")\n\n\tfor _, item := range l.Items {\n\t\tt.Log(\"Testing string '\" + item.Msg + \"'\")\n\t\tres := c.NameToSlug(item.Msg)\n\t\tif res != item.Expects {\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", item.Expects)\n\t\t}\n\t}\n}\n\nfunc TestWidgets(t *testing.T) {\n\tex, exf := exp(t), expf(t)\n\t_, e := c.Widgets.Get(1)\n\trecordMustNotExist(t, e, \"There shouldn't be any widgets by default\")\n\twidgets := c.Docks.RightSidebar.Items\n\texf(len(widgets) == 0, \"RightSidebar should have 0 items, not %d\", len(widgets))\n\n\twidget := &c.Widget{Position: 0, Side: \"rightSidebar\", Type: \"simple\", Enabled: true, Location: \"global\"}\n\tewidget := &c.WidgetEdit{widget, map[string]string{\"Name\": \"Test\", \"Text\": \"Testing\"}}\n\twid, e := ewidget.Create()\n\texpectNilErr(t, e)\n\tex(wid == 1, \"wid should be 1\")\n\n\twtest := func(w, w2 *c.Widget) {\n\t\tex(w.Position == w2.Position, \"wrong position\")\n\t\tex(w.Side == w2.Side, \"wrong side\")\n\t\tex(w.Type == w2.Type, \"wrong type\")\n\t\tex(w.Enabled == w2.Enabled, \"wrong enabled\")\n\t\tex(w.Location == w2.Location, \"wrong location\")\n\t}\n\n\t// TODO: Do a test for the widget body\n\twidget2, e := c.Widgets.Get(1)\n\texpectNilErr(t, e)\n\twtest(widget, widget2)\n\n\twidgets = c.Docks.RightSidebar.Items\n\texf(len(widgets) == 1, \"RightSidebar should have 1 item, not %d\", len(widgets))\n\twtest(widget, widgets[0])\n\n\twidget2.Enabled = false\n\tewidget = &c.WidgetEdit{widget2, map[string]string{\"Name\": \"Test\", \"Text\": \"Testing\"}}\n\texpectNilErr(t, ewidget.Commit())\n\n\twidget2, e = c.Widgets.Get(1)\n\texpectNilErr(t, e)\n\twidget.Enabled = false\n\twtest(widget, widget2)\n\n\twidgets = c.Docks.RightSidebar.Items\n\texf(len(widgets) == 1, \"RightSidebar should have 1 item, not %d\", len(widgets))\n\twidget.Enabled = false\n\twtest(widget, widgets[0])\n\n\texpectNilErr(t, widget2.Delete())\n\n\t_, e = c.Widgets.Get(1)\n\trecordMustNotExist(t, e, \"There shouldn't be any widgets anymore\")\n\twidgets = c.Docks.RightSidebar.Items\n\texf(len(widgets) == 0, \"RightSidebar should have 0 items, not %d\", len(widgets))\n}\n\n/*type ForumActionStoreInt interface {\n\tGet(faid int) (*ForumAction, error)\n\tGetInForum(fid int) ([]*ForumAction, error)\n\tGetAll() ([]*ForumAction, error)\n\tGetNewTopicActions(fid int) ([]*ForumAction, error)\n\n\tAdd(fa *ForumAction) (int, error)\n\tDelete(faid int) error\n\tExists(faid int) bool\n\tCount() int\n\tCountInForum(fid int) int\n\n\tDailyTick() error\n}*/\n\nfunc TestForumActions(t *testing.T) {\n\tex, exf, s := exp(t), expf(t), c.ForumActionStore\n\n\tcount := s.CountInForum(-1)\n\texf(count == 0, \"count should be %d not %d\", 0, count)\n\tcount = s.CountInForum(0)\n\texf(count == 0, \"count in 0 should be %d not %d\", 0, count)\n\tex(!s.Exists(-1), \"faid -1 should not exist\")\n\tex(!s.Exists(0), \"faid 0 should not exist\")\n\t_, e := s.Get(-1)\n\trecordMustNotExist(t, e, \"faid -1 should not exist\")\n\t_, e = s.Get(0)\n\trecordMustNotExist(t, e, \"faid 0 should not exist\")\n\n\tnoActions := func(fid, faid int) {\n\t\t/*sfid, */ sfaid := /*strconv.Itoa(fid), */ strconv.Itoa(faid)\n\t\tcount := s.Count()\n\t\texf(count == 0, \"count should be %d not %d\", 0, count)\n\t\tcount = s.CountInForum(fid)\n\t\texf(count == 0, \"count in %d should be %d not %d\", fid, 0, count)\n\t\texf(!s.Exists(faid), \"faid %d should not exist\", faid)\n\t\t_, e := s.Get(faid)\n\t\trecordMustNotExist(t, e, \"faid \"+sfaid+\" should not exist\")\n\t\t//exf(fa == nil, \"fa should be nil not %+v\", fa)\n\t\tfas, e := s.GetInForum(fid)\n\t\t//recordMustNotExist(t, e, \"fid \"+sfid+\" should not have any actions\")\n\t\texpectNilErr(t, e) // TODO: Why does this not return ErrNoRows?\n\t\texf(len(fas) == 0, \"len(fas) should be %d not %d\", 0, len(fas))\n\t\tfas, e = s.GetAll()\n\t\t//recordMustNotExist(t, e, \"there should not be any actions\")\n\t\texpectNilErr(t, e) // TODO: Why does this not return ErrNoRows?\n\t\texf(len(fas) == 0, \"len(fas) should be %d not %d\", 0, len(fas))\n\t\tfas, e = s.GetNewTopicActions(fid)\n\t\t//recordMustNotExist(t, e, \"fid \"+sfid+\" should not have any new topic actions\")\n\t\texpectNilErr(t, e) // TODO: Why does this not return ErrNoRows?\n\t\texf(len(fas) == 0, \"len(fas) should be %d not %d\", 0, len(fas))\n\t}\n\tnoActions(1, 1)\n\n\tfid, e := c.Forums.Create(\"Forum Action Test\", \"Forum Action Test\", true, \"\")\n\texpectNilErr(t, e)\n\tnoActions(fid, 1)\n\n\tfaid, e := c.ForumActionStore.Add(&c.ForumAction{\n\t\tForum:                      fid,\n\t\tRunOnTopicCreation:         false,\n\t\tRunDaysAfterTopicCreation:  1,\n\t\tRunDaysAfterTopicLastReply: 0,\n\t\tAction:                     c.ForumActionLock,\n\t\tExtra:                      \"\",\n\t})\n\texpectNilErr(t, e)\n\texf(faid == 1, \"faid should be %d not %d\", 1, faid)\n\tcount = s.Count()\n\texf(count == 1, \"count should be %d not %d\", 1, count)\n\tcount = s.CountInForum(fid)\n\texf(count == 1, \"count in %d should be %d not %d\", fid, 1, count)\n\texf(s.Exists(faid), \"faid %d should exist\", faid)\n\n\tfa, e := s.Get(faid)\n\texpectNilErr(t, e)\n\texf(fa.ID == faid, \"fa.ID should be %d not %d\", faid, fa.ID)\n\texf(fa.Forum == fid, \"fa.Forum should be %d not %d\", fid, fa.Forum)\n\texf(fa.RunOnTopicCreation == false, \"fa.RunOnTopicCreation should be false\")\n\texf(fa.RunDaysAfterTopicCreation == 1, \"fa.RunDaysAfterTopicCreation should be %d not %d\", 1, fa.RunDaysAfterTopicCreation)\n\texf(fa.RunDaysAfterTopicLastReply == 0, \"fa.RunDaysAfterTopicLastReply should be %d not %d\", 0, fa.RunDaysAfterTopicLastReply)\n\texf(fa.Action == c.ForumActionLock, \"fa.Action should be %d not %d\", c.ForumActionLock, fa.Action)\n\texf(fa.Extra == \"\", \"fa.Extra should be '%s' not '%s'\", \"\", fa.Extra)\n\n\ttid, e := c.Topics.Create(fid, \"Forum Action Topic\", \"Forum Action Topic\", 1, \"\")\n\texpectNilErr(t, e)\n\ttopic, e := c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\tex(!topic.IsClosed, \"topic.IsClosed should be false\")\n\tdayAgo := time.Now().AddDate(0, 0, -5)\n\texpectNilErr(t, topic.TestSetCreatedAt(dayAgo))\n\texpectNilErr(t, fa.Run())\n\ttopic, e = c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\tex(topic.IsClosed, \"topic.IsClosed should be true\")\n\t/*_, e = c.Rstore.Create(topic, \"Forum Action Reply\", \"\", 1)\n\texpectNilErr(t, e)*/\n\n\ttid, e = c.Topics.Create(fid, \"Forum Action Topic 2\", \"Forum Action Topic 2\", 1, \"\")\n\texpectNilErr(t, e)\n\ttopic, e = c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\tex(!topic.IsClosed, \"topic.IsClosed should be false\")\n\texpectNilErr(t, fa.Run())\n\ttopic, e = c.Topics.Get(tid)\n\texpectNilErr(t, e)\n\tex(!topic.IsClosed, \"topic.IsClosed should be false\")\n\n\t_ = tid\n\n\texpectNilErr(t, s.Delete(faid))\n\tnoActions(fid, faid)\n\n\t// TODO: Bulk lock tests\n\tfaid, e = c.ForumActionStore.Add(&c.ForumAction{\n\t\tForum:                      fid,\n\t\tRunOnTopicCreation:         false,\n\t\tRunDaysAfterTopicCreation:  2,\n\t\tRunDaysAfterTopicLastReply: 0,\n\t\tAction:                     c.ForumActionLock,\n\t\tExtra:                      \"\",\n\t})\n\texpectNilErr(t, e)\n\n\tvar l []int\n\taddTopic := func() {\n\t\ttid, e = c.Topics.Create(fid, \"Forum Action Topic 2\", \"Forum Action Topic 2\", 1, \"\")\n\t\texpectNilErr(t, e)\n\t\ttopic, e := c.Topics.Get(tid)\n\t\texpectNilErr(t, e)\n\t\tex(!topic.IsClosed, \"topic.IsClosed should be false\")\n\t\tdayAgo := time.Now().AddDate(0, 0, -5)\n\t\texpectNilErr(t, topic.TestSetCreatedAt(dayAgo))\n\t\tl = append(l, tid)\n\t}\n\tlTest := func() {\n\t\tfor _, ll := range l {\n\t\t\tto, e := c.Topics.Get(ll)\n\t\t\texpectNilErr(t, e)\n\t\t\tex(to.IsClosed, \"to.IsClosed should be true\")\n\t\t}\n\t\tl = nil\n\t}\n\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\texpectNilErr(t, fa.Run())\n\tlTest()\n\t// TODO: Create a method on the *ForumAction to get the count of topics which it could be run on and add a test to verify the count is as expected.\n\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\taddTopic()\n\texpectNilErr(t, fa.Run())\n\tlTest()\n\t// TODO: Create a method on the *ForumAction to get the count of topics which it could be run on and add a test to verify the count is as expected.\n}\n\nfunc TestTopicList(t *testing.T) {\n\tex, exf := exp(t), expf(t)\n\tfid, err := c.Forums.Create(\"Test Forum\", \"Desc for test forum\", true, \"\")\n\texpectNilErr(t, err)\n\ttint := c.TopicList.(c.TopicListIntTest)\n\n\ttestPagi := func(p c.Paginator, pageList []int, page, lastPage int) {\n\t\texf(len(p.PageList) == len(pageList), \"len(pagi.PageList) should be %d not %d\", len(pageList), len(p.PageList))\n\t\tfor i, page := range pageList {\n\t\t\texf(p.PageList[i] == page, \"pagi.PageList[%d] should be %d not %d\", i, page, p.PageList[i])\n\t\t}\n\t\texf(p.Page == page, \"pagi.Page should be %d not %d\", page, p.Page)\n\t\texf(p.LastPage == lastPage, \"pagi.LastPage should be %d not %d\", lastPage, p.LastPage)\n\t}\n\ttest := func(topicList []*c.TopicsRow, pagi c.Paginator, listLen int, pagi2 c.Paginator, tid1 int) {\n\t\texf(len(topicList) == listLen, \"len(topicList) should be %d not %d\", listLen, len(topicList))\n\t\tif len(topicList) > 0 {\n\t\t\ttopic := topicList[0]\n\t\t\texf(topic.ID == tid1, \"topic.ID should be %d not %d\", tid1, topic.ID)\n\t\t}\n\t\ttestPagi(pagi, pagi2.PageList, pagi2.Page, pagi2.LastPage)\n\t}\n\tnoTopics := func(topicList []*c.TopicsRow, pagi c.Paginator) {\n\t\texf(len(topicList) == 0, \"len(topicList) should be 0 not %d\", len(topicList))\n\t\ttestPagi(pagi, []int{}, 1, 1)\n\t}\n\tnoTopicsOnPage2 := func(topicList []*c.TopicsRow, pagi c.Paginator) {\n\t\texf(len(topicList) == 0, \"len(topicList) should be 0 not %d\", len(topicList))\n\t\ttestPagi(pagi, []int{1}, 2, 1)\n\t}\n\n\tforum, err := c.Forums.Get(fid)\n\texpectNilErr(t, err)\n\n\tisAdmin, isMod, isBanned := false, false, false\n\tgid, err := c.Groups.Create(\"Topic List Test\", \"Test\", isAdmin, isMod, isBanned)\n\texpectNilErr(t, err)\n\tex(c.Groups.Exists(gid), \"The group we just made doesn't exist\")\n\n\tfp, err := c.FPStore.GetCopy(fid, gid)\n\tif err == sql.ErrNoRows {\n\t\tfp = *c.BlankForumPerms()\n\t} else if err != nil {\n\t\texpectNilErr(t, err)\n\t}\n\tfp.ViewTopic = true\n\n\tforum, err = c.Forums.Get(fid)\n\texpectNilErr(t, err)\n\texpectNilErr(t, forum.SetPerms(&fp, \"custom\", gid))\n\n\tg, err := c.Groups.Get(gid)\n\texpectNilErr(t, err)\n\n\tnoTopicsTests := func() {\n\t\trr := func(page, orderby int) {\n\t\t\ttopicList, forumList, pagi, err := c.TopicList.GetListByGroup(g, page, orderby, []int{fid})\n\t\t\texpectNilErr(t, err)\n\t\t\tnoTopics(topicList, pagi)\n\t\t\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\t\t}\n\t\trr(1, 0)\n\t\trr(2, 0)\n\t\trr(1, 1)\n\t\trr(2, 1)\n\n\t\ttopicList, pagi, err := c.TopicList.GetListByForum(forum, 1, 0)\n\t\texpectNilErr(t, err)\n\t\tnoTopics(topicList, pagi)\n\n\t\ttopicList, pagi, err = tint.RawGetListByForum(forum, 1, 0)\n\t\texpectNilErr(t, err)\n\t\tnoTopics(topicList, pagi)\n\n\t\ttopicList, forumList, pagi, err := c.TopicList.GetList(1, 0, []int{fid})\n\t\texpectNilErr(t, err)\n\t\tnoTopics(topicList, pagi)\n\t\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\t\ttopicList, forumList, pagi, err = c.TopicList.GetListByCanSee([]int{fid}, 1, 0, []int{fid})\n\t\texpectNilErr(t, err)\n\t\tnoTopics(topicList, pagi)\n\t\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\t\ttopicList, forumList, pagi, err = c.TopicList.GetListByCanSee([]int{}, 1, 0, []int{fid})\n\t\texpectNilErr(t, err)\n\t\tnoTopics(topicList, pagi)\n\t\t// TODO: Why is there a discrepency between this and GetList()?\n\t\texf(len(forumList) == 0, \"len(forumList) should be 0 not %d\", len(forumList))\n\t}\n\tnoTopicsTests()\n\n\ttid, err := c.Topics.Create(fid, \"New Topic\", \"New Topic Body\", 1, \"\")\n\texpectNilErr(t, err)\n\n\ttopicList, forumList, pagi, err := c.TopicList.GetListByGroup(g, 1, 0, []int{fid})\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetListByGroup(g, 2, 0, []int{fid})\n\texpectNilErr(t, err)\n\tnoTopics(topicList, pagi)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetListByGroup(g, 1, 1, []int{fid})\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetListByGroup(g, 2, 1, []int{fid})\n\texpectNilErr(t, err)\n\tnoTopicsOnPage2(topicList, pagi)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, pagi, err = tint.RawGetListByForum(forum, 1, 0)\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\n\ttopicList, pagi, err = tint.RawGetListByForum(forum, 0, 0)\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\n\texpectNilErr(t, tint.Tick())\n\tforum, err = c.Forums.Get(fid)\n\texpectNilErr(t, err)\n\ttopicList, pagi, err = c.TopicList.GetListByForum(forum, 1, 0)\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetList(1, 0, []int{fid})\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetListByCanSee([]int{fid}, 1, 0, []int{fid})\n\texpectNilErr(t, err)\n\ttest(topicList, pagi, 1, c.Paginator{[]int{1}, 1, 1}, tid)\n\texf(len(forumList) == 1, \"len(forumList) should be 1 not %d\", len(forumList))\n\n\ttopicList, forumList, pagi, err = c.TopicList.GetListByCanSee([]int{}, 1, 0, []int{fid})\n\texpectNilErr(t, err)\n\tnoTopics(topicList, pagi)\n\texf(len(forumList) == 0, \"len(forumList) should be 0 not %d\", len(forumList))\n\n\ttopic, err := c.Topics.Get(tid)\n\texpectNilErr(t, err)\n\texpectNilErr(t, topic.Delete())\n\n\tforum, err = c.Forums.Get(fid)\n\texpectNilErr(t, err)\n\tnoTopicsTests()\n\n\t// TODO: More tests\n\n\t_ = ex\n}\n\nfunc TestUtils(t *testing.T) {\n\tee := func(email, eemail string) {\n\t\tcemail := c.CanonEmail(email)\n\t\texpectf(t, cemail == eemail, \"%s should be %s\", cemail, eemail)\n\t}\n\tee(\"test@example.com\", \"test@example.com\")\n\tee(\"test.test@example.com\", \"test.test@example.com\")\n\tee(\"\", \"\")\n\tee(\"ddd\", \"ddd\")\n\tee(\"test.test@gmail.com\", \"testtest@gmail.com\")\n\tee(\"TEST.test@gmail.com\", \"testtest@gmail.com\")\n\tee(\"test.TEST.test@gmail.com\", \"testtesttest@gmail.com\")\n\tee(\"test..TEST.test@gmail.com\", \"testtesttest@gmail.com\")\n\tee(\"TEST.test@example.com\", \"test.test@example.com\")\n\tee(\"test.TEST.test@example.com\", \"test.test.test@example.com\")\n\t// TODO: Exotic unicode email types? Are there those?\n\n\t// TODO: More utils.go tests\n}\n\nfunc TestWeakPassword(t *testing.T) {\n\tex := exp(t)\n\t/*weakPass := func(password, name, email string) func(error,string,...interface{}) {\n\t\terr := c.WeakPassword(password, name, email)\n\t\treturn func(expectErr error, m string, p ...interface{}) {\n\t\t\tm = fmt.Sprintf(\"pass=%s, user=%s, email=%s \", password, name, email) + m\n\t\t\texpect(t, err == expectErr, fmt.Sprintf(m,p...))\n\t\t}\n\t}*/\n\tnilErrStr := func(e error) error {\n\t\tif e == nil {\n\t\t\te = errors.New(\"nil\")\n\t\t}\n\t\treturn e\n\t}\n\tweakPass := func(password, name, email string) func(error) {\n\t\terr := c.WeakPassword(password, name, email)\n\t\te := nilErrStr(err)\n\t\tm := fmt.Sprintf(\"pass=%s, user=%s, email=%s \", password, name, email)\n\t\treturn func(expectErr error) {\n\t\t\tee := nilErrStr(expectErr)\n\t\t\tex(err == expectErr, m+fmt.Sprintf(\"err should be '%s' not '%s'\", ee, e))\n\t\t}\n\t}\n\n\t//weakPass(\"test\", \"test\", \"test@example.com\")(c.ErrWeakPasswordContains,\"err should be ErrWeakPasswordContains not '%s'\")\n\tweakPass(\"\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNone)\n\tweakPass(\"test\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordShort)\n\tweakPass(\"testtest\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordContains)\n\tweakPass(\"testdraw\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNameInPass)\n\tweakPass(\"test@example.com\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordEmailInPass)\n\tweakPass(\"meet@example.com2\", \"draw\", \"\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"Meet@example.com2\", \"draw\", \"\")(nil)\n\tweakPass(\"test2\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordShort)\n\tweakPass(\"test22222222\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordContains)\n\tweakPass(\"superman\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordCommon)\n\tweakPass(\"Superman\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordCommon)\n\tweakPass(\"Superma2\", \"draw\", \"test@example.com\")(nil)\n\tweakPass(\"superman2\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordCommon)\n\tweakPass(\"Superman2\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordCommon)\n\tweakPass(\"superman22\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"K\\\\@<^s}1\", \"draw\", \"test@example.com\")(nil)\n\tweakPass(\"K\\\\@<^s}r\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoNumbers)\n\tweakPass(\"k\\\\@<^s}1\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"aaaaaaaa\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"aA1aA1aA1\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordUniqueChars)\n\tweakPass(\"abababab\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"11111111111111111111\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordNoUpper)\n\tweakPass(\"aaaaaaaaaaAAAAAAAAAA\", \"draw\", \"test@example.com\")(c.ErrWeakPasswordUniqueChars)\n\tweakPass(\"-:u/nMxb,A!n=B;H\\\\sjM\", \"draw\", \"test@example.com\")(nil)\n}\n\nfunc TestAuth(t *testing.T) {\n\tex := exp(t)\n\t// bcrypt likes doing stupid things, so this test will probably fail\n\trealPassword := \"Madame Cassandra's Mystic Orb\"\n\tt.Logf(\"Set realPassword to '%s'\", realPassword)\n\tt.Log(\"Hashing the real password with bcrypt\")\n\thashedPassword, _, err := c.BcryptGeneratePassword(realPassword)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tpasswordTest(t, realPassword, hashedPassword)\n\t// TODO: Peek at the prefix to verify this is a bcrypt hash\n\n\tt.Log(\"Hashing the real password\")\n\thashedPassword2, _, err := c.GeneratePassword(realPassword)\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tpasswordTest(t, realPassword, hashedPassword2)\n\t// TODO: Peek at the prefix to verify this is a bcrypt hash\n\n\t_, err, _ = c.Auth.Authenticate(\"None\", \"password\")\n\terrmsg := \"Name None shouldn't exist\"\n\tif err != nil {\n\t\terrmsg += \"\\n\" + err.Error()\n\t}\n\tex(err == c.ErrNoUserByName, errmsg)\n\n\tuid, err, _ := c.Auth.Authenticate(\"Admin\", \"password\")\n\texpectNilErr(t, err)\n\texpectf(t, uid == 1, \"Default admin uid should be 1 not %d\", uid)\n\n\t_, err, _ = c.Auth.Authenticate(\"Sam\", \"ReallyBadPassword\")\n\terrmsg = \"Name Sam shouldn't exist\"\n\tif err != nil {\n\t\terrmsg += \"\\n\" + err.Error()\n\t}\n\tex(err == c.ErrNoUserByName, errmsg)\n\n\tadmin, err := c.Users.Get(1)\n\texpectNilErr(t, err)\n\t// TODO: Move this into the user store tests to provide better coverage? E.g. To see if the installer and the user creator initialise the field differently\n\tex(admin.Session == \"\", \"Admin session should be blank\")\n\n\tsession, err := c.Auth.CreateSession(1)\n\texpectNilErr(t, err)\n\tex(session != \"\", \"Admin session shouldn't be blank\")\n\t// TODO: Test the actual length set in the setting in addition to this \"too short\" test\n\t// TODO: We might be able to push up this minimum requirement\n\tex(len(session) > 10, \"Admin session shouldn't be too short\")\n\tex(admin.Session != session, \"Old session should not match new one\")\n\tadmin, err = c.Users.Get(1)\n\texpectNilErr(t, err)\n\tex(admin.Session == session, \"Sessions should match\")\n\n\t// TODO: Create a user with a unicode password and see if we can login as them\n\t// TODO: Tests for SessionCheck, GetCookies, and ForceLogout\n\t// TODO: Tests for MFA Verification\n}\n\n// TODO: Vary the salts? Keep in mind that some algorithms store the salt in the hash therefore the salt string may be blank\nfunc passwordTest(t *testing.T, realPassword, hashedPassword string) {\n\tif len(hashedPassword) < 10 {\n\t\tt.Error(\"Hash too short\")\n\t}\n\tsalt := \"\"\n\tpassword := realPassword\n\tt.Logf(\"Testing password '%s'\", password)\n\tt.Logf(\"Testing salt '%s'\", salt)\n\terr := c.CheckPassword(hashedPassword, password, salt)\n\tif err == c.ErrMismatchedHashAndPassword {\n\t\tt.Error(\"The two don't match\")\n\t} else if err == c.ErrPasswordTooLong {\n\t\tt.Error(\"CheckPassword thinks the password is too long\")\n\t} else if err != nil {\n\t\tt.Error(err)\n\t}\n\n\tpassword = \"hahaha\"\n\tt.Logf(\"Testing password '%s'\", password)\n\tt.Logf(\"Testing salt '%s'\", salt)\n\terr = c.CheckPassword(hashedPassword, password, salt)\n\tif err == c.ErrPasswordTooLong {\n\t\tt.Error(\"CheckPassword thinks the password is too long\")\n\t} else if err == nil {\n\t\tt.Error(\"The two shouldn't match!\")\n\t}\n\n\tpassword = \"Madame Cassandra's Mystic\"\n\tt.Logf(\"Testing password '%s'\", password)\n\tt.Logf(\"Testing salt '%s'\", salt)\n\terr = c.CheckPassword(hashedPassword, password, salt)\n\texpect(t, err != c.ErrPasswordTooLong, \"CheckPassword thinks the password is too long\")\n\texpect(t, err != nil, \"The two shouldn't match!\")\n}\n\nfunc TestUserPrivacy(t *testing.T) {\n\tpu, u := c.BlankUser(), &c.GuestUser\n\tpu.ID = 1\n\tex, exf := exp(t), expf(t)\n\tex(!pu.Privacy.NoPresence, \"pu.Privacy.NoPresence should be false\")\n\tex(!u.Privacy.NoPresence, \"u.Privacy.NoPresence should be false\")\n\n\tvar msg string\n\ttest := func(expects bool, level int) {\n\t\tpu.Privacy.ShowComments = level\n\t\tval := c.PrivacyCommentsShow(pu, u)\n\t\tvar bit string\n\t\tif !expects {\n\t\t\tbit = \" not\"\n\t\t\tval = !val\n\t\t}\n\t\texf(val, \"%s should%s be able to see comments on level %d\", msg, bit, level)\n\t}\n\t// 0 = default, 1 = public, 2 = registered, 3 = friends, 4 = self, 5 = disabled\n\n\tmsg = \"guest users\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(false, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\n\tu = c.BlankUser()\n\tmsg = \"blank users\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(false, 2)\n\t//test(false,3)\n\ttest(false, 4)\n\ttest(false, 5)\n\n\tu.Loggedin = true\n\tmsg = \"registered users\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\n\tu.IsBanned = true\n\tmsg = \"banned users\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\tu.IsBanned = false\n\n\tu.IsMod = true\n\tmsg = \"mods\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\tu.IsMod = false\n\n\tu.IsSuperMod = true\n\tmsg = \"super mods\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\tu.IsSuperMod = false\n\n\tu.IsAdmin = true\n\tmsg = \"admins\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\tu.IsAdmin = false\n\n\tu.IsSuperAdmin = true\n\tmsg = \"super admins\"\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(false, 3)\n\ttest(false, 4)\n\ttest(false, 5)\n\tu.IsSuperAdmin = false\n\n\tu.ID = 1\n\ttest(true, 0)\n\ttest(true, 1)\n\ttest(true, 2)\n\ttest(true, 3)\n\ttest(true, 4)\n\ttest(false, 5)\n}\n\ntype METri struct {\n\tName    string // Optional, this is here for tests involving invisible characters so we know what's going in\n\tMsg     string\n\tExpects string\n}\n\ntype METriList struct {\n\tItems []METri\n}\n\nfunc (l *METriList) Add(args ...string) {\n\tif len(args) < 2 {\n\t\tpanic(\"need 2 or more args\")\n\t}\n\tif len(args) > 2 {\n\t\tl.Items = append(l.Items, METri{args[0], args[1], args[2]})\n\t} else {\n\t\tl.Items = append(l.Items, METri{\"\", args[0], args[1]})\n\t}\n}\n\ntype CountTest struct {\n\tName    string\n\tMsg     string\n\tExpects int\n}\n\ntype CountTestList struct {\n\tItems []CountTest\n}\n\nfunc (l *CountTestList) Add(name, msg string, expects int) {\n\tl.Items = append(l.Items, CountTest{name, msg, expects})\n}\n\nfunc TestWordCount(t *testing.T) {\n\tl := &CountTestList{nil}\n\tl.Add(\"blank\", \"\", 0)\n\tl.Add(\"single-letter\", \"h\", 1)\n\tl.Add(\"single-kana\", \"お\", 1)\n\tl.Add(\"single-letter-words\", \"h h\", 2)\n\tl.Add(\"two-letter\", \"h\", 1)\n\tl.Add(\"two-kana\", \"おは\", 1)\n\tl.Add(\"two-letter-words\", \"hh hh\", 2)\n\tl.Add(\"\", \"h,h\", 2)\n\tl.Add(\"\", \"h,,h\", 2)\n\tl.Add(\"\", \"h, h\", 2)\n\tl.Add(\"\", \"  h, h\", 2)\n\tl.Add(\"\", \"h, h  \", 2)\n\tl.Add(\"\", \"  h, h  \", 2)\n\tl.Add(\"\", \"h,  h\", 2)\n\tl.Add(\"\", \"h\\nh\", 2)\n\tl.Add(\"\", \"h\\\"h\", 2)\n\tl.Add(\"\", \"h[r]h\", 3)\n\tl.Add(\"\", \"お,お\", 2)\n\tl.Add(\"\", \"お、お\", 2)\n\tl.Add(\"\", \"お\\nお\", 2)\n\tl.Add(\"\", \"お”お\", 2)\n\tl.Add(\"\", \"お「あ」お\", 3)\n\n\tfor _, item := range l.Items {\n\t\tres := c.WordCount(item.Msg)\n\t\tif res != item.Expects {\n\t\t\tif item.Name != \"\" {\n\t\t\t\tt.Error(\"Name: \", item.Name)\n\t\t\t}\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", res)\n\t\t\tt.Error(\"Expected:\", item.Expects)\n\t\t}\n\t}\n}\n\nfunc TestTick(t *testing.T) {\n\texpectNilErr(t, c.StartupTasks())\n\texpectNilErr(t, c.Dailies())\n\n\texpectNilErr(t, c.Tasks.HalfSec.Run())\n\texpectNilErr(t, c.Tasks.Sec.Run())\n\texpectNilErr(t, c.Tasks.FifteenMin.Run())\n\texpectNilErr(t, c.Tasks.Hour.Run())\n\texpectNilErr(t, c.Tasks.Day.Run())\n\n\tthumbChan := make(chan bool)\n\texpectNilErr(t, tickLoop(thumbChan))\n\texpectNilErr(t, c.CTickLoop.HalfSecf())\n\texpectNilErr(t, c.CTickLoop.Secf())\n\texpectNilErr(t, c.CTickLoop.FifteenMinf())\n\texpectNilErr(t, c.CTickLoop.Hourf())\n\texpectNilErr(t, c.CTickLoop.Dayf())\n}\n\nfunc TestWSHub(t *testing.T) {\n\tex, exf, h := exp(t), expf(t), &c.WsHub\n\texf(h.GuestCount() == 0, \"GuestCount should be %d not %d\", 0, h.GuestCount())\n\texf(h.UserCount() == 0, \"UserCount should be %d not %d\", 0, h.UserCount())\n\tex(!h.HasUser(-1), \"HasUser(-1) should be false\")\n\tex(!h.HasUser(0), \"HasUser(0) should be false\")\n\tex(!h.HasUser(1), \"HasUser(1) should be false\")\n\n\tuid, e := c.Users.Create(\"WsHub Test\", \"WsHub Test\", \"\", 1, true)\n\texpectNilErr(t, e)\n\texf(!h.HasUser(uid), \"HasUser(%d) should be false\", uid)\n\texf(len(h.AllUsers()) == 0, \"len(AllUsers()) should be %d not %d\", 0, len(h.AllUsers()))\n\n\tf := func(uid, guestCount, userCount, allUserListLen int, hasUser bool) {\n\t\texf(h.GuestCount() == guestCount, \"GuestCount should be %d not %d\", guestCount, h.GuestCount())\n\t\texf(h.UserCount() == userCount, \"UserCount should be %d not %d\", userCount, h.UserCount())\n\t\texf(len(h.AllUsers()) == allUserListLen, \"len(AllUsers()) should be %d not %d\", allUserListLen, len(h.AllUsers()))\n\t\tif hasUser {\n\t\t\texf(h.HasUser(uid), \"HasUser(%d) should be true\", uid)\n\t\t} else {\n\t\t\texf(!h.HasUser(uid), \"HasUser(%d) should be false\", uid)\n\t\t}\n\t}\n\n\tu, e := c.Users.Get(uid)\n\texpectNilErr(t, e)\n\twsUser, e := h.AddConn(u, nil)\n\texpectNilErr(t, e)\n\tf(uid, 0, 1, 1, true)\n\n\tuid, e = c.Users.Create(\"WsHub Test 2\", \"WsHub Test 2\", \"\", 1, true)\n\texpectNilErr(t, e)\n\tu2, e := c.Users.Get(uid)\n\texpectNilErr(t, e)\n\twsUser2, e := h.AddConn(u2, nil)\n\texpectNilErr(t, e)\n\tf(uid, 0, 2, 2, true)\n\n\th.RemoveConn(wsUser2, nil)\n\tf(uid, 0, 1, 1, false)\n\th.RemoveConn(wsUser2, nil)\n\tf(uid, 0, 1, 1, false)\n\th.RemoveConn(wsUser, nil)\n\tf(uid, 0, 0, 0, false)\n\n\tcountSockets := func(wsUser *c.WSUser, expect int) {\n\t\texf(wsUser.CountSockets() == expect, \"CountSockets() should be %d not %d\", expect, wsUser.CountSockets())\n\t}\n\twsUser2, e = h.AddConn(u2, nil)\n\texpectNilErr(t, e)\n\tf(uid, 0, 1, 1, true)\n\tcountSockets(wsUser2, 1)\n\twsUser2.RemoveSocket(nil)\n\tf(uid, 0, 1, 1, true)\n\tcountSockets(wsUser2, 0)\n\th.RemoveConn(wsUser2, nil)\n\tf(uid, 0, 0, 0, false)\n\tcountSockets(wsUser2, 0)\n\n\twsUser2, e = h.AddConn(u2, nil)\n\texpectNilErr(t, e)\n\tf(uid, 0, 1, 1, true)\n\tcountSockets(wsUser2, 1)\n\texpectNilErr(t, wsUser2.Ping())\n\tf(uid, 0, 1, 1, true)\n\tcountSockets(wsUser2, 0)\n\th.RemoveConn(wsUser2, nil)\n\tf(uid, 0, 0, 0, false)\n\tcountSockets(wsUser2, 0)\n\n\t// TODO: Add more tests\n}\n"
  },
  {
    "path": "mssql.go",
    "content": "// +build mssql\n\n/*\n*\n*\tGosora MSSQL Interface\n*\tCopyright Azareal 2016 - 2020\n*\n */\npackage main\n\nimport (\n\t\"database/sql\"\n\t\"net/url\"\n\n\t\"github.com/Azareal/Gosora/common\"\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/denisenkom/go-mssqldb\"\n)\n\nvar dbInstance string = \"\"\n\nfunc init() {\n\tdbAdapter = \"mssql\"\n\t_initDatabase = initMSSQL\n}\n\nfunc initMSSQL() (err error) {\n\t// TODO: Move this bit to the query gen lib\n\tquery := url.Values{}\n\tquery.Add(\"database\", common.DbConfig.Dbname)\n\tu := &url.URL{\n\t\tScheme:   \"sqlserver\",\n\t\tUser:     url.UserPassword(common.DbConfig.Username, common.DbConfig.Password),\n\t\tHost:     common.DbConfig.Host + \":\" + common.DbConfig.Port,\n\t\tPath:     dbInstance,\n\t\tRawQuery: query.Encode(),\n\t}\n\tdb, err = sql.Open(\"mssql\", u.String())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set the number of max open connections\n\tdb.SetMaxOpenConns(64)\n\tdb.SetMaxIdleConns(32)\n\n\t// Only hold connections open for five seconds to avoid accumulating a large number of stale connections\n\t//db.SetConnMaxLifetime(5 * time.Second)\n\tdb.SetConnMaxLifetime(c.DBTimeout())\n\n\t// Build the generated prepared statements, we are going to slowly move the queries over to the query generator rather than writing them all by hand, this'll make it easier for us to implement database adapters for other databases like PostgreSQL, MSSQL, SQlite, etc.\n\terr = _gen_mssql()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Ready the query builder\n\tqgen.Builder.SetConn(db)\n\terr = qgen.Builder.SetAdapter(\"mssql\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsetter, ok := qgen.Builder.GetAdapter().(qgen.SetPrimaryKeys)\n\tif ok {\n\t\tsetter.SetPrimaryKeys(dbTablePrimaryKeys)\n\t}\n\n\t// TODO: Is there a less noisy way of doing this for tests?\n\t/*log.Print(\"Preparing getActivityFeedByWatcher statement.\")\n\tstmts.getActivityFeedByWatcherStmt, err = db.Prepare(\"SELECT activity_stream_matches.asid, activity_stream.actor, activity_stream.targetUser, activity_stream.event, activity_stream.elementType, activity_stream.elementID, activity_stream.createdAt FROM [activity_stream_matches] INNER JOIN [activity_stream] ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE [watcher] = ? ORDER BY activity_stream.asid DESC OFFSET 0 ROWS FETCH NEXT 16 ROWS ONLY\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Print(\"Preparing getActivityFeedByWatcher statement.\")\n\tstmts.getActivityFeedByWatcherStmt, err = db.Prepare(\"SELECT activity_stream_matches.asid, activity_stream.actor, activity_stream.targetUser, activity_stream.event, activity_stream.elementType, activity_stream.elementID, activity_stream.createdAt FROM [activity_stream_matches] INNER JOIN [activity_stream] ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE [watcher] = ? ORDER BY activity_stream.asid DESC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Print(\"Preparing getActivityCountByWatcher statement.\")\n\tstmts.getActivityCountByWatcherStmt, err = db.Prepare(\"SELECT count(*) FROM [activity_stream_matches] INNER JOIN [activity_stream] ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE [watcher] = ?\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t*/\n\n\treturn nil\n}\n"
  },
  {
    "path": "mysql.go",
    "content": "// +build !pgsql,!mssql\n\n/*\n*\n*\tGosora MySQL Interface\n*\tCopyright Azareal 2016 - 2020\n*\n */\npackage main\n\nimport (\n\t\"log\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/go-sql-driver/mysql\"\n\t\"github.com/pkg/errors\"\n)\n\nvar dbCollation = \"utf8mb4_general_ci\"\n\nfunc init() {\n\tdbAdapter = \"mysql\"\n\t_initDatabase = initMySQL\n}\n\nfunc initMySQL() (err error) {\n\terr = qgen.Builder.Init(\"mysql\", map[string]string{\n\t\t\"host\":      c.DbConfig.Host,\n\t\t\"port\":      c.DbConfig.Port,\n\t\t\"name\":      c.DbConfig.Dbname,\n\t\t\"username\":  c.DbConfig.Username,\n\t\t\"password\":  c.DbConfig.Password,\n\t\t\"collation\": dbCollation,\n\t})\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\t// Set the number of max open connections\n\tdb = qgen.Builder.GetConn()\n\tdb.SetMaxOpenConns(64)\n\tdb.SetMaxIdleConns(32)\n\t//db.SetConnMaxLifetime(time.Second * 60 * 5) // Just in case we accumulate some bad connections due to the MySQL driver being stupid\n\n\t// Only hold connections open for five seconds to avoid accumulating a large number of stale connections\n\t//db.SetConnMaxLifetime(5 * time.Second)\n\tdb.SetConnMaxLifetime(c.DBTimeout())\n\n\t// Build the generated prepared statements, we are going to slowly move the queries over to the query generator rather than writing them all by hand, this'll make it easier for us to implement database adapters for other databases like PostgreSQL, MSSQL, SQlite, etc.\n\terr = _gen_mysql()\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\t// TODO: Is there a less noisy way of doing this for tests?\n\t/*log.Print(\"Preparing getActivityFeedByWatcher statement.\")\n\tstmts.getActivityFeedByWatcher, err = db.Prepare(\"SELECT activity_stream_matches.asid, activity_stream.actor, activity_stream.targetUser, activity_stream.event, activity_stream.elementType, activity_stream.elementID, activity_stream.createdAt FROM `activity_stream_matches` INNER JOIN `activity_stream` ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE `watcher` = ? ORDER BY activity_stream.asid DESC LIMIT 16\")\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}*/\n\n\tlog.Print(\"Preparing getActivityFeedByWatcher statement.\")\n\tstmts.getActivityFeedByWatcher, err = db.Prepare(\"SELECT activity_stream_matches.asid, activity_stream.actor, activity_stream.targetUser, activity_stream.event, activity_stream.elementType, activity_stream.elementID, activity_stream.createdAt FROM activity_stream_matches INNER JOIN activity_stream ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE watcher=? ORDER BY activity_stream.asid DESC LIMIT ?\")\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\t/*log.Print(\"Preparing getActivityFeedByWatcherAfter statement.\")\n\tstmts.getActivityFeedByWatcherAfter, err = db.Prepare(\"SELECT activity_stream_matches.asid, activity_stream.actor, activity_stream.targetUser, activity_stream.event, activity_stream.elementType, activity_stream.elementID, activity_stream.createdAt FROM activity_stream_matches INNER JOIN activity_stream ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE watcher=? AND asid => ? ORDER BY activity_stream.asid DESC LIMIT ?\")\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}*/\n\n\tlog.Print(\"Preparing getActivityCountByWatcher statement.\")\n\tstmts.getActivityCountByWatcher, err = db.Prepare(\"SELECT count(*) FROM activity_stream_matches INNER JOIN activity_stream ON activity_stream_matches.asid = activity_stream.asid AND activity_stream_matches.watcher != activity_stream.actor WHERE watcher=?\")\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "old_router.go",
    "content": "/* Obsoleted by gen_router.go :( */\npackage main\n\n//import \"fmt\"\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/Azareal/Gosora/common\"\n)\n\n// TODO: Support the new handler signatures created by our efforts to move the PreRoute middleware into the generated router\n// nolint Stop linting the uselessness of this file, we never know when we might need this file again\ntype Router struct {\n\tsync.RWMutex\n\troutes map[string]func(http.ResponseWriter, *http.Request)\n}\n\n// nolint\nfunc NewRouter() *Router {\n\treturn &Router{\n\t\troutes: make(map[string]func(http.ResponseWriter, *http.Request)),\n\t}\n}\n\n// nolint\nfunc (router *Router) Handle(pattern string, handle http.Handler) {\n\trouter.Lock()\n\trouter.routes[pattern] = handle.ServeHTTP\n\trouter.Unlock()\n}\n\n// nolint\nfunc (router *Router) HandleFunc(pattern string, handle func(http.ResponseWriter, *http.Request)) {\n\trouter.Lock()\n\trouter.routes[pattern] = handle\n\trouter.Unlock()\n}\n\n// nolint\nfunc (router *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {\n\tif len(req.URL.Path) == 0 || req.URL.Path[0] != '/' {\n\t\tw.WriteHeader(405)\n\t\tw.Write([]byte(\"\"))\n\t\treturn\n\t}\n\n\tvar /*extraData, */ prefix string\n\tif req.URL.Path[len(req.URL.Path)-1] != '/' {\n\t\t//extraData = req.URL.Path[strings.LastIndexByte(req.URL.Path,'/') + 1:]\n\t\tprefix = req.URL.Path[:strings.LastIndexByte(req.URL.Path, '/')+1]\n\t} else {\n\t\tprefix = req.URL.Path\n\t}\n\n\trouter.RLock()\n\thandle, ok := router.routes[prefix]\n\trouter.RUnlock()\n\n\tif ok {\n\t\thandle(w, req)\n\t\treturn\n\t}\n\t//log.Print(\"req.URL.Path[:strings.LastIndexByte(req.URL.Path,'/')]\",req.URL.Path[:strings.LastIndexByte(req.URL.Path,'/')])\n\tcommon.NotFound(w, req,nil)\n}\n"
  },
  {
    "path": "pages/page_test.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"rowblock rowhead\">\n\t<div class=\"rowitem\">Test Page</div>\n</div>\n<div class=\"rowblock parablock\">\n\t<div class=\"rowitem passive\">Testing</div>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "parser_test.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc TestPreparser(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tl := &METriList{nil}\n\n\t// Note: The open tag is evaluated without knowledge of the close tag for efficiency and simplicity, so the parser autofills the associated close tag when it finds an open tag without a partner\n\tl.Add(\"\", \"\")\n\tl.Add(\" \", \"\")\n\tl.Add(\" hi\", \"hi\")\n\tl.Add(\"hi \", \"hi\")\n\tl.Add(\"hi\", \"hi\")\n\tl.Add(\":grinning:\", \"😀\")\n\tl.Add(\":grinning: :grinning:\", \"😀 😀\")\n\tl.Add(\" :grinning: \", \"😀\")\n\tl.Add(\": :grinning: :\", \": 😀 :\")\n\tl.Add(\"::grinning::\", \":😀:\")\n\t//l.Add(\"d:grinning:d\", \"d:grinning:d\") // todo\n\tl.Add(\"d :grinning: d\", \"d 😀 d\")\n\tl.Add(\"😀\", \"😀\")\n\tl.Add(\"&nbsp;\", \"\")\n\tl.Add(\"<p>\", \"\")\n\tl.Add(\"</p>\", \"\")\n\tl.Add(\"<p></p>\", \"\")\n\n\tl.Add(\"<\", \"&lt;\")\n\tl.Add(\">\", \"&gt;\")\n\tl.Add(\"<meow>\", \"&lt;meow&gt;\")\n\tl.Add(\"&lt;\", \"&amp;lt;\")\n\tl.Add(\"&\", \"&amp;\")\n\n\t// Note: strings.TrimSpace strips newlines, if there's nothing before or after them\n\tl.Add(\"<br>\", \"\")\n\tl.Add(\"<br />\", \"\")\n\tl.Add(\"\\\\n\", \"\\n\", \"\")\n\tl.Add(\"\\\\n\\\\n\", \"\\n\\n\", \"\")\n\tl.Add(\"\\\\n\\\\n\\\\n\", \"\\n\\n\\n\", \"\")\n\tl.Add(\"\\\\r\\\\n\", \"\\r\\n\", \"\") // Windows style line ending\n\tl.Add(\"\\\\n\\\\r\", \"\\n\\r\", \"\")\n\n\tl.Add(\"ho<br>ho\", \"ho\\n\\nho\")\n\tl.Add(\"ho<br />ho\", \"ho\\n\\nho\")\n\tl.Add(\"ho\\\\nho\", \"ho\\nho\", \"ho\\nho\")\n\tl.Add(\"ho\\\\n\\\\nho\", \"ho\\n\\nho\", \"ho\\n\\nho\")\n\t//l.Add(\"ho\\\\n\\\\n\\\\n\\\\nho\", \"ho\\n\\n\\n\\nho\", \"ho\\n\\n\\nho\")\n\tl.Add(\"ho\\\\r\\\\nho\", \"ho\\r\\nho\", \"ho\\nho\") // Windows style line ending\n\tl.Add(\"ho\\\\n\\\\rho\", \"ho\\n\\rho\", \"ho\\nho\")\n\n\tl.Add(\"<b></b>\", \"<strong></strong>\")\n\tl.Add(\"<b>hi</b>\", \"<strong>hi</strong>\")\n\tl.Add(\"<b>h</b>\", \"<strong>h</strong>\")\n\tl.Add(\"<s>hi</s>\", \"<del>hi</del>\")\n\tl.Add(\"<del>hi</del>\", \"<del>hi</del>\")\n\tl.Add(\"<u>hi</u>\", \"<u>hi</u>\")\n\tl.Add(\"<em>hi</em>\", \"<em>hi</em>\")\n\tl.Add(\"<i>hi</i>\", \"<em>hi</em>\")\n\tl.Add(\"<strong>hi</strong>\", \"<strong>hi</strong>\")\n\tl.Add(\"<spoiler>hi</spoiler>\", \"<spoiler>hi</spoiler>\")\n\tl.Add(\"<g>hi</g>\", \"hi\") // Grammarly fix\n\tl.Add(\"<b><i>hi</i></b>\", \"<strong><em>hi</em></strong>\")\n\tl.Add(\"<strong><em>hi</em></strong>\", \"<strong><em>hi</em></strong>\")\n\tl.Add(\"<b><i><b>hi</b></i></b>\", \"<strong><em><strong>hi</strong></em></strong>\")\n\tl.Add(\"<strong><em><strong>hi</strong></em></strong>\", \"<strong><em><strong>hi</strong></em></strong>\")\n\tl.Add(\"<div>hi</div>\", \"&lt;div&gt;hi&lt;/div&gt;\")\n\tl.Add(\"<span>hi</span>\", \"hi\") // This is stripped since the editor (Trumbowyg) likes blasting useless spans\n\tl.Add(\"<span   >hi</span>\", \"hi\")\n\tl.Add(\"<span style='background-color: yellow;'>hi</span>\", \"hi\")\n\tl.Add(\"<span style='background-color: yellow;'>>hi</span>\", \"&gt;hi\")\n\tl.Add(\"<b>hi\", \"<strong>hi</strong>\")\n\tl.Add(\"hi</b>\", \"hi&lt;/b&gt;\")\n\tl.Add(\"</b>\", \"&lt;/b&gt;\")\n\tl.Add(\"</del>\", \"&lt;/del&gt;\")\n\tl.Add(\"</strong>\", \"&lt;/strong&gt;\")\n\tl.Add(\"<b>\", \"<strong></strong>\")\n\tl.Add(\"<span style='background-color: yellow;'>hi\", \"hi\")\n\tl.Add(\"<span style='background-color:yellow;'>hi\", \"hi\")\n\tl.Add(\"hi</span>\", \"hi\")\n\tl.Add(\"</span>\", \"\")\n\tl.Add(\"<span></span>\", \"\")\n\tl.Add(\"<span   ></span>\", \"\")\n\tl.Add(\"<span><span></span></span>\", \"\")\n\tl.Add(\"<span><b></b></span>\", \"<strong></strong>\")\n\tl.Add(\"<h1>t</h1>\", \"<h2>t</h2>\")\n\tl.Add(\"<h2>t</h2>\", \"<h3>t</h3>\")\n\tl.Add(\"<h3>t</h3>\", \"<h4>t</h4>\")\n\tl.Add(\"<></>\", \"&lt;&gt;&lt;/&gt;\")\n\tl.Add(\"</><>\", \"&lt;/&gt;&lt;&gt;\")\n\tl.Add(\"<>\", \"&lt;&gt;\")\n\tl.Add(\"</>\", \"&lt;/&gt;\")\n\tl.Add(\"<p>hi</p>\", \"hi\")\n\tl.Add(\"<p></p>\", \"\")\n\tl.Add(\"<blockquote>hi</blockquote>\", \"<blockquote>hi</blockquote>\")\n\tl.Add(\"<blockquote><b>hi</b></blockquote>\", \"<blockquote><strong>hi</strong></blockquote>\")\n\tl.Add(\"<blockquote><meow>hi</meow></blockquote>\", \"<blockquote>&lt;meow&gt;hi&lt;/meow&gt;</blockquote>\")\n\tl.Add(\"\\\\<blockquote>hi</blockquote>\", \"&lt;blockquote&gt;hi&lt;/blockquote&gt;\")\n\t//l.Add(\"\\\\\\\\<blockquote><meow>hi</meow></blockquote>\", \"\\\\<blockquote>&lt;meow&gt;hi&lt;/meow&gt;</blockquote>\") // TODO: Double escapes should print a literal backslash\n\t//l.Add(\"&lt;blockquote&gt;hi&lt;/blockquote&gt;\", \"&lt;blockquote&gt;hi&lt;/blockquote&gt;\") // TODO: Stop double-entitising this\n\tl.Add(\"\\\\<blockquote>hi</blockquote>\\\\<blockquote>hi</blockquote>\", \"&lt;blockquote&gt;hi&lt;/blockquote&gt;&lt;blockquote&gt;hi&lt;/blockquote&gt;\")\n\tl.Add(\"\\\\<a itemprop=\\\"author\\\">Admin</a>\", \"&lt;a itemprop=&#34;author&#34;&gt;Admin&lt;/a&gt;\")\n\tl.Add(\"<blockquote>\\\\<a itemprop=\\\"author\\\">Admin</a></blockquote>\", \"<blockquote>&lt;a itemprop=&#34;author&#34;&gt;Admin&lt;/a&gt;</blockquote>\")\n\tl.Add(\"\\n<blockquote>\\\\<a itemprop=\\\"author\\\">Admin</a></blockquote>\\n\", \"<blockquote>&lt;a itemprop=&#34;author&#34;&gt;Admin&lt;/a&gt;</blockquote>\")\n\tl.Add(\"tt\\n<blockquote>\\\\<a itemprop=\\\"author\\\">Admin</a></blockquote>\\ntt\", \"tt\\n<blockquote>&lt;a itemprop=&#34;author&#34;&gt;Admin&lt;/a&gt;</blockquote>\\ntt\")\n\tl.Add(\"@\", \"@\")\n\tl.Add(\"@Admin\", \"@1\")\n\tl.Add(\"@Bah\", \"@Bah\")\n\tl.Add(\" @Admin\", \"@1\")\n\tl.Add(\"\\n@Admin\", \"@1\")\n\tl.Add(\"@Admin\\n\", \"@1\")\n\tl.Add(\"@Admin\\ndd\", \"@1\\ndd\")\n\tl.Add(\"d@Admin\", \"d@Admin\")\n\tl.Add(\"\\\\@Admin\", \"@Admin\")\n\tl.Add(\"@元気\", \"@元気\")\n\t// TODO: More tests for unicode names?\n\t//l.Add(\"\\\\\\\\@Admin\", \"@1\")\n\t//l.Add(\"byte 0\", string([]byte{0}), \"\")\n\tl.Add(\"byte 'a'\", string([]byte{'a'}), \"a\")\n\t//l.Add(\"byte 255\", string([]byte{255}), \"\")\n\t//l.Add(\"rune 0\", string([]rune{0}), \"\")\n\t// TODO: Do a test with invalid UTF-8 input\n\n\tfor _, item := range l.Items {\n\t\tif res := c.PreparseMessage(item.Msg); res != item.Expects {\n\t\t\tif item.Name != \"\" {\n\t\t\t\tt.Error(\"Name: \", item.Name)\n\t\t\t}\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\t//t.Error(\"Ouput in bytes:\", []byte(res))\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t}\n\t}\n}\n\nfunc TestParser(t *testing.T) {\n\tmiscinit(t)\n\tif !c.PluginsInited {\n\t\tc.InitPlugins()\n\t}\n\tl := &METriList{nil}\n\n\turl := \"github.com/Azareal/Gosora\"\n\teurl := \"<a rel='ugc'href='//\" + url + \"'>\" + url + \"</a>\"\n\tl.Add(\"\", \"\")\n\tl.Add(\"haha\", \"haha\")\n\tl.Add(\":P\", \"😛\")\n\tl.Add(\" :P \", \" 😛 \")\n\tl.Add(\":p\", \"😛\")\n\tl.Add(\"d:p\", \"d:p\")\n\tl.Add(\":pd\", \"😛d\")\n\tl.Add(\":pdd\", \"😛dd\")\n\tl.Add(\":pddd\", \"😛ddd\")\n\tl.Add(\":p d\", \"😛 d\")\n\tl.Add(\":p dd\", \"😛 dd\")\n\tl.Add(\":p ddd\", \"😛 ddd\")\n\t//l.Add(\":p:p:p\", \"😛😛😛\")\n\tl.Add(\":p:p:p\", \"😛:p:p\")\n\tl.Add(\":p :p\", \"😛 😛\")\n\tl.Add(\":p :p :p\", \"😛 😛 😛\")\n\tl.Add(\":p :p :p :p\", \"😛 😛 😛 😛\")\n\tl.Add(\":p  :p  :p\", \"😛  😛  😛\")\n\tl.Add(\"word:p\", \"word:p\")\n\tl.Add(\"word:pword\", \"word:pword\")\n\tl.Add(\":pword\", \"😛word\") // TODO: Change the semantics on this to detect the succeeding character?\n\tl.Add(\"word :p\", \"word 😛\")\n\tl.Add(\":p word\", \"😛 word\")\n\tl.Add(\"<b>t</b>\", \"<b>t</b>\")\n\tl.Add(\"//\", \"//\")\n\tl.Add(\"http://\", \"<red>[Invalid URL]</red>\")\n\t//l.Add(\"https://\", \"<red>[Invalid URL]</red>\")\n\tl.Add(\"ipfs://\", \"<red>[Invalid URL]</red>\")\n\tl.Add(\"ftp://\", \"<red>[Invalid URL]</red>\")\n\tl.Add(\"git://\", \"<red>[Invalid URL]</red>\")\n\tl.Add(\"ssh://\", \"ssh://\")\n\tl.Add(\"fff://\", \"fff://\")\n\n\tl.Add(\"// \", \"// \")\n\tl.Add(\"// //\", \"// //\")\n\tl.Add(\"// // //\", \"// // //\")\n\tl.Add(\"http:// \", \"<red>[Invalid URL]</red> \")\n\tl.Add(\"https:// \", \"<red>[Invalid URL]</red> \")\n\tl.Add(\"ftp:// \", \"<red>[Invalid URL]</red> \")\n\tl.Add(\"git:// \", \"<red>[Invalid URL]</red> \")\n\tl.Add(\"ssh:// \", \"ssh:// \")\n\tl.Add(\"fff:// \", \"fff:// \")\n\n\tl.Add(\"//t\", \"<a rel='ugc'href='//t'>t</a>\")\n\tl.Add(\"// t\", \"// t\")\n\tl.Add(\"http:// t\", \"<red>[Invalid URL]</red> t\")\n\n\tl.Add(\"g\", \"g\")\n\tl.Add(\"g/\", \"g/\")\n\tl.Add(\"g//\", \"g//\")\n\tl.Add(\"/g\", \"/g\")\n\tl.Add(\"/gg\", \"/gg\")\n\tl.Add(\"/g/\", \"/g/\")\n\tl.Add(\"hi\", \"hi\")\n\tl.Add(\"hit\", \"hit\")\n\tl.Add(\"hit:\", \"hit:\")\n\tl.Add(\"hit:/\", \"hit:/\")\n\tl.Add(\"hit://\", \"hit://\")\n\tl.Add(\"hit://t\", \"hit://t\")\n\tl.Add(\"h\", \"h\")\n\tl.Add(\"ht\", \"ht\")\n\tl.Add(\"htt\", \"htt\")\n\tl.Add(\"http\", \"http\")\n\tl.Add(\"http:\", \"http:\")\n\t//t l.Add(\"http:/\", \"http:/\")\n\t//t l.Add(\"http:/d\", \"http:/d\")\n\tl.Add(\"http:d\", \"http:d\")\n\tl.Add(\"https:\", \"https:\")\n\tl.Add(\"gttps:\", \"gttps:\")\n\tl.Add(\"gttps:/\", \"gttps:/\")\n\tl.Add(\"gttps://\", \"gttps://\")\n\tl.Add(\"ftp:\", \"ftp:\")\n\tl.Add(\"git:\", \"git:\")\n\tl.Add(\"ssh:\", \"ssh:\")\n\n\tl.Add(\"http\", \"http\")\n\tl.Add(\"https\", \"https\")\n\tl.Add(\"ftp\", \"ftp\")\n\tl.Add(\"git\", \"git\")\n\tl.Add(\"ssh\", \"ssh\")\n\n\tl.Add(\"ht\", \"ht\")\n\tl.Add(\"htt\", \"htt\")\n\tl.Add(\"ft\", \"ft\")\n\tl.Add(\"gi\", \"gi\")\n\tl.Add(\"ss\", \"ss\")\n\tl.Add(\"haha\\nhaha\\nhaha\", \"haha<br>haha<br>haha\")\n\tl.Add(\"//\"+url, eurl)\n\tl.Add(\"//a\", \"<a rel='ugc'href='//a'>a</a>\")\n\tl.Add(\" //a\", \" <a rel='ugc'href='//a'>a</a>\")\n\tl.Add(\"//a \", \"<a rel='ugc'href='//a'>a</a> \")\n\tl.Add(\" //a \", \" <a rel='ugc'href='//a'>a</a> \")\n\tl.Add(\"d //a \", \"d <a rel='ugc'href='//a'>a</a> \")\n\tl.Add(\"ddd ddd //a \", \"ddd ddd <a rel='ugc'href='//a'>a</a> \")\n\tl.Add(\"https://\"+url, \"<a rel='ugc'href='https://\"+url+\"'>\"+url+\"</a>\")\n\tl.Add(\"https://t\", \"<a rel='ugc'href='https://t'>t</a>\")\n\tl.Add(\"https://en.wikipedia.org/wiki/First_they_came_...\", \"<a rel='ugc'href='https://en.wikipedia.org/wiki/First_they_came_...'>en.wikipedia.org/wiki/First_they_came_...</a>\") // this frequently fails in some chat clients, we should make sure that doesn't happen here\n\tl.Add(\"http://\"+url, \"<a rel='ugc'href='http://\"+url+\"'>\"+url+\"</a>\")\n\tl.Add(\"#http://\"+url, \"#http://\"+url)\n\tl.Add(\"@http://\"+url, \"<red>[Invalid Profile]</red>ttp://\"+url)\n\tl.Add(\"//\"+url+\"\\n\", \"<a rel='ugc'href='//\"+url+\"'>\"+url+\"</a><br>\")\n\tl.Add(\"\\n//\"+url, \"<br>\"+eurl)\n\tl.Add(\"\\n//\"+url+\"\\n\", \"<br>\"+eurl+\"<br>\")\n\tl.Add(\"\\n//\"+url+\"\\n\\n\", \"<br>\"+eurl+\"<br><br>\")\n\tl.Add(\"//\"+url+\"\\n//\"+url, eurl+\"<br>\"+eurl)\n\tl.Add(\"//\"+url+\" //\"+url, eurl+\" \"+eurl)\n\tl.Add(\"//\"+url+\"  //\"+url, eurl+\"  \"+eurl)\n\t//l.Add(\"//\"+url+\"//\"+url, eurl+\"\"+eurl)\n\t//l.Add(\"//\"+url+\"|//\"+url, eurl+\"|\"+eurl)\n\tl.Add(\"//\"+url+\"|//\"+url, \"<red>[Invalid URL]</red>|//\"+url)\n\tl.Add(\"//\"+url+\"//\"+url, \"<a rel='ugc'href='//\"+url+\"//\"+url+\"'>\"+url+\"//\"+url+\"</a>\")\n\tl.Add(\"//\"+url+\"\\n\\n//\"+url, eurl+\"<br><br>\"+eurl)\n\n\tpre2 := c.Config.SslSchema\n\tc.Config.SslSchema = true\n\tlocal := func(u string) {\n\t\ts := \"//\" + c.Site.URL\n\t\tfs := \"http://\" + c.Site.URL\n\t\tipns := \"ipns://\" + c.Site.URL\n\t\tif c.Config.SslSchema {\n\t\t\ts = \"https:\" + s\n\t\t\tfs = \"https://\" + c.Site.URL\n\t\t}\n\t\tl.Add(\"//\"+u, \"<a href='\"+fs+\"'>\"+c.Site.URL+\"</a>\")\n\n\t\t// TODO: Strip redundant slashes?\n\t\tl.Add(\"//\"+u+\"/\", \"<a href='\"+fs+\"/'>\"+c.Site.URL+\"/</a>\")\n\t\tl.Add(\"//\"+u+\"//\", \"<a href='\"+fs+\"//'>\"+c.Site.URL+\"//</a>\")\n\n\t\tl.Add(\"//\"+u+\"\\n\", \"<a href='\"+fs+\"'>\"+c.Site.URL+\"</a><br>\")\n\t\tl.Add(\"//\"+u+\"\\n//\"+u, \"<a href='\"+fs+\"'>\"+c.Site.URL+\"</a><br><a href='\"+fs+\"'>\"+c.Site.URL+\"</a>\")\n\t\tl.Add(\"http://\"+u, \"<a href='\"+fs+\"'>\"+c.Site.URL+\"</a>\")\n\t\tl.Add(\"https://\"+u, \"<a href='\"+fs+\"'>\"+c.Site.URL+\"</a>\")\n\t\tl.Add(\"ipfs://testthis\", \"<a rel='ugc'href='ipfs://testthis'>ipfs://testthis</a>\")\n\t\tl.Add(ipns, \"<a rel='ugc'href='\"+ipns+\"'>\"+c.Site.URL+\"</a>\")\n\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.webm?sid=1&stype=forums\", \"<video controls src=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"download>Attachment</a></video>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.webm\", \"<video controls src=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"download>Attachment</a></video>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.webm?sid=1\", \"<video controls src=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"download>Attachment</a></video>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.webm?stype=forums\", \"<video controls src=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.webm?sid=1&amp;stype=forums\\\"download>Attachment</a></video>\")\n\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.mp3?sid=1&stype=forums\", \"<audio controls src=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"download>Attachment</a></audio>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.mp3\", \"<audio controls src=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"download>Attachment</a></audio>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.mp3?sid=1\", \"<audio controls src=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"download>Attachment</a></audio>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.mp3?stype=forums\", \"<audio controls src=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.mp3?sid=1&amp;stype=forums\\\"download>Attachment</a></audio>\")\n\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.png?sid=1&stype=forums\", \"<a href=\\\"\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums\\\"><img src='\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums'class='postImage'></a>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash?sid=1&stype=forums\", \"<red>[Invalid URL]</red>\")\n\t\tl.Add(\"//\"+u+\"/attachs/s?sid=1&stype=forums\", \"<red>[Invalid URL]</red>\")\n\t\tl.Add(\"//\"+u+\"/attachs/?sid=1&stype=forums\", \"<red>[Invalid URL]</red>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.?sid=1&stype=forums\", \"<red>[Invalid URL]</red>\")\n\t\tl.Add(\"//\"+u+\"/attachs?sid=1&stype=forums\", \"<red>[Invalid URL]</red>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.png\", \"<a href=\\\"\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums\\\"><img src='\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums'class='postImage'></a>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.png?sid=1\", \"<a href=\\\"\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums\\\"><img src='\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums'class='postImage'></a>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.png?stype=forums\", \"<a href=\\\"\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums\\\"><img src='\"+fs+\"/attachs/sha256hash.png?sid=1&amp;stype=forums'class='postImage'></a>\")\n\n\t\t//l.Add(\"//\"+u+\"/attachs/sha256hash.txt?sid=1&stype=forums\", \"<a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.txt?sid=1&amp;stype=forums\\\"download>Attachment</a>\")\n\t\tl.Add(\"//\"+u+\"/attachs/sha256hash.txt?sid=1&stype=forums\", \"<div class='attach_box'><div class='attach_info'>\"+fs+\"/attachs/sha256hash.txt</div><div class='attach_opts'><a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.txt?sid=1&amp;stype=forums\\\">View</a> / <a class='attach'href=\\\"\"+fs+\"/attachs/sha256hash.txt?sid=1&amp;stype=forums\\\"download>Download</a></div></div>\")\n\n\t\tl.Add(\"//example.com/image.png\", \"<a href=\\\"//example.com/image.png\\\"><img src='//example.com/image.png'class='postImage'></a>\")\n\t\tl.Add(\"https://example.com/image.png\", \"<a href=\\\"https://example.com/image.png\\\"><img src='https://example.com/image.png'class='postImage'></a>\")\n\t\tl.Add(\"http://example.com/image.png\", \"<a href=\\\"http://example.com/image.png\\\"><img src='http://example.com/image.png'class='postImage'></a>\")\n\t}\n\tlocal(\"localhost\")\n\tlocal(\"127.0.0.1\")\n\tlocal(\"[::1]\")\n\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala&t=30s\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala?start=30'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala&t=30s'>https://www.youtube.com/watch?v=lalalalala&t=30s</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala&t=1s\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala&t=1\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\t//l.Add(\"https://www.youtube.com/watch?v=;\",\"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/;'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch'>https://www.youtube.com/watch</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=d;\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/d'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=d'>https://www.youtube.com/watch?v=d</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=d;d\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/d'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=d'>https://www.youtube.com/watch?v=d</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=alert()\", \"<red>[Invalid URL]</red>()\")\n\tl.Add(\"https://www.youtube.com/watch?v=alert()()\", \"<red>[Invalid URL]</red>()()\")\n\tl.Add(\"https://www.youtube.com/watch?v=js:alert()\", \"<red>[Invalid URL]</red>()\")\n\tl.Add(\"https://www.youtube.com/watch?v='+><script>alert(\\\"\\\")</script><+'\", \"<red>[Invalid URL]</red>'+><script>alert(\\\"\\\")</script><+'\")\n\tl.Add(\"https://www.youtube.com/watch?v='+onready='alert(\\\"\\\")'+'\", \"<red>[Invalid URL]</red>'+onready='alert(\\\"\\\")'+'\")\n\tl.Add(\" https://www.youtube.com/watch?v=lalalalala\", \" <iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala tt\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript> tt\")\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala&d=haha\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://gaming.youtube.com/watch?v=lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://gaming.youtube.com/watch?v=lalalalala'>https://gaming.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://gaming.youtube.com/watch?v=lalalalala&d=haha\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://gaming.youtube.com/watch?v=lalalalala'>https://gaming.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://youtu.be/lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://youtu.be/lalalalala'>https://youtu.be/lalalalala</a></noscript>\")\n\tl.Add(\"https://m.youtube.com/watch?v=lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://m.youtube.com/watch?v=lalalalala'>https://m.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://m.youtube.com/watch?v=lalalalala&d=haha\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://m.youtube.com/watch?v=lalalalala'>https://m.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"http://www.youtube.com/watch?v=lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"//www.youtube.com/watch?v=lalalalala\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\t//l.Add(\"www.youtube.com/watch?v=lalalalala\",\"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala'>https://www.youtube.com/watch?v=lalalalala</a></noscript>\")\n\tl.Add(\"https://www.nicovideo.jp/watch/sm111111\", \"<iframe class='postIframe'src='https://embed.nicovideo.jp/watch/sm111111?jsapi=1&amp;playerId=1'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.nicovideo.jp/watch/sm111111'>https://www.nicovideo.jp/watch/sm111111</a></noscript>\")\n\t//l.Add(\"www.nicovideo.jp/watch/sm111111\", \"<iframe class='postIframe'src='https://embed.nicovideo.jp/watch/sm111111?jsapi=1&amp;playerId=1'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.nicovideo.jp/watch/sm111111'>www.nicovideo.jp/watch/sm111111</a></noscript>\")\n\t//l.Add(\"www.nicovideo.jp/watch/smlalalalala\", \"<a href='www.nicovideo.jp/watch/smlalalalala'>www.nicovideo.jp/watch/smlalalalala</a>\")\n\tl.Add(\"https://www.nicovideo.jp/watch/smlalalalala\", \"<a rel='ugc'href='https://www.nicovideo.jp/watch/smlalalalala'>www.nicovideo.jp/watch/smlalalalala</a>\")\n\tl.Add(\"//www.youtube.com/watch?v=lalalalala&t=30s\", \"<iframe class='postIframe'src='https://www.youtube-nocookie.com/embed/lalalalala?start=30'frameborder=0 allowfullscreen></iframe><noscript><a href='https://www.youtube.com/watch?v=lalalalala&t=30s'>https://www.youtube.com/watch?v=lalalalala&t=30s</a></noscript>\")\n\n\tl.Add(\"#tid-1\", \"<a href='/topic/1'>#tid-1</a>\")\n\tl.Add(\"#tid-1#tid-1\", \"<a href='/topic/1'>#tid-1</a>#tid-1\")\n\tl.Add(\"##tid-1\", \"##tid-1\")\n\tl.Add(\"#@tid-1\", \"#@tid-1\")\n\tl.Add(\"# #tid-1\", \"# #tid-1\")\n\tl.Add(\"@ #tid-1\", \"<red>[Invalid Profile]</red>#tid-1\")\n\tl.Add(\"@#tid-1\", \"<red>[Invalid Profile]</red>tid-1\")\n\tl.Add(\"@ #tid-@\", \"<red>[Invalid Profile]</red>#tid-@\")\n\tl.Add(\"#tid-1 #tid-1\", \"<a href='/topic/1'>#tid-1</a> <a href='/topic/1'>#tid-1</a>\")\n\tl.Add(\"#tid-0\", \"<red>[Invalid Topic]</red>\")\n\tl.Add(\"https://\"+url+\"/#tid-1\", \"<a rel='ugc'href='https://\"+url+\"/#tid-1'>\"+url+\"/#tid-1</a>\")\n\tl.Add(\"https://\"+url+\"/?hi=2\", \"<a rel='ugc'href='https://\"+url+\"/?hi=2'>\"+url+\"/?hi=2</a>\")\n\tl.Add(\"https://\"+url+\"/?hi=2#t=1\", \"<a rel='ugc'href='https://\"+url+\"/?hi=2#t=1'>\"+url+\"/?hi=2#t=1</a>\")\n\tl.Add(\"#fid-1\", \"<a href='/forum/1'>#fid-1</a>\")\n\tl.Add(\" #fid-1\", \" <a href='/forum/1'>#fid-1</a>\")\n\tl.Add(\"#fid-0\", \"<red>[Invalid Forum]</red>\")\n\tl.Add(\" #fid-0\", \" <red>[Invalid Forum]</red>\")\n\tl.Add(\"#\", \"#\")\n\tl.Add(\"# \", \"# \")\n\tl.Add(\" @\", \" @\")\n\tl.Add(\" #\", \" #\")\n\tl.Add(\"#@\", \"#@\")\n\tl.Add(\"#@ \", \"#@ \")\n\tl.Add(\"#@1\", \"#@1\")\n\tl.Add(\"#f\", \"#f\")\n\tl.Add(\"f#f\", \"f#f\")\n\tl.Add(\"f#\", \"f#\")\n\tl.Add(\"#ff\", \"#ff\")\n\tl.Add(\"#ffffid-0\", \"#ffffid-0\")\n\t//l.Add(\"#ffffid-0\", \"#ffffid-0\")\n\tl.Add(\"#nid-0\", \"#nid-0\")\n\tl.Add(\"#nnid-0\", \"#nnid-0\")\n\tl.Add(\"@@\", \"<red>[Invalid Profile]</red>\")\n\tl.Add(\"@@ @@\", \"<red>[Invalid Profile]</red> <red>[Invalid Profile]</red>\")\n\tl.Add(\"@@1\", \"<red>[Invalid Profile]</red>1\")\n\tl.Add(\"@#1\", \"<red>[Invalid Profile]</red>1\")\n\tl.Add(\"@##1\", \"<red>[Invalid Profile]</red>#1\")\n\tl.Add(\"@2\", \"<red>[Invalid Profile]</red>\")\n\tl.Add(\"@2t\", \"<red>[Invalid Profile]</red>t\")\n\tl.Add(\"@2 t\", \"<red>[Invalid Profile]</red> t\")\n\tl.Add(\"@2 \", \"<red>[Invalid Profile]</red> \")\n\tl.Add(\"@2 @2\", \"<red>[Invalid Profile]</red> <red>[Invalid Profile]</red>\")\n\tl.Add(\"@1\", \"<a href='/user/admin.1'class='mention'>@Admin</a>\")\n\tl.Add(\" @1\", \" <a href='/user/admin.1'class='mention'>@Admin</a>\")\n\tl.Add(\"@1t\", \"<a href='/user/admin.1'class='mention'>@Admin</a>t\")\n\tl.Add(\"@1 \", \"<a href='/user/admin.1'class='mention'>@Admin</a> \")\n\tl.Add(\"@1 @1\", \"<a href='/user/admin.1'class='mention'>@Admin</a> <a href='/user/admin.1'class='mention'>@Admin</a>\")\n\tl.Add(\"@0\", \"<red>[Invalid Profile]</red>\")\n\tl.Add(\"@-1\", \"<red>[Invalid Profile]</red>1\")\n\n\t// TODO: Fix this hack and make the results a bit more reproducible, push the tests further in the process.\n\tc.GuestUser.Perms.AutoLink = true\n\tfor _, item := range l.Items {\n\t\tif res := c.ParseMessage(item.Msg, 1, \"forums\", nil, nil); res != item.Expects {\n\t\t\tif item.Name != \"\" {\n\t\t\t\tt.Error(\"Name: \", item.Name)\n\t\t\t}\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t\tbreak\n\t\t}\n\t}\n\tc.Config.SslSchema = pre2\n\n\tl = &METriList{nil}\n\tpre := c.Site.URL // Just in case this is localhost...\n\tpre2 = c.Config.SslSchema\n\tc.Site.URL = \"example.com\"\n\tc.Config.SslSchema = true\n\tl.Add(\"//\"+c.Site.URL, \"<a href='https://\"+c.Site.URL+\"'>\"+c.Site.URL+\"</a>\")\n\tl.Add(\"//\"+c.Site.URL+\"\\n\", \"<a href='https://\"+c.Site.URL+\"'>\"+c.Site.URL+\"</a><br>\")\n\tl.Add(\"//\"+c.Site.URL+\"\\n//\"+c.Site.URL, \"<a href='https://\"+c.Site.URL+\"'>\"+c.Site.URL+\"</a><br><a href='https://\"+c.Site.URL+\"'>\"+c.Site.URL+\"</a>\")\n\tfor _, item := range l.Items {\n\t\tif res := c.ParseMessage(item.Msg, 1, \"forums\", nil, nil); res != item.Expects {\n\t\t\tif item.Name != \"\" {\n\t\t\t\tt.Error(\"Name: \", item.Name)\n\t\t\t}\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t\tbreak\n\t\t}\n\t}\n\tc.Site.URL = pre\n\tc.Config.SslSchema = pre2\n\n\tl = &METriList{nil}\n\tl.Add(\"//\", \"//\")\n\tl.Add(\"//z\", \"//z\")\n\tl.Add(\"//\"+url, \"//\"+url)\n\tl.Add(\"https://www.youtube.com/watch?v=lalalalala&t=1\", \"https://www.youtube.com/watch?v=lalalalala&t=1\")\n\tl.Add(\"#tid-1\", \"<a href='/topic/1'>#tid-1</a>\")\n\tc.GuestUser.Perms.AutoLink = false\n\tfor _, item := range l.Items {\n\t\tif res := c.ParseMessage(item.Msg, 1, \"forums\", nil, nil); res != item.Expects {\n\t\t\tif item.Name != \"\" {\n\t\t\t\tt.Error(\"Name: \", item.Name)\n\t\t\t}\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t\tbreak\n\t\t}\n\t}\n\n\tc.AddHashLinkType(\"nnid-\", func(sb *strings.Builder, msg string, i *int) {\n\t\ttid, intLen := c.CoerceIntString(msg[*i:])\n\t\t*i += intLen\n\n\t\ttopic, err := c.Topics.Get(tid)\n\t\tif err != nil || !c.Forums.Exists(topic.ParentID) {\n\t\t\tsb.Write(c.InvalidTopic)\n\t\t\treturn\n\t\t}\n\t\tc.WriteURL(sb, c.BuildTopicURL(\"\", tid), \"#nnid-\"+strconv.Itoa(tid))\n\t})\n\tres := c.ParseMessage(\"#nnid-1\", 1, \"forums\", nil, nil)\n\texpect := \"<a href='/topic/1'>#nnid-1</a>\"\n\tif res != expect {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected:\", \"'\"+expect+\"'\")\n\t}\n\n\tc.AddHashLinkType(\"longidnameneedtooverflowhack-\", func(sb *strings.Builder, msg string, i *int) {\n\t\ttid, intLen := c.CoerceIntString(msg[*i:])\n\t\t*i += intLen\n\n\t\ttopic, err := c.Topics.Get(tid)\n\t\tif err != nil || !c.Forums.Exists(topic.ParentID) {\n\t\t\tsb.Write(c.InvalidTopic)\n\t\t\treturn\n\t\t}\n\t\tc.WriteURL(sb, c.BuildTopicURL(\"\", tid), \"#longidnameneedtooverflowhack-\"+strconv.Itoa(tid))\n\t})\n\tres = c.ParseMessage(\"#longidnameneedtooverflowhack-1\", 1, \"forums\", nil, nil)\n\texpect = \"<a href='/topic/1'>#longidnameneedtooverflowhack-1</a>\"\n\tif res != expect {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected:\", \"'\"+expect+\"'\")\n\t}\n}\n\nfunc TestPaginate(t *testing.T) {\n\tvar plist []int\n\tf := func(i, want int) {\n\t\texpect(t, plist[i] == want, fmt.Sprintf(\"plist[%d] should be %d not %d\", i, want, plist[i]))\n\t}\n\n\tplist = c.Paginate(1, 1, 5)\n\texpect(t, len(plist) == 1, fmt.Sprintf(\"len of plist should be 1 not %d\", len(plist)))\n\tf(0, 1)\n\n\tplist = c.Paginate(1, 5, 5)\n\texpect(t, len(plist) == 5, fmt.Sprintf(\"len of plist should be 5 not %d\", len(plist)))\n\tf(0, 1)\n\tf(1, 2)\n\tf(2, 3)\n\tf(3, 4)\n\tf(4, 5)\n\n\t// TODO: More Paginate() tests\n\t// TODO: Tests for other paginator functions\n}\n"
  },
  {
    "path": "patcher/main.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\nvar patches = make(map[int]func(*bufio.Scanner) error)\n\nfunc addPatch(index int, handle func(*bufio.Scanner) error) {\n\tpatches[index] = handle\n}\n\nfunc main() {\n\tscanner := bufio.NewScanner(os.Stdin)\n\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t\tpressAnyKey(scanner)\n\t\t\tlog.Fatal(\"\")\n\t\t\treturn\n\t\t}\n\t}()\n\n\tlog.Print(\"Loading the configuration data\")\n\terr := c.LoadConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tlog.Print(\"Processing configuration data\")\n\terr = c.ProcessConfig()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif c.DbConfig.Adapter != \"mysql\" && c.DbConfig.Adapter != \"\" {\n\t\tlog.Fatal(\"Only MySQL is supported for upgrades right now, please wait for a newer build of the patcher\")\n\t}\n\n\terr = prepMySQL()\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\terr = patcher(scanner)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n\nfunc pressAnyKey(scanner *bufio.Scanner) {\n\tfmt.Println(\"Please press enter to exit...\")\n\tfor scanner.Scan() {\n\t\t_ = scanner.Text()\n\t\treturn\n\t}\n}\n\nfunc prepMySQL() error {\n\treturn qgen.Builder.Init(\"mysql\", map[string]string{\n\t\t\"host\":      c.DbConfig.Host,\n\t\t\"port\":      c.DbConfig.Port,\n\t\t\"name\":      c.DbConfig.Dbname,\n\t\t\"username\":  c.DbConfig.Username,\n\t\t\"password\":  c.DbConfig.Password,\n\t\t\"collation\": \"utf8mb4_general_ci\",\n\t})\n}\n\ntype SchemaFile struct {\n\tDBVersion          string // Current version of the database schema\n\tDynamicFileVersion string\n\tMinGoVersion       string // TODO: Minimum version of Go needed to install this version\n\tMinVersion         string // TODO: Minimum version of Gosora to jump to this version, might be tricky as we don't store this in the schema file, maybe store it in the database\n}\n\nfunc loadSchema() (schemaFile SchemaFile, err error) {\n\tfmt.Println(\"Loading the schema file\")\n\tdata, err := ioutil.ReadFile(\"./schema/lastSchema.json\")\n\tif err != nil {\n\t\treturn schemaFile, err\n\t}\n\terr = json.Unmarshal(data, &schemaFile)\n\treturn schemaFile, err\n}\n\nfunc patcher(scanner *bufio.Scanner) error {\n\tvar dbVersion int\n\terr := qgen.NewAcc().Select(\"updates\").Columns(\"dbVersion\").QueryRow().Scan(&dbVersion)\n\tif err == sql.ErrNoRows {\n\t\tschemaFile, err := loadSchema()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdbVersion, err = strconv.Atoi(schemaFile.DBVersion)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else if err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Println(\"Applying the patches\")\n\tvar pslice = make([]func(*bufio.Scanner) error, len(patches))\n\tfor i := 0; i < len(patches); i++ {\n\t\tpslice[i] = patches[i]\n\t}\n\n\t// Run the queued up patches\n\tvar patched int\n\tfor index, patch := range pslice {\n\t\tif dbVersion > index {\n\t\t\tcontinue\n\t\t}\n\t\terr := patch(scanner)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Failed to apply patch \" + strconv.Itoa(index+1))\n\t\t\treturn err\n\t\t}\n\t\tfmt.Println(\"Applied patch \" + strconv.Itoa(index+1))\n\t\tpatched++\n\t}\n\n\tif patched > 0 {\n\t\t_, err := qgen.NewAcc().Update(\"updates\").Set(\"dbVersion = ?\").Exec(len(pslice))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tfmt.Println(\"No new patches found.\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "patcher/patches.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"database/sql\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode\"\n\n\tmeta \"github.com/Azareal/Gosora/common/meta\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype tblColumn = qgen.DBTableColumn\ntype tC = tblColumn\ntype tblKey = qgen.DBTableKey\ntype tK = tblKey\n\nfunc init() {\n\taddPatch(0, patch0)\n\taddPatch(1, patch1)\n\taddPatch(2, patch2)\n\taddPatch(3, patch3)\n\taddPatch(4, patch4)\n\taddPatch(5, patch5)\n\taddPatch(6, patch6)\n\taddPatch(7, patch7)\n\taddPatch(8, patch8)\n\taddPatch(9, patch9)\n\taddPatch(10, patch10)\n\taddPatch(11, patch11)\n\taddPatch(12, patch12)\n\taddPatch(13, patch13)\n\taddPatch(14, patch14)\n\taddPatch(15, patch15)\n\taddPatch(16, patch16)\n\taddPatch(17, patch17)\n\taddPatch(18, patch18)\n\taddPatch(19, patch19)\n\taddPatch(20, patch20)\n\taddPatch(21, patch21)\n\taddPatch(22, patch22)\n\taddPatch(23, patch23)\n\taddPatch(24, patch24)\n\taddPatch(25, patch25)\n\taddPatch(26, patch26)\n\taddPatch(27, patch27)\n\taddPatch(28, patch28)\n\taddPatch(29, patch29)\n\taddPatch(30, patch30)\n\taddPatch(31, patch31)\n\taddPatch(32, patch32)\n\taddPatch(33, patch33)\n\taddPatch(34, patch34)\n\taddPatch(35, patch35)\n\taddPatch(36, patch36)\n}\n\nfunc bcol(col string, val bool) qgen.DBTableColumn {\n\tif val {\n\t\treturn tC{col, \"boolean\", 0, false, false, \"1\"}\n\t}\n\treturn tC{col, \"boolean\", 0, false, false, \"0\"}\n}\nfunc ccol(col string, size int, sdefault string) qgen.DBTableColumn {\n\treturn tC{col, \"varchar\", size, false, false, sdefault}\n}\n\nfunc patch0(scanner *bufio.Scanner) (err error) {\n\terr = createTable(\"menus\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"mid\", \"int\", 0, false, true, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"mid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = createTable(\"menu_items\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"miid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"mid\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t\tccol(\"htmlID\", 200, \"''\"),\n\t\t\tccol(\"cssClass\", 200, \"''\"),\n\t\t\tccol(\"position\", 100, \"\"),\n\t\t\tccol(\"path\", 200, \"''\"),\n\t\t\tccol(\"aria\", 200, \"''\"),\n\t\t\tccol(\"tooltip\", 200, \"''\"),\n\t\t\tccol(\"tmplName\", 200, \"''\"),\n\t\t\t{\"order\", \"int\", 0, false, false, \"0\"},\n\n\t\t\tbcol(\"guestOnly\", false),\n\t\t\tbcol(\"memberOnly\", false),\n\t\t\tbcol(\"staffOnly\", false),\n\t\t\tbcol(\"adminOnly\", false),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"miid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = execStmt(qgen.Builder.SimpleInsert(\"menus\", \"\", \"\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar order int\n\tmOrder := \"mid, name, htmlID, cssClass, position, path, aria, tooltip, guestOnly, memberOnly, staffOnly, adminOnly\"\n\taddMenuItem := func(data map[string]interface{}) error {\n\t\tcols, values := qgen.InterfaceMapToInsertStrings(data, mOrder)\n\t\terr := execStmt(qgen.Builder.SimpleInsert(\"menu_items\", cols+\", order\", values+\",\"+strconv.Itoa(order)))\n\t\torder++\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_forums}\", \"htmlID\": \"menu_forums\", \"position\": \"left\", \"path\": \"/forums/\", \"aria\": \"{lang.menu_forums_aria}\", \"tooltip\": \"{lang.menu_forums_tooltip}\"})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_topics}\", \"htmlID\": \"menu_topics\", \"cssClass\": \"menu_topics\", \"position\": \"left\", \"path\": \"/topics/\", \"aria\": \"{lang.menu_topics_aria}\", \"tooltip\": \"{lang.menu_topics_tooltip}\"})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"htmlID\": \"general_alerts\", \"cssClass\": \"menu_alerts\", \"position\": \"right\", \"tmplName\": \"menu_alerts\"})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_account}\", \"cssClass\": \"menu_account\", \"position\": \"left\", \"path\": \"/user/edit/critical/\", \"aria\": \"{lang.menu_account_aria}\", \"tooltip\": \"{lang.menu_account_tooltip}\", \"memberOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_profile}\", \"cssClass\": \"menu_profile\", \"position\": \"left\", \"path\": \"{me.Link}\", \"aria\": \"{lang.menu_profile_aria}\", \"tooltip\": \"{lang.menu_profile_tooltip}\", \"memberOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_panel}\", \"cssClass\": \"menu_panel menu_account\", \"position\": \"left\", \"path\": \"/panel/\", \"aria\": \"{lang.menu_panel_aria}\", \"tooltip\": \"{lang.menu_panel_tooltip}\", \"memberOnly\": true, \"staffOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_logout}\", \"cssClass\": \"menu_logout\", \"position\": \"left\", \"path\": \"/accounts/logout/?session={me.Session}\", \"aria\": \"{lang.menu_logout_aria}\", \"tooltip\": \"{lang.menu_logout_tooltip}\", \"memberOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_register}\", \"cssClass\": \"menu_register\", \"position\": \"left\", \"path\": \"/accounts/create/\", \"aria\": \"{lang.menu_register_aria}\", \"tooltip\": \"{lang.menu_register_tooltip}\", \"guestOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = addMenuItem(map[string]interface{}{\"mid\": 1, \"name\": \"{lang.menu_login}\", \"cssClass\": \"menu_login\", \"position\": \"left\", \"path\": \"/accounts/login/\", \"aria\": \"{lang.menu_login_aria}\", \"tooltip\": \"{lang.menu_login_tooltip}\", \"guestOnly\": true})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc patch1(scanner *bufio.Scanner) error {\n\treturn renameRoutes(map[string]string{\n\t\t\"routeAccountEditCriticalSubmit\": \"routes.AccountEditCriticalSubmit\",\n\t\t\"routeAccountEditAvatar\":         \"routes.AccountEditAvatar\",\n\t\t\"routeAccountEditAvatarSubmit\":   \"routes.AccountEditAvatarSubmit\",\n\t\t\"routeAccountEditUsername\":       \"routes.AccountEditUsername\",\n\t\t\"routeAccountEditUsernameSubmit\": \"routes.AccountEditUsernameSubmit\",\n\t})\n}\n\nfunc patch2(scanner *bufio.Scanner) error {\n\treturn renameRoutes(map[string]string{\n\t\t\"routeLogout\":                   \"routes.AccountLogout\",\n\t\t\"routeShowAttachment\":           \"routes.ShowAttachment\",\n\t\t\"routeChangeTheme\":              \"routes.ChangeTheme\",\n\t\t\"routeProfileReplyCreateSubmit\": \"routes.ProfileReplyCreateSubmit\",\n\t\t\"routeLikeTopicSubmit\":          \"routes.LikeTopicSubmit\",\n\t\t\"routeReplyLikeSubmit\":          \"routes.ReplyLikeSubmit\",\n\t\t\"routeDynamic\":                  \"routes.DynamicRoute\",\n\t\t\"routeUploads\":                  \"routes.UploadedFile\",\n\t\t\"BadRoute\":                      \"routes.BadRoute\",\n\t})\n}\n\nfunc patch3(scanner *bufio.Scanner) error {\n\treturn createTable(\"registration_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"rlid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"username\", 100, \"\"),\n\t\t\tccol(\"email\", 100, \"\"),\n\t\t\tccol(\"failureReason\", 100, \"\"),\n\t\t\tbcol(\"success\", false), // Did this attempt succeed?\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\t{\"doneAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"rlid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nfunc patch4(scanner *bufio.Scanner) error {\n\troutes := map[string]string{\n\t\t\"routeReportSubmit\":                      \"routes.ReportSubmit\",\n\t\t\"routeAccountEditEmail\":                  \"routes.AccountEditEmail\",\n\t\t\"routeAccountEditEmailTokenSubmit\":       \"routes.AccountEditEmailTokenSubmit\",\n\t\t\"routePanelLogsRegs\":                     \"panel.LogsRegs\",\n\t\t\"routePanelLogsMod\":                      \"panel.LogsMod\",\n\t\t\"routePanelLogsAdmin\":                    \"panel.LogsAdmin\",\n\t\t\"routePanelDebug\":                        \"panel.Debug\",\n\t\t\"routePanelAnalyticsViews\":               \"panel.AnalyticsViews\",\n\t\t\"routePanelAnalyticsRouteViews\":          \"panel.AnalyticsRouteViews\",\n\t\t\"routePanelAnalyticsAgentViews\":          \"panel.AnalyticsAgentViews\",\n\t\t\"routePanelAnalyticsForumViews\":          \"panel.AnalyticsForumViews\",\n\t\t\"routePanelAnalyticsSystemViews\":         \"panel.AnalyticsSystemViews\",\n\t\t\"routePanelAnalyticsLanguageViews\":       \"panel.AnalyticsLanguageViews\",\n\t\t\"routePanelAnalyticsReferrerViews\":       \"panel.AnalyticsReferrerViews\",\n\t\t\"routePanelAnalyticsTopics\":              \"panel.AnalyticsTopics\",\n\t\t\"routePanelAnalyticsPosts\":               \"panel.AnalyticsPosts\",\n\t\t\"routePanelAnalyticsForums\":              \"panel.AnalyticsForums\",\n\t\t\"routePanelAnalyticsRoutes\":              \"panel.AnalyticsRoutes\",\n\t\t\"routePanelAnalyticsAgents\":              \"panel.AnalyticsAgents\",\n\t\t\"routePanelAnalyticsSystems\":             \"panel.AnalyticsSystems\",\n\t\t\"routePanelAnalyticsLanguages\":           \"panel.AnalyticsLanguages\",\n\t\t\"routePanelAnalyticsReferrers\":           \"panel.AnalyticsReferrers\",\n\t\t\"routePanelSettings\":                     \"panel.Settings\",\n\t\t\"routePanelSettingEdit\":                  \"panel.SettingEdit\",\n\t\t\"routePanelSettingEditSubmit\":            \"panel.SettingEditSubmit\",\n\t\t\"routePanelForums\":                       \"panel.Forums\",\n\t\t\"routePanelForumsCreateSubmit\":           \"panel.ForumsCreateSubmit\",\n\t\t\"routePanelForumsDelete\":                 \"panel.ForumsDelete\",\n\t\t\"routePanelForumsDeleteSubmit\":           \"panel.ForumsDeleteSubmit\",\n\t\t\"routePanelForumsEdit\":                   \"panel.ForumsEdit\",\n\t\t\"routePanelForumsEditSubmit\":             \"panel.ForumsEditSubmit\",\n\t\t\"routePanelForumsEditPermsSubmit\":        \"panel.ForumsEditPermsSubmit\",\n\t\t\"routePanelForumsEditPermsAdvance\":       \"panel.ForumsEditPermsAdvance\",\n\t\t\"routePanelForumsEditPermsAdvanceSubmit\": \"panel.ForumsEditPermsAdvanceSubmit\",\n\t\t\"routePanelBackups\":                      \"panel.Backups\",\n\t}\n\te := renameRoutes(routes)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\te = execStmt(qgen.Builder.SimpleDelete(\"settings\", \"name='url_tags'\"))\n\tif e != nil {\n\t\treturn e\n\t}\n\n\treturn createTable(\"pages\", \"utf8mb4\", \"utf8mb4_general_ci\",\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t\tccol(\"title\", 200, \"\"),\n\t\t\t{\"body\", \"text\", 0, false, false, \"\"},\n\t\t\t{\"allowedGroups\", \"text\", 0, false, false, \"\"},\n\t\t\t{\"menuID\", \"int\", 0, false, false, \"-1\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nfunc patch5(scanner *bufio.Scanner) error {\n\troutes := map[string]string{\n\t\t\"routePanelUsers\":                  \"panel.Users\",\n\t\t\"routePanelUsersEdit\":              \"panel.UsersEdit\",\n\t\t\"routePanelUsersEditSubmit\":        \"panel.UsersEditSubmit\",\n\t\t\"routes.AccountEditCritical\":       \"routes.AccountEditPassword\",\n\t\t\"routes.AccountEditCriticalSubmit\": \"routes.AccountEditPasswordSubmit\",\n\t}\n\te := renameRoutes(routes)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\te = execStmt(qgen.Builder.SimpleUpdate(\"menu_items\", \"path='/user/edit/'\", \"path='/user/edit/critical/'\"))\n\tif e != nil {\n\t\treturn e\n\t}\n\n\treturn createTable(\"users_2fa_keys\", \"utf8mb4\", \"utf8mb4_general_ci\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"secret\", 100, \"\"),\n\t\t\tccol(\"scratch1\", 50, \"\"),\n\t\t\tccol(\"scratch2\", 50, \"\"),\n\t\t\tccol(\"scratch3\", 50, \"\"),\n\t\t\tccol(\"scratch4\", 50, \"\"),\n\t\t\tccol(\"scratch5\", 50, \"\"),\n\t\t\tccol(\"scratch6\", 50, \"\"),\n\t\t\tccol(\"scratch7\", 50, \"\"),\n\t\t\tccol(\"scratch8\", 50, \"\"),\n\t\t\t{\"createdAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nfunc patch6(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.SimpleInsert(\"settings\", \"name, content, type\", \"'rapid_loading','1','bool'\"))\n}\n\nfunc patch7(scanner *bufio.Scanner) error {\n\treturn createTable(\"users_avatar_queue\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t},\n\t\t[]tK{\n\t\t\t{\"uid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nfunc renameRoutes(routes map[string]string) error {\n\t// ! Don't reuse this function blindly, it doesn't escape apostrophes\n\treplaceTextWhere := func(replaceThis string, withThis string) error {\n\t\treturn execStmt(qgen.Builder.SimpleUpdate(\"viewchunks\", \"route = '\"+withThis+\"'\", \"route = '\"+replaceThis+\"'\"))\n\t}\n\n\tfor key, value := range routes {\n\t\te := replaceTextWhere(key, value)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc patch8(scanner *bufio.Scanner) error {\n\troutes := map[string]string{\n\t\t\"routePanelWordFilter\":                 \"panel.WordFilters\",\n\t\t\"routePanelWordFiltersCreateSubmit\":    \"panel.WordFiltersCreateSubmit\",\n\t\t\"routePanelWordFiltersEdit\":            \"panel.WordFiltersEdit\",\n\t\t\"routePanelWordFiltersEditSubmit\":      \"panel.WordFiltersEditSubmit\",\n\t\t\"routePanelWordFiltersDeleteSubmit\":    \"panel.WordFiltersDeleteSubmit\",\n\t\t\"routePanelPlugins\":                    \"panel.Plugins\",\n\t\t\"routePanelPluginsActivate\":            \"panel.PluginsActivate\",\n\t\t\"routePanelPluginsDeactivate\":          \"panel.PluginsDeactivate\",\n\t\t\"routePanelPluginsInstall\":             \"panel.PluginsInstall\",\n\t\t\"routePanelGroups\":                     \"panel.Groups\",\n\t\t\"routePanelGroupsEdit\":                 \"panel.GroupsEdit\",\n\t\t\"routePanelGroupsEditPerms\":            \"panel.GroupsEditPerms\",\n\t\t\"routePanelGroupsEditSubmit\":           \"panel.GroupsEditSubmit\",\n\t\t\"routePanelGroupsEditPermsSubmit\":      \"panel.GroupsEditPermsSubmit\",\n\t\t\"routePanelGroupsCreateSubmit\":         \"panel.GroupsCreateSubmit\",\n\t\t\"routePanelThemes\":                     \"panel.Themes\",\n\t\t\"routePanelThemesSetDefault\":           \"panel.ThemesSetDefault\",\n\t\t\"routePanelThemesMenus\":                \"panel.ThemesMenus\",\n\t\t\"routePanelThemesMenusEdit\":            \"panel.ThemesMenusEdit\",\n\t\t\"routePanelThemesMenuItemEdit\":         \"panel.ThemesMenuItemEdit\",\n\t\t\"routePanelThemesMenuItemEditSubmit\":   \"panel.ThemesMenuItemEditSubmit\",\n\t\t\"routePanelThemesMenuItemCreateSubmit\": \"panel.ThemesMenuItemCreateSubmit\",\n\t\t\"routePanelThemesMenuItemDeleteSubmit\": \"panel.ThemesMenuItemDeleteSubmit\",\n\t\t\"routePanelThemesMenuItemOrderSubmit\":  \"panel.ThemesMenuItemOrderSubmit\",\n\t\t\"routePanelDashboard\":                  \"panel.Dashboard\",\n\t}\n\te := renameRoutes(routes)\n\tif e != nil {\n\t\treturn e\n\t}\n\n\treturn createTable(\"updates\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"dbVersion\", \"int\", 0, false, false, \"0\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch9(scanner *bufio.Scanner) error {\n\t// Table \"updates\" might not exist due to the installer, so drop it and remake it if so\n\terr := patch8(scanner)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn createTable(\"login_logs\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"lid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\tbcol(\"success\", false), // Did this attempt succeed?\n\t\t\tccol(\"ipaddress\", 200, \"\"),\n\t\t\t{\"doneAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"lid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nvar acc = qgen.NewAcc\nvar itoa = strconv.Itoa\n\nfunc patch10(scanner *bufio.Scanner) error {\n\terr := execStmt(qgen.Builder.AddColumn(\"topics\", tC{\"attachCount\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.AddColumn(\"topics\", tC{\"lastReplyID\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = acc().Select(\"topics\").Cols(\"tid\").EachInt(func(tid int) error {\n\t\tstid := itoa(tid)\n\t\tcount, err := acc().Count(\"attachments\").Where(\"originTable = 'topics' and originID=\" + stid).Total()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\thasReply := false\n\t\terr = acc().Select(\"replies\").Cols(\"rid\").Where(\"tid=\" + stid).Orderby(\"rid DESC\").Limit(\"1\").EachInt(func(rid int) error {\n\t\t\thasReply = true\n\t\t\t_, err := acc().Update(\"topics\").Set(\"lastReplyID=?, attachCount=?\").Where(\"tid=\"+stid).Exec(rid, count)\n\t\t\treturn err\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !hasReply {\n\t\t\t_, err = acc().Update(\"topics\").Set(\"attachCount=?\").Where(\"tid=\" + stid).Exec(count)\n\t\t}\n\t\treturn err\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = acc().Insert(\"updates\").Columns(\"dbVersion\").Fields(\"0\").Exec()\n\treturn err\n}\n\nfunc patch11(scanner *bufio.Scanner) error {\n\terr := execStmt(qgen.Builder.AddColumn(\"replies\", tC{\"attachCount\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Attachments for replies got the topicID rather than the replyID for a while in error, so we want to separate these out\n\t_, err = acc().Update(\"attachments\").Set(\"originTable='freplies'\").Where(\"originTable='replies'\").Exec()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// We could probably do something more efficient, but as there shouldn't be too many sites right now, we can probably cheat a little, otherwise it'll take forever to get things done\n\treturn acc().Select(\"topics\").Cols(\"tid\").EachInt(func(tid int) error {\n\t\tstid := itoa(tid)\n\t\tcount, err := acc().Count(\"attachments\").Where(\"originTable='topics' and originID=\" + stid).Total()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = acc().Update(\"topics\").Set(\"attachCount=?\").Where(\"tid=\" + stid).Exec(count)\n\t\treturn err\n\t})\n\n\t/*return acc().Select(\"replies\").Cols(\"rid\").EachInt(func(rid int) error {\n\t\tsrid := itoa(rid)\n\t\tcount, err := acc().Count(\"attachments\").Where(\"originTable='replies' and originID=\" + srid).Total()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = acc().Update(\"replies\").Set(\"attachCount = ?\").Where(\"rid=\" + srid).Exec(count)\n\t\treturn err\n\t})*/\n}\n\nfunc patch12(scanner *bufio.Scanner) error {\n\tvar e error\n\taddIndex := func(tbl, iname, colname string) {\n\t\tif e != nil {\n\t\t\treturn\n\t\t}\n\t\t/*e = execStmt(qgen.Builder.RemoveIndex(tbl, iname))\n\t\tif e != nil {\n\t\t\treturn\n\t\t}*/\n\t\te = execStmt(qgen.Builder.AddIndex(tbl, iname, colname))\n\t}\n\taddIndex(\"topics\", \"parentID\", \"parentID\")\n\taddIndex(\"replies\", \"tid\", \"tid\")\n\taddIndex(\"polls\", \"parentID\", \"parentID\")\n\taddIndex(\"likes\", \"targetItem\", \"targetItem\")\n\taddIndex(\"emails\", \"uid\", \"uid\")\n\taddIndex(\"attachments\", \"originID\", \"originID\")\n\taddIndex(\"attachments\", \"path\", \"path\")\n\taddIndex(\"activity_stream_matches\", \"watcher\", \"watcher\")\n\treturn e\n}\n\nfunc patch13(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"widgets\", tC{\"wid\", \"int\", 0, false, true, \"\"}, &tK{\"wid\", \"primary\", \"\", false}))\n}\n\nfunc patch14(scanner *bufio.Scanner) error {\n\t/*err := execStmt(qgen.Builder.AddKey(\"topics\", \"title\", tK{\"title\", \"fulltext\", \"\", false}))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.AddKey(\"topics\", \"content\", tK{\"content\", \"fulltext\", \"\", false}))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.AddKey(\"replies\", \"content\", tK{\"content\", \"fulltext\", \"\", false}))\n\tif err != nil {\n\t\treturn err\n\t}*/\n\n\treturn nil\n}\n\nfunc patch15(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.SimpleInsert(\"settings\", \"name, content, type\", \"'google_site_verify','','html-attribute'\"))\n}\n\nfunc patch16(scanner *bufio.Scanner) error {\n\treturn createTable(\"password_resets\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"email\", 200, \"\"),\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\tccol(\"validated\", 200, \"\"),          // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token\n\t\t\tccol(\"token\", 200, \"\"),\n\t\t\t{\"createdAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch17(scanner *bufio.Scanner) error {\n\terr := execStmt(qgen.Builder.AddColumn(\"attachments\", ccol(\"extra\", 200, \"\"), nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = acc().Select(\"topics\").Cols(\"tid,parentID\").Where(\"attachCount > 0\").Each(func(rows *sql.Rows) error {\n\t\tvar tid, parentID int\n\t\terr := rows.Scan(&tid, &parentID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = acc().Update(\"attachments\").Set(\"sectionID=?\").Where(\"originTable='topics' AND originID=?\").Exec(parentID, tid)\n\t\treturn err\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn acc().Select(\"replies\").Cols(\"rid,tid\").Where(\"attachCount > 0\").Each(func(rows *sql.Rows) error {\n\t\tvar rid, tid, sectionID int\n\t\terr := rows.Scan(&rid, &tid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = acc().Select(\"topics\").Cols(\"parentID\").Where(\"tid=?\").QueryRow(tid).Scan(&sectionID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = acc().Update(\"attachments\").Set(\"sectionID=?, extra=?\").Where(\"originTable='replies' AND originID=?\").Exec(sectionID, tid, rid)\n\t\treturn err\n\t})\n}\n\nfunc patch18(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"forums\", tC{\"order\", \"int\", 0, false, false, \"0\"}, nil))\n}\n\nfunc patch19(scanner *bufio.Scanner) error {\n\treturn createTable(\"memchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"count\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch20(scanner *bufio.Scanner) error {\n\terr := acc().Select(\"activity_stream_matches\").Cols(\"asid\").Each(func(rows *sql.Rows) error {\n\t\tvar asid int\n\t\tif e := rows.Scan(&asid); e != nil {\n\t\t\treturn e\n\t\t}\n\t\te := acc().Select(\"activity_stream\").Cols(\"asid\").Where(\"asid=?\").QueryRow(asid).Scan(&asid)\n\t\tif e != sql.ErrNoRows {\n\t\t\treturn e\n\t\t}\n\t\t_, e = acc().Delete(\"activity_stream_matches\").Where(\"asid=?\").Run(asid)\n\t\treturn e\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn execStmt(qgen.Builder.AddForeignKey(\"activity_stream_matches\", \"asid\", \"activity_stream\", \"asid\", true))\n}\n\nfunc patch21(scanner *bufio.Scanner) error {\n\terr := execStmt(qgen.Builder.AddColumn(\"memchunks\", tC{\"stack\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = execStmt(qgen.Builder.AddColumn(\"memchunks\", tC{\"heap\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = createTable(\"meta\", \"\", \"\",\n\t\t[]tC{\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t\tccol(\"value\", 200, \"\"),\n\t\t}, nil,\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn execStmt(qgen.Builder.AddColumn(\"activity_stream\", tC{\"createdAt\", \"createdAt\", 0, false, false, \"\"}, nil))\n}\n\nfunc patch22(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"forums\", ccol(\"tmpl\", 200, \"''\"), nil))\n}\n\nfunc patch23(scanner *bufio.Scanner) error {\n\terr := createTable(\"conversations\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"cid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"}, // TODO: Make this a foreign key\n\t\t\t{\"createdAt\", \"createdAt\", 0, false, false, \"\"},\n\t\t\t{\"lastReplyAt\", \"datetime\", 0, false, false, \"\"},\n\t\t\t{\"lastReplyBy\", \"int\", 0, false, false, \"\"},\n\t\t},\n\t\t[]tK{\n\t\t\t{\"cid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = createTable(\"conversations_posts\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"cid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"createdBy\", \"int\", 0, false, false, \"\"},\n\t\t\tccol(\"body\", 50, \"\"),\n\t\t\tccol(\"post\", 50, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn createTable(\"conversations_participants\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"uid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"cid\", \"int\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch24(scanner *bufio.Scanner) error {\n\treturn createTable(\"users_groups_promotions\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"pid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"from_gid\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"to_gid\", \"int\", 0, false, false, \"\"},\n\t\t\tbcol(\"two_way\", false), // If a user no longer meets the requirements for this promotion then they will be demoted if this flag is set\n\n\t\t\t// Requirements\n\t\t\t{\"level\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"minTime\", \"int\", 0, false, false, \"\"}, // How long someone needs to have been in their current group before being promoted\n\t\t},\n\t\t[]tK{\n\t\t\t{\"pid\", \"primary\", \"\", false},\n\t\t},\n\t)\n}\n\nfunc patch25(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"users_groups_promotions\", tC{\"posts\", \"int\", 0, false, false, \"0\"}, nil))\n}\n\nfunc patch26(scanner *bufio.Scanner) error {\n\treturn createTable(\"users_blocks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"blocker\", \"int\", 0, false, false, \"\"},\n\t\t\t{\"blockedUser\", \"int\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch27(scanner *bufio.Scanner) error {\n\terr := execStmt(qgen.Builder.AddColumn(\"moderation_logs\", tC{\"extra\", \"text\", 0, false, false, \"\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn execStmt(qgen.Builder.AddColumn(\"administration_logs\", tC{\"extra\", \"text\", 0, false, false, \"\"}, nil))\n}\n\nfunc patch28(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"users\", tC{\"enable_embeds\", \"int\", 0, false, false, \"-1\"}, nil))\n}\n\n// The word counter might run into problems with some languages where words aren't as obviously demarcated, I would advise turning it off in those cases, or if it becomes annoying in general, really.\nfunc WordCount(input string) (count int) {\n\tinput = strings.TrimSpace(input)\n\tif input == \"\" {\n\t\treturn 0\n\t}\n\n\tvar inSpace bool\n\tfor _, value := range input {\n\t\tif unicode.IsSpace(value) || unicode.IsPunct(value) {\n\t\t\tif !inSpace {\n\t\t\t\tinSpace = true\n\t\t\t}\n\t\t} else if inSpace {\n\t\t\tcount++\n\t\t\tinSpace = false\n\t\t}\n\t}\n\n\treturn count + 1\n}\n\nfunc patch29(scanner *bufio.Scanner) error {\n\tf := func(tbl, idCol string) error {\n\t\treturn acc().Select(tbl).Cols(idCol + \",content\").Each(func(rows *sql.Rows) error {\n\t\t\tvar id int\n\t\t\tvar content string\n\t\t\terr := rows.Scan(&id, &content)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\t_, err = acc().Update(tbl).Set(\"words=?\").Where(idCol+\"=?\").Exec(WordCount(content), id)\n\t\t\treturn err\n\t\t})\n\t}\n\terr := f(\"topics\", \"tid\")\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = f(\"replies\", \"rid\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmeta, err := meta.NewDefaultMetaStore(acc())\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = meta.Set(\"sched\", \"recalc\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfixCols := func(tbls ...string) error {\n\t\tfor _, tbl := range tbls {\n\t\t\t//err := execStmt(qgen.Builder.RenameColumn(tbl, \"ipaddress\",\"ip\"))\n\t\t\terr := execStmt(qgen.Builder.ChangeColumn(tbl, \"ipaddress\", ccol(\"ip\", 200, \"''\")))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr = execStmt(qgen.Builder.SetDefaultColumn(tbl, \"ip\", \"varchar\", \"\"))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\terr = fixCols(\"topics\", \"replies\", \"polls_votes\", \"users_replies\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = execStmt(qgen.Builder.SetDefaultColumn(\"replies\", \"lastEdit\", \"int\", \"0\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.SetDefaultColumn(\"replies\", \"lastEditBy\", \"int\", \"0\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.SetDefaultColumn(\"users_replies\", \"lastEdit\", \"int\", \"0\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = execStmt(qgen.Builder.SetDefaultColumn(\"users_replies\", \"lastEditBy\", \"int\", \"0\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn execStmt(qgen.Builder.AddColumn(\"activity_stream\", tC{\"extra\", \"varchar\", 200, false, false, \"''\"}, nil))\n\n}\n\nfunc patch30(scanner *bufio.Scanner) error {\n\te := execStmt(qgen.Builder.AddColumn(\"users_groups_promotions\", tC{\"registeredFor\", \"int\", 0, false, false, \"0\"}, nil))\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn execStmt(qgen.Builder.SetDefaultColumn(\"users\", \"last_ip\", \"varchar\", \"\"))\n}\n\nfunc patch31(scanner *bufio.Scanner) (e error) {\n\taddKey := func(tbl, col string, tk qgen.DBTableKey) error {\n\t\t/*e := execStmt(qgen.Builder.RemoveIndex(tbl, col))\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}*/\n\t\treturn execStmt(qgen.Builder.AddKey(tbl, col, tk))\n\t}\n\te = addKey(\"topics\", \"title\", tK{\"title\", \"fulltext\", \"\", false})\n\tif e != nil {\n\t\treturn e\n\t}\n\te = addKey(\"topics\", \"content\", tK{\"content\", \"fulltext\", \"\", false})\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn addKey(\"replies\", \"content\", tK{\"content\", \"fulltext\", \"\", false})\n}\n\nfunc createTable(tbl, charset, collation string, cols []tC, keys []tK) error {\n\te := execStmt(qgen.Builder.DropTable(tbl))\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn execStmt(qgen.Builder.CreateTable(tbl, charset, collation, cols, keys))\n}\n\nfunc patch32(scanner *bufio.Scanner) error {\n\treturn createTable(\"perfchunks\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"low\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"high\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"avg\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"createdAt\", \"datetime\", 0, false, false, \"\"},\n\t\t}, nil,\n\t)\n}\n\nfunc patch33(scanner *bufio.Scanner) error {\n\treturn execStmt(qgen.Builder.AddColumn(\"viewchunks\", tC{\"avg\", \"int\", 0, false, false, \"0\"}, nil))\n}\n\nfunc patch34(scanner *bufio.Scanner) error {\n\t/*err := createTable(\"tables\", \"\", \"\",\n\t\t[]tC{\n\t\t\t{\"id\", \"int\", 0, false, true, \"\"},\n\t\t\tccol(\"name\", 200, \"\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"id\", \"primary\", \"\", false},\n\t\t\t{\"name\", \"unique\", \"\", false},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\tinsert := func(tbl, cols, fields string) {\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = execStmt(qgen.Builder.SimpleInsert(tbl, cols, fields))\n\t}\n\tinsert(\"tables\", \"name\", \"forums\")\n\tinsert(\"tables\", \"name\", \"topics\")\n\tinsert(\"tables\", \"name\", \"replies\")\n\t// ! Hold onto freplies for a while longer\n\tinsert(\"tables\", \"name\", \"freplies\")*/\n\t/*err := execStmt(qgen.Builder.AddColumn(\"topics\", tC{\"attachCount\", \"int\", 0, false, false, \"0\"}, nil))\n\tif err != nil {\n\t\treturn err\n\t}*/\n\toverwriteColumn := func(tbl, col string, tc qgen.DBTableColumn) error {\n\t\t/*e := execStmt(qgen.Builder.DropColumn(tbl, col))\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}*/\n\t\treturn execStmt(qgen.Builder.AddColumn(tbl, tc, nil))\n\t}\n\terr := overwriteColumn(\"users\", \"profile_comments\", tC{\"profile_comments\", \"int\", 0, false, false, \"0\"})\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = overwriteColumn(\"users\", \"who_can_convo\", tC{\"who_can_convo\", \"int\", 0, false, false, \"0\"})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsetDefault := func(tbl, col, typ, val string) {\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = execStmt(qgen.Builder.SetDefaultColumn(tbl, col, typ, val))\n\t}\n\tsetDefault(\"users_groups\", \"permissions\", \"text\", \"{}\")\n\tsetDefault(\"users_groups\", \"plugin_perms\", \"text\", \"{}\")\n\tsetDefault(\"forums_permissions\", \"permissions\", \"text\", \"{}\")\n\tsetDefault(\"topics\", \"content\", \"text\", \"\")\n\tsetDefault(\"topics\", \"parsed_content\", \"text\", \"\")\n\tsetDefault(\"replies\", \"content\", \"text\", \"\")\n\tsetDefault(\"replies\", \"parsed_content\", \"text\", \"\")\n\t//setDefault(\"revisions\", \"content\", \"text\", \"\")\n\tsetDefault(\"users_replies\", \"content\", \"text\", \"\")\n\tsetDefault(\"users_replies\", \"parsed_content\", \"text\", \"\")\n\tsetDefault(\"pages\", \"body\", \"text\", \"\")\n\tsetDefault(\"pages\", \"allowedGroups\", \"text\", \"\")\n\tsetDefault(\"moderation_logs\", \"extra\", \"text\", \"\")\n\tsetDefault(\"administration_logs\", \"extra\", \"text\", \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc patch35(scanner *bufio.Scanner) error {\n\te := execStmt(qgen.Builder.AddColumn(\"topics\", tC{\"weekEvenViews\", \"int\", 0, false, false, \"0\"}, nil))\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn execStmt(qgen.Builder.AddColumn(\"topics\", tC{\"weekOddViews\", \"int\", 0, false, false, \"0\"}, nil))\n}\n\nfunc patch36(scanner *bufio.Scanner) error {\n\te := createTable(\"forums_actions\", \"utf8mb4\", \"utf8mb4_general_ci\",\n\t\t[]tC{\n\t\t\t{\"faid\", \"int\", 0, false, true, \"\"},\n\t\t\t{\"fid\", \"int\", 0, false, false, \"\"},\n\t\t\tbcol(\"runOnTopicCreation\", false),\n\t\t\t{\"runDaysAfterTopicCreation\", \"int\", 0, false, false, \"0\"},\n\t\t\t{\"runDaysAfterTopicLastReply\", \"int\", 0, false, false, \"0\"},\n\t\t\tccol(\"action\", 50, \"\"),\n\t\t\tccol(\"extra\", 200, \"''\"),\n\t\t},\n\t\t[]tK{\n\t\t\t{\"faid\", \"primary\", \"\", false},\n\t\t},\n\t)\n\tif e != nil {\n\t\treturn e\n\t}\n\treturn execStmt(qgen.Builder.SimpleInsert(\"settings\", \"name, content, type, constraints\", \"'avatar_visibility','0','list','0-1'\"))\n}\n"
  },
  {
    "path": "patcher/utils.go",
    "content": "package main\n\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/query_gen\"\n\nfunc execStmt(stmt *sql.Stmt, err error) error {\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = stmt.Exec()\n\treturn err\n}\n\n/*func eachUserQuick(handle func(int)) error {\n\tstmt, err := qgen.Builder.Select(\"users\").Orderby(\"uid desc\").Limit(1).Prepare()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar topID int\n\terr := stmt.QueryRow(topID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor i := 1; i <= topID; i++ {\n\t\terr = handle(i)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n}*/\n\nfunc eachUser(handle func(int) error) error {\n\terr := qgen.NewAcc().Select(\"users\").Cols(\"uid\").Each(func(rows *sql.Rows) error {\n\t\tvar uid int\n\t\terr := rows.Scan(&uid)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn handle(uid)\n\t})\n\treturn err\n}\n"
  },
  {
    "path": "pgsql.go",
    "content": "// +build pgsql\n\n/* Copyright Azareal 2016 - 2020 */\n/* Super experimental and incomplete. DON'T USE IT YET! */\npackage main\n\nimport (\n\t\"database/sql\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t_ \"github.com/lib/pq\"\n)\n\n// TODO: Add support for SSL for all database drivers, not just pgsql\nvar dbSslmode = \"disable\" // verify-full\n\nfunc init() {\n\tdbAdapter = \"pgsql\"\n\t_initDatabase = initPgsql\n}\n\nfunc initPgsql() (err error) {\n\t// TODO: Investigate connect_timeout to see what it does exactly and whether it's relevant to us\n\tvar _dbpassword string\n\tif c.DbConfig.Password != \"\" {\n\t\t_dbpassword = \" password='\" + _escape_bit(c.DbConfig.Password) + \"'\"\n\t}\n\t// TODO: Move this bit to the query gen lib\n\tdb, err = sql.Open(\"postgres\", \"host='\"+_escape_bit(c.DbConfig.Host)+\"' port='\"+_escape_bit(c.DbConfig.Port)+\"' user='\"+_escape_bit(c.DbConfig.Username)+\"' dbname='\"+_escape_bit(c.DbConfig.Dbname)+\"'\"+_dbpassword+\" sslmode='\"+dbSslmode+\"'\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Make sure that the connection is alive\n\terr = db.Ping()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set the number of max open connections. How many do we need? Might need to do some tests.\n\tdb.SetMaxOpenConns(64)\n\tdb.SetMaxIdleConns(32)\n\n\t// Only hold connections open for five seconds to avoid accumulating a large number of stale connections\n\t//db.SetConnMaxLifetime(5 * time.Second)\n\tdb.SetConnMaxLifetime(c.DBTimeout())\n\n\terr = _gen_pgsql()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Ready the query builder\n\tqgen.Builder.SetConn(db)\n\terr = qgen.Builder.SetAdapter(\"pgsql\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// TO-DO Handle the queries which the query generator can't handle yet\n\n\treturn nil\n}\n\nfunc _escape_bit(bit string) string {\n\t// TODO: Write a custom parser, so that backslashes work properly in the sql.Open string. Do something similar for the database driver, if possible?\n\treturn strings.Replace(bit, \"'\", \"\\\\'\", -1)\n}\n"
  },
  {
    "path": "plugin_test.go",
    "content": "package main\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\te \"github.com/Azareal/Gosora/extend\"\n)\n\n// go test -v\n\n// TODO: Write a test for Hello World?\n\ntype MEPair struct {\n\tMsg     string\n\tExpects string\n}\n\ntype MEPairList struct {\n\tItems []MEPair\n}\n\nfunc (l *MEPairList) Add(msg, expects string) {\n\tl.Items = append(l.Items, MEPair{msg, expects})\n}\n\nfunc TestBBCodeRender(t *testing.T) {\n\t//t.Skip()\n\tif e := e.InitBbcode(c.Plugins[\"bbcode\"]); e != nil {\n\t\tt.Fatal(e)\n\t}\n\n\tvar res string\n\tl := &MEPairList{nil}\n\tl.Add(\"\", \"\")\n\tl.Add(\" \", \" \")\n\tl.Add(\"  \", \"  \")\n\tl.Add(\"   \", \"   \")\n\tl.Add(\"[b]\", \"<b></b>\")\n\tl.Add(\"[b][/b]\", \"<b></b>\")\n\tl.Add(\"hi\", \"hi\")\n\tl.Add(\"😀\", \"😀\")\n\tl.Add(\"[b]😀[/b]\", \"<b>😀</b>\")\n\tl.Add(\"[b]😀😀😀[/b]\", \"<b>😀😀😀</b>\")\n\tl.Add(\"[b]hi[/b]\", \"<b>hi</b>\")\n\tl.Add(\"[u]hi[/u]\", \"<u>hi</u>\")\n\tl.Add(\"[i]hi[/i]\", \"<i>hi</i>\")\n\tl.Add(\"[s]hi[/s]\", \"<s>hi</s>\")\n\tl.Add(\"[c]hi[/c]\", \"[c]hi[/c]\")\n\tl.Add(\"[h1]hi\", \"[h1]hi\")\n\tl.Add(\"[h1]hi[/h1]\", \"<h2>hi</h2>\")\n\tif !testing.Short() {\n\t\t//l.Add(\"[b]hi[/i]\", \"[b]hi[/i]\")\n\t\t//l.Add(\"[/b]hi[b]\", \"[/b]hi[b]\")\n\t\t//l.Add(\"[/b]hi[/b]\", \"[/b]hi[/b]\")\n\t\t//l.Add(\"[b][b]hi[/b]\", \"<b>hi</b>\")\n\t\t//l.Add(\"[b][b]hi\", \"[b][b]hi\")\n\t\t//l.Add(\"[b][b][b]hi\", \"[b][b][b]hi\")\n\t\t//l.Add(\"[/b]hi\", \"[/b]hi\")\n\t}\n\tl.Add(\"[spoiler]hi[/spoiler]\", \"<spoiler>hi</spoiler>\")\n\tl.Add(\"[code]hi[/code]\", \"<span class='codequotes'>hi</span>\")\n\tl.Add(\"[code][b]hi[/b][/code]\", \"<span class='codequotes'>[b]hi[/b]</span>\")\n\tl.Add(\"[code][b]hi[/code][/b]\", \"<span class='codequotes'>[b]hi</span>[/b]\")\n\tl.Add(\"[quote]hi[/quote]\", \"<blockquote>hi</blockquote>\")\n\tl.Add(\"[quote][b]hi[/b][/quote]\", \"<blockquote><b>hi</b></blockquote>\")\n\tl.Add(\"[quote][b]h[/b][/quote]\", \"<blockquote><b>h</b></blockquote>\")\n\tl.Add(\"[quote][b][/b][/quote]\", \"<blockquote><b></b></blockquote>\")\n\tl.Add(\"[url][/url]\", \"<a href=''></a>\")\n\tl.Add(\"[url]https://github.com/Azareal/Gosora[/url]\", \"<a href='https://github.com/Azareal/Gosora'>https://github.com/Azareal/Gosora</a>\")\n\tl.Add(\"[url]http://github.com/Azareal/Gosora[/url]\", \"<a href='http://github.com/Azareal/Gosora'>http://github.com/Azareal/Gosora</a>\")\n\tl.Add(\"[url]//github.com/Azareal/Gosora[/url]\", \"<a href='//github.com/Azareal/Gosora'>//github.com/Azareal/Gosora</a>\")\n\tl.Add(\"-你好-\", \"-你好-\")\n\tl.Add(\"[i]-你好-[/i]\", \"<i>-你好-</i>\") // TODO: More of these Unicode tests? Emoji, Chinese, etc.?\n\n\tt.Log(\"Testing BbcodeFullParse\")\n\tfor _, item := range l.Items {\n\t\tres = e.BbcodeFullParse(item.Msg)\n\t\tif res != item.Expects {\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t}\n\t}\n\n\tf := func(msg, expects string) {\n\t\tt.Log(\"Testing string '\" + msg + \"'\")\n\t\tres := e.BbcodeFullParse(msg)\n\t\tif res != expects {\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+expects+\"'\")\n\t\t}\n\t}\n\tf(\"[rand][/rand]\", \"<red>[Invalid Number]</red>[rand][/rand]\")\n\tf(\"[rand]-1[/rand]\", \"<red>[No Negative Numbers]</red>[rand]-1[/rand]\")\n\tf(\"[rand]-01[/rand]\", \"<red>[No Negative Numbers]</red>[rand]-01[/rand]\")\n\tf(\"[rand]NaN[/rand]\", \"<red>[Invalid Number]</red>[rand]NaN[/rand]\")\n\tf(\"[rand]Inf[/rand]\", \"<red>[Invalid Number]</red>[rand]Inf[/rand]\")\n\tf(\"[rand]+[/rand]\", \"<red>[Invalid Number]</red>[rand]+[/rand]\")\n\tf(\"[rand]1+1[/rand]\", \"<red>[Invalid Number]</red>[rand]1+1[/rand]\")\n\n\tmsg := \"[rand]1[/rand]\"\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\tconv, err := strconv.Atoi(res)\n\tif err != nil || (conv > 1 || conv < 0) {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected a number in the range 0-1\")\n\t}\n\n\tmsg = \"[rand]0[/rand]\"\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\tconv, err = strconv.Atoi(res)\n\tif err != nil || conv != 0 {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected the number 0\")\n\t}\n\n\tmsg = \"[rand]2147483647[/rand]\" // Signed 32-bit MAX\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\tconv, err = strconv.Atoi(res)\n\tif err != nil || (conv > 2147483647 || conv < 0) {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected a number between 0 and 2147483647\")\n\t}\n\n\tmsg = \"[rand]9223372036854775807[/rand]\" // Signed 64-bit MAX\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\tconv, err = strconv.Atoi(res)\n\tif err != nil || (conv > 9223372036854775807 || conv < 0) {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected a number between 0 and 9223372036854775807\")\n\t}\n\n\t// Note: conv is commented out in these two, as these numbers overflow int\n\tmsg = \"[rand]18446744073709551615[/rand]\" // Unsigned 64-bit MAX\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\t_, err = strconv.Atoi(res)\n\tif err != nil && res != \"<red>[Invalid Number]</red>[rand]18446744073709551615[/rand]\" {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected a number between 0 and 18446744073709551615\")\n\t}\n\tmsg = \"[rand]170141183460469231731687303715884105727[/rand]\" // Signed 128-bit MAX\n\tt.Log(\"Testing string '\" + msg + \"'\")\n\tres = e.BbcodeFullParse(msg)\n\t_, err = strconv.Atoi(res)\n\tif err != nil && res != \"<red>[Invalid Number]</red>[rand]170141183460469231731687303715884105727[/rand]\" {\n\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\tt.Error(\"Expected a number between 0 and 170141183460469231731687303715884105727\")\n\t}\n\n\t/*t.Log(\"Testing bbcode_regex_parse\")\n\tfor _, item := range l.Items {\n\t\tt.Log(\"Testing string '\" + item.Msg + \"'\")\n\t\tres = bbcodeRegexParse(item.Msg)\n\t\tif res != item.Expects {\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", item.Expects)\n\t\t}\n\t}*/\n\t\n\tl = &MEPairList{nil}\n\tl.Add(\"\", \"\")\n\tl.Add(\"ddd\", \"ddd\")\n\tl.Add(\"[b][/b]\", \"\")\n\tl.Add(\"[b]ddd[/b]\", \"ddd\")\n\tl.Add(\"ddd[b]ddd[/b]ddd\", \"ddddddddd\")\n\tl.Add(\"ddd\\n[b]ddd[/b]\\nddd\", \"ddd\\nddd\\nddd\")\n\tt.Log(\"Testing BbcodeStripTags\")\n\tfor _, item := range l.Items {\n\t\tres = e.BbcodeStripTags(item.Msg)\n\t\tif res != item.Expects {\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t}\n\t}\n}\n\nfunc TestMarkdownRender(t *testing.T) {\n\t//t.Skip()\n\tif err := e.InitMarkdown(c.Plugins[\"markdown\"]); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tl := &MEPairList{nil}\n\tl2 := &MEPairList{nil}\n\t// TODO: Fix more of these odd cases\n\tl.Add(\"\", \"\")\n\tl.Add(\" \", \" \")\n\tl.Add(\"  \", \"  \")\n\tl.Add(\"   \", \"   \")\n\tl.Add(\"\\t\", \"\\t\")\n\tl.Add(\"\\n\", \"\\n\")\n\tl.Add(\"*\", \"*\")\n\tl.Add(\"`\", \"`\")\n\t//l.Add(\"**\", \"<i></i>\")\n\tl.Add(\"h\", \"h\")\n\tl.Add(\"hi\", \"hi\")\n\tl.Add(\"**h**\", \"<b>h</b>\")\n\tl.Add(\"**hi**\", \"<b>hi</b>\")\n\tl.Add(\"_h_\", \"<u>h</u>\")\n\tl.Add(\"_hi_\", \"<u>hi</u>\")\n\tl.Add(\" _hi_\", \" <u>hi</u>\")\n\tl.Add(\"h_hi_h\", \"h_hi_h\")\n\tl.Add(\"h _hi_ h\", \"h <u>hi</u> h\")\n\tl.Add(\"h _hi_h\", \"h <u>hi</u>h\")\n\tl.Add(\"*h*\", \"<i>h</i>\")\n\tl.Add(\"*hi*\", \"<i>hi</i>\")\n\tl.Add(\"~h~\", \"<s>h</s>\")\n\tl.Add(\"~hi~\", \"<s>hi</s>\")\n\tl.Add(\"`hi`\", \"<blockquote>hi</blockquote>\")\n\t// TODO: Hide the backslash after escaping the item\n\t// TODO: Doesn't behave consistently with d in-front of it\n\tl2.Add(\"\\\\`hi`\", \"\\\\`hi`\")\n\tl2.Add(\"#\", \"#\")\n\tl2.Add(\"#h\", \"<h2>h</h2>\")\n\tl2.Add(\"#hi\", \"<h2>hi</h2>\")\n\tl2.Add(\"# hi\", \"<h2>hi</h2>\")\n\tl2.Add(\"#      hi\", \"<h2>hi</h2>\")\n\tl.Add(\"\\n#\", \"\\n#\")\n\tl.Add(\"\\n#h\", \"\\n<h2>h</h2>\")\n\tl.Add(\"\\n#hi\", \"\\n<h2>hi</h2>\")\n\tl.Add(\"\\n#h\\n\", \"\\n<h2>h</h2>\")\n\tl.Add(\"\\n#hi\\n\", \"\\n<h2>hi</h2>\")\n\tl.Add(\"*hi**\", \"<i>hi</i>*\")\n\tl.Add(\"**hi***\", \"<b>hi</b>*\")\n\t//l.Add(\"**hi*\", \"*<i>hi</i>\")\n\tl.Add(\"***hi***\", \"<b><i>hi</i></b>\")\n\tl.Add(\"***h***\", \"<b><i>h</i></b>\")\n\tl.Add(\"\\\\***h**\\\\*\", \"*<b>h</b>*\")\n\tl.Add(\"\\\\*\\\\**h*\\\\*\\\\*\", \"**<i>h</i>**\")\n\tl.Add(\"\\\\*hi\\\\*\", \"*hi*\")\n\tl.Add(\"d\\\\*hi\\\\*\", \"d*hi*\")\n\tl.Add(\"\\\\*hi\\\\*d\", \"*hi*d\")\n\tl.Add(\"d\\\\*hi\\\\*d\", \"d*hi*d\")\n\tl.Add(\"\\\\\", \"\\\\\")\n\tl.Add(\"\\\\\\\\\", \"\\\\\\\\\")\n\tl.Add(\"\\\\d\", \"\\\\d\")\n\tl.Add(\"\\\\\\\\d\", \"\\\\\\\\d\")\n\tl.Add(\"\\\\\\\\\\\\d\", \"\\\\\\\\\\\\d\")\n\tl.Add(\"d\\\\\", \"d\\\\\")\n\tl.Add(\"\\\\d\\\\\", \"\\\\d\\\\\")\n\tl.Add(\"*_hi_*\", \"<i><u>hi</u></i>\")\n\tl.Add(\"*~hi~*\", \"<i><s>hi</s></i>\")\n\t//l.Add(\"~*hi*~\", \"<s><i>hi</i></s>\")\n\t//l.Add(\"~ *hi* ~\", \"<s> <i>hi</i> </s>\")\n\tl.Add(\"_~hi~_\", \"<u><s>hi</s></u>\")\n\tl.Add(\"***~hi~***\", \"<b><i><s>hi</s></i></b>\")\n\tl.Add(\"**\", \"**\")\n\tl.Add(\"***\", \"***\")\n\tl.Add(\"****\", \"****\")\n\tl.Add(\"*****\", \"*****\")\n\tl.Add(\"******\", \"******\")\n\tl.Add(\"*******\", \"*******\")\n\tl.Add(\"~~\", \"~~\")\n\tl.Add(\"~~~\", \"~~~\")\n\tl.Add(\"~~~~\", \"~~~~\")\n\tl.Add(\"~~~~~\", \"~~~~~\")\n\tl.Add(\"|hi|\", \"<spoiler>hi</spoiler>\")\n\tl.Add(\"__\", \"__\")\n\tl.Add(\"___\", \"___\")\n\tl.Add(\"_ _\", \"<u> </u>\")\n\tl.Add(\"* *\", \"<i> </i>\")\n\tl.Add(\"** **\", \"<b> </b>\")\n\tl.Add(\"*** ***\", \"<b><i> </i></b>\")\n\tl.Add(\"-你好-\", \"-你好-\")\n\tl.Add(\"*-你好-*\", \"<i>-你好-</i>\") // TODO: More of these Unicode tests? Emoji, Chinese, etc.?\n\n\tfor _, item := range l.Items {\n\t\tif res := e.MarkdownParse(item.Msg); res != item.Expects {\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\t//t.Error(\"Ouput in bytes:\", []byte(res))\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t}\n\t}\n\n\tfor _, item := range l2.Items {\n\t\tif res := e.MarkdownParse(item.Msg); res != item.Expects {\n\t\t\tt.Error(\"Testing string '\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\t//t.Error(\"Ouput in bytes:\", []byte(res))\n\t\t\tt.Error(\"Expected:\", \"'\"+item.Expects+\"'\")\n\t\t}\n\t}\n\n\t/*for _, item := range l.Items {\n\t\tif res := e.MarkdownParse(\"d\" + item.Msg); res != \"d\"+item.Expects {\n\t\t\tt.Error(\"Testing string 'd\" + item.Msg + \"'\")\n\t\t\tt.Error(\"Bad output:\", \"'\"+res+\"'\")\n\t\t\t//t.Error(\"Ouput in bytes:\", []byte(res))\n\t\t\tt.Error(\"Expected:\", \"'d\"+item.Expects+\"'\")\n\t\t}\n\t}*/\n\n\t// TODO: Write suffix tests and double string tests\n\t// TODO: Write similar prefix, suffix, and double string tests for plugin_bbcode. Ditto for the outer parser along with suitable tests for that like making sure the URL parser and media embedder works.\n}\n"
  },
  {
    "path": "pre-run-linux",
    "content": "echo \"Deleting artifacts from previous builds\"\nrm -f template_*.go\nrm -f tmpl_*.go\nrm -f gen_*.go\nrm -f tmpl_client/template_*\nrm -f tmpl_client/tmpl_*\nrm -f ./Gosora\nrm -f ./common/gen_extend.go\n\necho \"Generating the dynamic code\"\ngo generate\n\necho \"Building the router generator\"\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\necho \"Running the router generator\"\n./RouterGen\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\necho \"Building the hook generator\"\ngo build -tags hookgen -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\necho \"Running the hook generator\"\n./HookGen\n\necho \"Building the query generator\"\ngo build -ldflags=\"-s -w\" -o QueryGen \"./cmd/query_gen\"\necho \"Running the query generator\"\n./QueryGen\n\necho \"Generating the JSON handlers\"\neasyjson -pkg common\n\necho \"Building Gosora\"\ngo build -ldflags=\"-s -w\" -o Gosora\n\necho \"Building the templates\"\n./Gosora -build-templates\n\necho \"Building Gosora... Again\"\ngo build -ldflags=\"-s -w\" -o Gosora"
  },
  {
    "path": "public/EQCSS.js",
    "content": "/*\n\n#  EQCSS\n## version 1.7.0\n\nA JavaScript plugin to read EQCSS syntax to provide:\nscoped styles, element queries, container queries,\nmeta-selectors, eval(), and element-based units.\n\n- github.com/eqcss/eqcss\n- elementqueries.com\n\nAuthors: Tommy Hodgins, Maxime Euzière, Azareal\n\nLicense: MIT\n\n*/\n\n// Uses Node, AMD or browser globals to create a module\n(function (root, factory) {\n  if (typeof define === 'function' && define.amd) {\n    // AMD: Register as an anonymous module\n    define([], factory);\n  } else if (typeof module === 'object' && module.exports) {\n    // Node: Does not work with strict CommonJS, but\n    // only CommonJS-like environments that support module.exports,\n    // like Node\n    module.exports = factory();\n  } else {\n    // Browser globals (root is window)\n    root.EQCSS = factory();\n  }\n}(this, function() {\n    var EQCSS = {\n      data: []\n    }\n\n    /*\n     * EQCSS.load()\n     * Called automatically on page load.\n     * Call it manually after adding EQCSS code in the page.\n     * Loads and parses all the EQCSS code.\n     */\n    EQCSS.load = function() {\n      // Retrieve all style blocks\n      var styles = document.getElementsByTagName('style');\n\n      for (var i = 0; i < styles.length; i++) {\n        // Test if the style is not read yet\n        if (styles[i].getAttribute('data-eqcss-read') === null) {\n\n          // Mark the style block as read\n          styles[i].setAttribute('data-eqcss-read', 'true');\n\n          EQCSS.process(styles[i].innerHTML);\n        }\n      }\n\n      // Retrieve all link tags\n      var link = document.getElementsByTagName('link');\n\n      for (i = 0; i < link.length; i++) {\n        // Test if the link is not read yet, and has rel=stylesheet\n        if (link[i].getAttribute('data-eqcss-read') === null && link[i].rel === 'stylesheet' && link[i].getAttribute(\"href\").endsWith(\"main.css\")) {\n          // retrieve the file content with AJAX and process it\n          if (link[i].href) {\n            (function() {\n              var xhr = new XMLHttpRequest;\n              xhr.open('GET', link[i].href, true);\n              xhr.send(null);\n              xhr.onreadystatechange = function() {\n                EQCSS.process(xhr.responseText);\n              }\n            })();\n          }\n          // Mark the link as read\n          link[i].setAttribute('data-eqcss-read', 'true');\n        }\n      }\n    }\n\n    /*\n     * EQCSS.parse()\n     * Called by load for each script / style / link resource.\n     * Generates data for each Element Query found\n     */\n    EQCSS.parse = function(code) {\n      var parsed_queries = new Array();\n\n      // Cleanup\n      code = code.replace(/\\s+/g, ' '); // reduce spaces and line breaks\n      code = code.replace(/\\/\\*[\\w\\W]*?\\*\\//g, ''); // remove comments\n      code = code.replace(/@element/g, '\\n@element'); // one element query per line\n      code = code.replace(/(@element.*?\\{([^}]*?\\{[^}]*?\\}[^}]*?)*\\}).*/g, '$1'); // Keep the queries only (discard regular css written around them)\n\n      // Parse\n\n      // For each query\n      code.replace(/(@element.*(?!@element))/g, function(string, query) {\n        // Create a data entry\n        var dataEntry = {};\n\n        // Extract the selector\n        query.replace(/(@element)\\s*(\".*?\"|'.*?'|.*?)\\s*(and\\s*\\(|{)/g, function(string, atrule, selector, extra) {\n          // Strip outer quotes if present\n          selector = selector.replace(/^\\s?['](.*)[']/, '$1');\n          selector = selector.replace(/^\\s?[\"](.*)[\"]/, '$1');\n\n          dataEntry.selector = selector;\n        })\n\n        // Extract the conditions (measure, value, unit)\n        dataEntry.conditions = [];\n        query.replace(/and ?\\( ?([^:]*) ?: ?([^)]*) ?\\)/g, function(string, measure, value) {\n          // Separate value and unit if it's possible\n          var unit = null;\n          unit = value.replace(/^(\\d*\\.?\\d+)(\\D+)$/, '$2');\n\n          if (unit === value) {\n            unit = null;\n          }\n          value = value.replace(/^(\\d*\\.?\\d+)\\D+$/, '$1');\n          dataEntry.conditions.push({measure: measure, value: value, unit: unit});\n        });\n\n        // Extract the styles\n        query.replace(/{(.*)}/g, function(string, style) {\n          dataEntry.style = style;\n        });\n\n        parsed_queries.push(dataEntry);\n      });\n\n      return parsed_queries;\n    }\n\n    /*\n     * EQCSS.register()\n     * Add a single object, or an array of objects to EQCSS.data\n     *\n     */\n     EQCSS.register = function(queries) {\n       if (Object.prototype.toString.call(queries) === '[object Object]') {\n         EQCSS.data.push(queries);\n         EQCSS.apply();\n       }\n\n       if (Object.prototype.toString.call(queries) === '[object Array]') {\n        for (var i=0; i<queries.length; i++) {\n          EQCSS.data.push(queries[i]);\n        }\n        EQCSS.apply();\n       }\n     }\n\n    /*\n     * EQCSS.process()\n     * Parse and Register queries with `EQCSS.data`\n     */\n\n     EQCSS.process = function(code) {\n       var queries = EQCSS.parse(code)\n       return EQCSS.register(queries)\n     }\n\n    /*\n     * EQCSS.apply()\n     * Called on load, on resize and manually on DOM update\n     * Enable the Element Queries in which the conditions are true\n     */\n    EQCSS.apply = function() {\n      var elements;                     // Elements targeted by each query\n      var element_guid;                 // GUID for current element\n      var css_block;                    // CSS block corresponding to each targeted element\n      var element_guid_parent;          // GUID for current element's parent\n      var element_guid_prev;            // GUID for current element's previous sibling element\n      var element_guid_next;            // GUID for current element's next sibling element\n      var css_code;                     // CSS code to write in each CSS block (one per targeted element)\n      var element_width, parent_width;  // Computed widths\n      var element_height, parent_height;// Computed heights\n      var element_line_height;          // Computed line-height\n      var test;                         // Query's condition test result\n      var computed_style;               // Each targeted element's computed style\n      var parent_computed_style;        // Each targeted element parent's computed style\n\n      // Loop on all element queries\n      for (var i = 0; i < EQCSS.data.length; i++) {\n        // Find all the elements targeted by the query\n        elements = document.querySelectorAll(EQCSS.data[i].selector);\n\n        // Loop on all the elements\n        for (var j = 0; j < elements.length; j++) {\n          // Create a guid for this element\n          // Pattern: 'EQCSS_{element-query-index}_{matched-element-index}'\n          element_guid = 'data-eqcss-' + i + '-' + j;\n\n          // Add this guid as an attribute to the element\n          elements[j].setAttribute(element_guid, '');\n\n          // Create a guid for the parent of this element\n          // Pattern: 'EQCSS_{element-query-index}_{matched-element-index}_parent'\n          element_guid_parent = 'data-eqcss-' + i + '-' + j + '-parent';\n\n          // Add this guid as an attribute to the element's parent (except if element is the root element)\n          if (elements[j] != document.documentElement) {\n            elements[j].parentNode.setAttribute(element_guid_parent, '');\n          }\n\n          // Get the CSS block associated to this element (or create one in the <HEAD> if it doesn't exist)\n          css_block = document.querySelector('#' + element_guid);\n\n          if (!css_block) {\n            css_block = document.createElement('style');\n            css_block.id = element_guid;\n            css_block.setAttribute('data-eqcss-read', 'true');\n            document.querySelector('head').appendChild(css_block);\n          }\n          css_block = document.querySelector('#' + element_guid);\n\n          // Reset the query test's result (first, we assume that the selector is matched)\n          test = true;\n\n          // Loop on the conditions\n          test_conditions: for (var k = 0; k < EQCSS.data[i].conditions.length; k++) {\n            // Reuse element and parent's computed style instead of computing it everywhere\n            computed_style = window.getComputedStyle(elements[j], null);\n\n            parent_computed_style = null;\n\n            if (elements[j] != document.documentElement) {\n              parent_computed_style = window.getComputedStyle(elements[j].parentNode, null);\n            }\n\n            // Do we have to reconvert the size in px at each call?\n            // This is true only for vw/vh/vmin/vmax\n            var recomputed = false;\n\n            // If the condition's unit is vw, convert current value in vw, in px\n            if (EQCSS.data[i].conditions[k].unit === 'vw') {\n              recomputed = true;\n\n              var value = parseInt(EQCSS.data[i].conditions[k].value);\n              EQCSS.data[i].conditions[k].recomputed_value = value * window.innerWidth / 100;\n            }\n\n            // If the condition's unit is vh, convert current value in vh, in px\n            else if (EQCSS.data[i].conditions[k].unit === 'vh') {\n              recomputed = true;\n\n              var value = parseInt(EQCSS.data[i].conditions[k].value);\n              EQCSS.data[i].conditions[k].recomputed_value = value * window.innerHeight / 100;\n            }\n\n            // If the condition's unit is vmin, convert current value in vmin, in px\n            else if (EQCSS.data[i].conditions[k].unit === 'vmin') {\n              recomputed = true;\n\n              var value = parseInt(EQCSS.data[i].conditions[k].value);\n              EQCSS.data[i].conditions[k].recomputed_value = value * Math.min(window.innerWidth, window.innerHeight) / 100;\n            }\n\n            // If the condition's unit is vmax, convert current value in vmax, in px\n            else if (EQCSS.data[i].conditions[k].unit === 'vmax') {\n              recomputed = true;\n\n              var value = parseInt(EQCSS.data[i].conditions[k].value);\n              EQCSS.data[i].conditions[k].recomputed_value = value * Math.max(window.innerWidth, window.innerHeight) / 100;\n            }\n\n            // If the condition's unit is set and is not px or %, convert it into pixels\n            else if (EQCSS.data[i].conditions[k].unit != null && EQCSS.data[i].conditions[k].unit != 'px' && EQCSS.data[i].conditions[k].unit != '%') {\n              // Create a hidden DIV, sibling of the current element (or its child, if the element is <html>)\n              // Set the given measure and unit to the DIV's width\n              // Measure the DIV's width in px\n              // Remove the DIV\n              var div = document.createElement('div');\n\n              div.style.visibility = 'hidden';\n              div.style.border = '1px solid red';\n              div.style.width = EQCSS.data[i].conditions[k].value + EQCSS.data[i].conditions[k].unit;\n\n              var position = elements[j];\n              if (elements[j] != document.documentElement) {\n                position = elements[j].parentNode;\n              }\n\n              position.appendChild(div);\n              EQCSS.data[i].conditions[k].value = parseInt(window.getComputedStyle(div, null).getPropertyValue('width'));\n              EQCSS.data[i].conditions[k].unit = 'px';\n              position.removeChild(div);\n            }\n\n            // Store the good value in final_value depending if the size is recomputed or not\n            var final_value = recomputed ? EQCSS.data[i].conditions[k].recomputed_value : parseInt(EQCSS.data[i].conditions[k].value);\n\n            // Check each condition for this query and this element\n            // If at least one condition is false, the element selector is not matched\n            switch (EQCSS.data[i].conditions[k].measure) {\n             case 'min-width':\n                // Min-width in px\n                if (recomputed === true || EQCSS.data[i].conditions[k].unit === 'px') {\n                  element_width = parseInt(computed_style.getPropertyValue('width'));\n                  if (!(element_width >= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n\n                // Min-width in %\n                if (EQCSS.data[i].conditions[k].unit === '%') {\n                  element_width = parseInt(computed_style.getPropertyValue('width'));\n                  parent_width = parseInt(parent_computed_style.getPropertyValue('width'));\n                  if (!(parent_width / element_width <= 100 / final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n\n              break;\n\n              case 'max-width':\n                // Max-width in px\n                if (recomputed === true || EQCSS.data[i].conditions[k].unit === 'px') {\n                  element_width = parseInt(computed_style.getPropertyValue('width'));\n                  if (!(element_width <= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n\n                // Max-width in %\n                if (EQCSS.data[i].conditions[k].unit === '%') {\n                  element_width = parseInt(computed_style.getPropertyValue('width'));\n                  parent_width = parseInt(parent_computed_style.getPropertyValue('width'));\n                  if (!(parent_width / element_width >= 100 / final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n              break;\n\n              case 'min-height':\n                // Min-height in px\n                if (recomputed === true || EQCSS.data[i].conditions[k].unit === 'px') {\n                  element_height = parseInt(computed_style.getPropertyValue('height'));\n                  if (!(element_height >= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n\n                // Min-height in %\n                if (EQCSS.data[i].conditions[k].unit === '%') {\n                  element_height = parseInt(computed_style.getPropertyValue('height'));\n                  parent_height = parseInt(parent_computed_style.getPropertyValue('height'));\n                  if (!(parent_height / element_height <= 100 / final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n              break;\n\n              case 'max-height':\n                // Max-height in px\n                if (recomputed === true || EQCSS.data[i].conditions[k].unit === 'px') {\n                  element_height = parseInt(computed_style.getPropertyValue('height'));\n                  if (!(element_height <= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n\n                // Max-height in %\n                if (EQCSS.data[i].conditions[k].unit === '%') {\n                  element_height = parseInt(computed_style.getPropertyValue('height'));\n                  parent_height = parseInt(parent_computed_style.getPropertyValue('height'));\n                  if (!(parent_height / element_height >= 100 / final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n              break;\n              \n              // Min-characters\n              case 'min-characters':\n                // form inputs\n                if (elements[j].value) {\n                  if (!(elements[j].value.length >= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n                // blocks\n                else {\n                  if (!(elements[j].textContent.length >= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n              break;\n\n              // Max-characters\n              case 'max-characters':\n                // form inputs\n                if (elements[j].value) {\n                  if (!(elements[j].value.length <= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n                // blocks\n                else {\n                  if (!(elements[j].textContent.length <= final_value)) {\n                    test = false;\n                    break test_conditions;\n                  }\n                }\n              break;\n\n              // Min-children\n              case 'min-children':\n                if (!(elements[j].children.length >= final_value)) {\n                  test = false;\n                  break test_conditions;\n                }\n              break;\n\n              // Max-children\n              case 'max-children':\n                if (!(elements[j].children.length <= final_value)) {\n                  test = false;\n                  break test_conditions;\n                }\n              break;\n\n              // Min-lines\n              case 'min-lines':\n                element_height =\n                  parseInt(computed_style.getPropertyValue('height'))\n                  - parseInt(computed_style.getPropertyValue('border-top-width'))\n                  - parseInt(computed_style.getPropertyValue('border-bottom-width'))\n                  - parseInt(computed_style.getPropertyValue('padding-top'))\n                  - parseInt(computed_style.getPropertyValue('padding-bottom'));\n\n                element_line_height = computed_style.getPropertyValue('line-height');\n                if (element_line_height === 'normal') {\n                  var element_font_size = parseInt(computed_style.getPropertyValue('font-size'));\n                  element_line_height = element_font_size * 1.125;\n                } else {\n                  element_line_height = parseInt(element_line_height);\n                }\n\n                if (!(element_height / element_line_height >= final_value)) {\n                  test = false;\n                  break test_conditions;\n                }\n              break;\n\n              // Max-lines\n              case 'max-lines':\n                element_height =\n                  parseInt(computed_style.getPropertyValue('height'))\n                  - parseInt(computed_style.getPropertyValue('border-top-width'))\n                  - parseInt(computed_style.getPropertyValue('border-bottom-width'))\n                  - parseInt(computed_style.getPropertyValue('padding-top'))\n                  - parseInt(computed_style.getPropertyValue('padding-bottom'));\n\n                element_line_height = computed_style.getPropertyValue('line-height');\n                if (element_line_height === 'normal') {\n                  var element_font_size = parseInt(computed_style.getPropertyValue('font-size'));\n                  element_line_height = element_font_size * 1.125;\n                } else {\n                  element_line_height = parseInt(element_line_height);\n                }\n\n                if (!(element_height / element_line_height + 1 <= final_value)) {\n                  test = false;\n                  break test_conditions;\n                }\n              break;\n            }\n          }\n\n          // Update CSS block:\n          // If all conditions are met: copy the CSS code from the query to the corresponding CSS block\n          if (test === true) {\n            // Get the CSS code to apply to the element\n            css_code = EQCSS.data[i].style;\n\n            // Replace eval('xyz') with the result of try{with(element){eval(xyz)}} in JS\n            css_code = css_code.replace(\n              /eval\\( *((\".*?\")|('.*?')) *\\)/g,\n              function(string, match) {\n                return EQCSS.tryWithEval(elements[j], match);\n              }\n            );\n\n            // Replace '$this' or 'eq_this' with '[element_guid]'\n            css_code = css_code.replace(/(\\$|eq_)this/gi, '[' + element_guid + ']');\n\n            // Replace '$parent' or 'eq_parent' with '[element_guid_parent]'\n            css_code = css_code.replace(/(\\$|eq_)parent/gi, '[' + element_guid_parent + ']');\n            \n            if(css_block.innerHTML != css_code){\n              css_block.innerHTML = css_code;\n            }\n          }\n\n          // If condition is not met: empty the CSS block\n          else if(css_block.innerHTML != '') {\n            css_block.innerHTML = '';\n          }\n        }\n      }\n    }\n\n    /*\n     * Eval('') and $it\n     * (…yes with() was necessary, and eval() too!)\n     */\n    EQCSS.tryWithEval = function(element, string) {\n      var $it = element;\n      var ret = '';\n      \n      try {\n        with ($it) { ret = eval(string.slice(1, -1)) }\n      }\n      catch(e) {\n        ret = '';\n      }\n      return ret;\n    }\n\n    /*\n     * EQCSS.reset\n     * Deletes parsed queries removes EQCSS-generated tags and attributes\n     * To reload EQCSS again after running EQCSS.reset() use EQCSS.load()\n     */\n    EQCSS.reset = function() {\n      // Reset EQCSS.data, removing previously parsed queries\n      EQCSS.data = [];\n      \n      // Remove EQCSS-generated style tags from head\n      var style_tag = document.querySelectorAll('head style[id^=\"data-eqcss-\"]');\n      for (var i = 0; i < style_tag.length; i++) {\n        style_tag[i].parentNode.removeChild(style_tag[i]);\n      }\n\n      // Remove EQCSS-generated attributes from all tags\n      var tag = document.querySelectorAll('*');\n\n      // For each tag in the document\n      for (var j = 0; j < tag.length; j++) {\n        // Loop through all attributes\n        for (var k = 0; k < tag[j].attributes.length; k++) {\n          // If an attribute begins with 'data-eqcss-'\n          if (tag[j].attributes[k].name.indexOf('data-eqcss-') === 0) {\n            // Remove the attribute from the tag\n            tag[j].removeAttribute(tag[j].attributes[k].name)\n          }\n        }\n      }\n    }\n\n    /*\n     * 'DOM Ready' cross-browser polyfill / Diego Perini / MIT license\n     * Forked from: https://github.com/dperini/ContentLoaded/blob/master/src/contentloaded.js\n     */\n    EQCSS.domReady = function(fn) {\n      var done = false;\n      var top = true;\n      var doc = window.document;\n      var root = doc.documentElement;\n      var modern = !~navigator.userAgent.indexOf('MSIE 8');\n      var add = modern ? 'addEventListener' : 'attachEvent';\n      var rem = modern ? 'removeEventListener' : 'detachEvent';\n      var pre = modern ? '' : 'on';\n      var init = function(e) {\n        if (e.type === 'readystatechange' && doc.readyState !== 'complete') return;\n        (e.type === 'load' ? window : doc)[rem](pre + e.type, init, false);\n        if (!done && (done = true)) fn.call(window, e.type || e);\n      },\n      poll = function() {\n        try {\n          root.doScroll('left');\n        }\n        catch(e) {\n          setTimeout(poll, 50);\n          return;\n        }\n        init('poll');\n      };\n\n      if (doc.readyState === 'complete') {\n        fn.call(window, 'lazy');\n        return;\n      }\n      \n      if (!modern && root.doScroll) {\n          try {\n            top = !window.frameElement;\n          }\n          catch(e) {}\n          if (top) poll();\n      }\n      doc[add](pre + 'DOMContentLoaded', init, false);\n      doc[add](pre + 'readystatechange', init, false);\n      window[add](pre + 'load', init, false);\n    }\n\n    /*\n     * EQCSS.throttle\n     * Ensures EQCSS.apply() is not called more than once every (EQCSS_timeout)ms\n     */\n    var EQCSS_throttle_available = true;\n    var EQCSS_throttle_queued = false;\n    var EQCSS_mouse_down = false;\n    var EQCSS_timeout = 200;\n\n    EQCSS.throttle = function() {\n     /* if (EQCSS_throttle_available) {*/\n        EQCSS.apply();\n        /*EQCSS_throttle_available = false;\n\n        setTimeout(function() {\n          EQCSS_throttle_available = true;\n          if (EQCSS_throttle_queued) {\n            EQCSS_throttle_queued = false;\n            EQCSS.apply();\n          }\n        }, EQCSS_timeout);\n      } else {\n        EQCSS_throttle_queued = true;\n      }*/\n    }\n\n    // Call load (and apply, indirectly) on page load\n    EQCSS.domReady(function() {\n      EQCSS.load();\n      EQCSS.throttle();\n    });\n\n    // On resize, click, call EQCSS.throttle.\n    window.addEventListener('resize', EQCSS.throttle);\n    window.addEventListener('click', EQCSS.throttle);\n\n    // Debug: here's a shortcut for console.log\n    function l(a) { console.log(a) }\n\n    return EQCSS;\n}));"
  },
  {
    "path": "public/Sortable-1.4.0/.editorconfig",
    "content": "# editorconfig.org\nroot = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": "public/Sortable-1.4.0/.gitignore",
    "content": "node_modules\nmock.png\n.*.sw*\n.build*\njquery.fn.*\n"
  },
  {
    "path": "public/Sortable-1.4.0/.jshintrc",
    "content": "{\n\t\"strict\": true,\n\t\"newcap\": false,\n\t\"node\": true,\n\t\"expr\": true,\n\t\"supernew\": true,\n\t\"laxbreak\": true,\n\t\"white\": true,\n\t\"globals\": {\n\t\t\"define\": true,\n\t\t\"test\": true,\n\t\t\"expect\": true,\n\t\t\"module\": true,\n\t\t\"asyncTest\": true,\n\t\t\"start\": true,\n\t\t\"ok\": true,\n\t\t\"equal\": true,\n\t\t\"notEqual\": true,\n\t\t\"deepEqual\": true,\n\t\t\"window\": true,\n\t\t\"document\": true,\n\t\t\"performance\": true\n\t}\n}\n"
  },
  {
    "path": "public/Sortable-1.4.0/CONTRIBUTING.md",
    "content": "# Contribution Guidelines\n\n### Issue\n\n 1. Try [dev](https://github.com/RubaXa/Sortable/tree/dev/)-branch, perhaps the problem has been solved;\n 2. [Use the search](https://github.com/RubaXa/Sortable/search?q=problem), maybe already have an answer;\n 3. If not found, create example on [jsbin.com (draft)](http://jsbin.com/zunibaxada/1/edit?html,js,output) and describe the problem.\n\n---\n\n### Pull Request\n\n 1. Before PR run `grunt`;\n 2. Only into [dev](https://github.com/RubaXa/Sortable/tree/dev/)-branch.\n\n### Setup\n\n Pieced together from [gruntjs](http://gruntjs.com/getting-started)\n\n 1. Fork repo on [github](https://github.com)\n 2. Clone locally\n 3. from local repro ```npm install```\n 4. Install grunt-cli globally ```sudo -H npm install -g grunt-cli```\n"
  },
  {
    "path": "public/Sortable-1.4.0/README.md",
    "content": "# Sortable\nSortable is a minimalist JavaScript library for reorderable drag-and-drop lists.\n\nDemo: http://rubaxa.github.io/Sortable/\n\n\n## Features\n\n * Supports touch devices and [modern](http://caniuse.com/#search=drag) browsers (including IE9)\n * Can drag from one list to another or within the same list\n * CSS animation when moving items\n * Supports drag handles *and selectable text* (better than voidberg's html5sortable)\n * Smart auto-scrolling\n * Built using native HTML5 drag and drop API\n * Supports [Meteor](meteor/README.md), [AngularJS](#ng), [React](#react) and [Polymer](#polymer)\n * Supports any CSS library, e.g. [Bootstrap](#bs)\n * Simple API\n * [CDN](#cdn)\n * No jQuery (but there is [support](#jq))\n\n\n<br/>\n\n\n### Articles\n * [Sortable v1.0 — New capabilities](https://github.com/RubaXa/Sortable/wiki/Sortable-v1.0-—-New-capabilities/) (December 22, 2014)\n * [Sorting with the help of HTML5 Drag'n'Drop API](https://github.com/RubaXa/Sortable/wiki/Sorting-with-the-help-of-HTML5-Drag'n'Drop-API/) (December 23, 2013)\n\n\n<br/>\n\n\n### Usage\n```html\n<ul id=\"items\">\n\t<li>item 1</li>\n\t<li>item 2</li>\n\t<li>item 3</li>\n</ul>\n```\n\n```js\nvar el = document.getElementById('items');\nvar sortable = Sortable.create(el);\n```\n\nYou can use any element for the list and its elements, not just `ul`/`li`. Here is an [example with `div`s](http://jsbin.com/luxero/2/edit?html,js,output).\n\n\n---\n\n\n### Options\n```js\nvar sortable = new Sortable(el, {\n\tgroup: \"name\",  // or { name: \"...\", pull: [true, false, clone], put: [true, false, array] }\n\tsort: true,  // sorting inside list\n\tdelay: 0, // time in milliseconds to define when the sorting should start\n\tdisabled: false, // Disables the sortable if set to true.\n\tstore: null,  // @see Store\n\tanimation: 150,  // ms, animation speed moving items when sorting, `0` — without animation\n\thandle: \".my-handle\",  // Drag handle selector within list items\n\tfilter: \".ignore-elements\",  // Selectors that do not lead to dragging (String or Function)\n\tdraggable: \".item\",  // Specifies which items inside the element should be sortable\n\tghostClass: \"sortable-ghost\",  // Class name for the drop placeholder\n\tchosenClass: \"sortable-chosen\",  // Class name for the chosen item\n\tdataIdAttr: 'data-id',\n\t\n\tforceFallback: false,  // ignore the HTML5 DnD behaviour and force the fallback to kick in\n\tfallbackClass: \"sortable-fallback\"  // Class name for the cloned DOM Element when using forceFallback\n\tfallbackOnBody: false  // Appends the cloned DOM Element into the Document's Body\n\t\n\tscroll: true, // or HTMLElement\n\tscrollSensitivity: 30, // px, how near the mouse must be to an edge to start scrolling.\n\tscrollSpeed: 10, // px\n\t\n\tsetData: function (dataTransfer, dragEl) {\n\t\tdataTransfer.setData('Text', dragEl.textContent);\n\t},\n\n\t// dragging started\n\tonStart: function (/**Event*/evt) {\n\t\tevt.oldIndex;  // element index within parent\n\t},\n\t\n\t// dragging ended\n\tonEnd: function (/**Event*/evt) {\n\t\tevt.oldIndex;  // element's old index within parent\n\t\tevt.newIndex;  // element's new index within parent\n\t},\n\n\t// Element is dropped into the list from another list\n\tonAdd: function (/**Event*/evt) {\n\t\tvar itemEl = evt.item;  // dragged HTMLElement\n\t\tevt.from;  // previous list\n\t\t// + indexes from onEnd\n\t},\n\n\t// Changed sorting within list\n\tonUpdate: function (/**Event*/evt) {\n\t\tvar itemEl = evt.item;  // dragged HTMLElement\n\t\t// + indexes from onEnd\n\t},\n\n\t// Called by any change to the list (add / update / remove)\n\tonSort: function (/**Event*/evt) {\n\t\t// same properties as onUpdate\n\t},\n\n\t// Element is removed from the list into another list\n\tonRemove: function (/**Event*/evt) {\n\t\t// same properties as onUpdate\n\t},\n\n\t// Attempt to drag a filtered element\n\tonFilter: function (/**Event*/evt) {\n\t\tvar itemEl = evt.item;  // HTMLElement receiving the `mousedown|tapstart` event.\n\t},\n\t\n\t// Event when you move an item in the list or between lists\n\tonMove: function (/**Event*/evt) {\n\t\t// Example: http://jsbin.com/tuyafe/1/edit?js,output\n\t\tevt.dragged; // dragged HTMLElement\n\t\tevt.draggedRect; // TextRectangle {left, top, right и bottom}\n\t\tevt.related; // HTMLElement on which have guided\n\t\tevt.relatedRect; // TextRectangle\n\t\t// return false; — for cancel\n\t}\n});\n```\n\n\n---\n\n\n#### `group` option\nTo drag elements from one list into another, both lists must have the same `group` value.\nYou can also define whether lists can give away, give and keep a copy (`clone`), and receive elements.\n\n * name: `String` — group name\n * pull: `true|false|'clone'` — ability to move from the list. `clone` — copy the item, rather than move.\n * put: `true|false|[\"foo\", \"bar\"]` — whether elements can be added from other lists, or an array of group names from which elements can be taken. Demo: http://jsbin.com/naduvo/2/edit?html,js,output\n\n\n---\n\n\n#### `sort` option\nSorting inside list.\n\nDemo: http://jsbin.com/xizeh/2/edit?html,js,output\n\n\n---\n\n\n#### `delay` option\nTime in milliseconds to define when the sorting should start.\n\nDemo: http://jsbin.com/xizeh/4/edit?html,js,output\n\n\n---\n\n\n#### `disabled` options\nDisables the sortable if set to `true`.\n\nDemo: http://jsbin.com/xiloqu/1/edit?html,js,output\n\n```js\nvar sortable = Sortable.create(list);\n\ndocument.getElementById(\"switcher\").onclick = function () {\n\tvar state = sortable.option(\"disabled\"); // get\n\n\tsortable.option(\"disabled\", !state); // set\n};\n```\n\n\n---\n\n\n#### `handle` option\nTo make list items draggable, Sortable disables text selection by the user.\nThat's not always desirable. To allow text selection, define a drag handler,\nwhich is an area of every list element that allows it to be dragged around.\n\nDemo: http://jsbin.com/newize/1/edit?html,js,output\n\n```js\nSortable.create(el, {\n\thandle: \".my-handle\"\n});\n```\n\n```html\n<ul>\n\t<li><span class=\"my-handle\">::</span> list item text one\n\t<li><span class=\"my-handle\">::</span> list item text two\n</ul>\n```\n\n```css\n.my-handle {\n\tcursor: move;\n\tcursor: -webkit-grabbing;\n}\n```\n\n\n---\n\n\n#### `filter` option\n\n\n```js\nSortable.create(list, {\n\tfilter: \".js-remove, .js-edit\",\n\tonFilter: function (evt) {\n\t\tvar item = evt.item,\n\t\t\tctrl = evt.target;\n\n\t\tif (Sortable.utils.is(ctrl, \".js-remove\")) {  // Click on remove button\n\t\t\titem.parentNode.removeChild(item); // remove sortable item\n\t\t}\n\t\telse if (Sortable.utils.is(ctrl, \".js-edit\")) {  // Click on edit link\n\t\t\t// ...\n\t\t}\n\t}\n})\n```\n\n\n---\n\n\n#### `ghostClass` option\nClass name for the drop placeholder (default `sortable-ghost`).\n\nDemo: http://jsbin.com/hunifu/1/edit?css,js,output\n\n```css\n.ghost {\n  opacity: 0.4;\n}\n```\n\n```js\nSortable.create(list, {\n  ghostClass: \"ghost\"\n});\n```\n\n\n---\n\n\n#### `chosenClass` option\nClass name for the chosen item  (default `sortable-chosen`).\n\nDemo: http://jsbin.com/hunifu/edit?html,css,js,output\n\n```css\n.chosen {\n  color: #fff;\n  background-color: #c00;\n}\n```\n\n```js\nSortable.create(list, {\n  delay: 500,\n  chosenClass: \"chosen\"\n});\n```\n\n\n---\n\n\n#### `forceFallback` option\nIf set to `true`, the Fallback for non HTML5 Browser will be used, even if we are using an HTML5 Browser.\nThis gives us the possiblity to test the behaviour for older Browsers even in newer Browser, or make the Drag 'n Drop feel more consistent between Desktop , Mobile and old Browsers.\n\nOn top of that, the Fallback always generates a copy of that DOM Element and appends the class `fallbackClass` definied in the options. This behaviour controls the look of this 'dragged' Element.\n\nDemo: http://jsbin.com/pucurizace/edit?html,css,js,output\n\n\n---\n\n\n#### `scroll` option\nIf set to `true`, the page (or sortable-area) scrolls when coming to an edge.\n\nDemo:\n - `window`: http://jsbin.com/boqugumiqi/1/edit?html,js,output \n - `overflow: hidden`: http://jsbin.com/kohamakiwi/1/edit?html,js,output\n\n\n---\n\n\n#### `scrollSensitivity` option\nDefines how near the mouse must be to an edge to start scrolling.\n\n\n---\n\n\n#### `scrollSpeed` option\nThe speed at which the window should scroll once the mouse pointer gets within the `scrollSensitivity` distance.\n\n\n---\n\n\n<a name=\"ng\"></a>\n### Support AngularJS\nInclude [ng-sortable.js](ng-sortable.js)\n\nDemo: http://jsbin.com/naduvo/1/edit?html,js,output\n\n```html\n<div ng-app=\"myApp\" ng-controller=\"demo\">\n\t<ul ng-sortable>\n\t\t<li ng-repeat=\"item in items\">{{item}}</li>\n\t</ul>\n\n\t<ul ng-sortable=\"{ group: 'foobar' }\">\n\t\t<li ng-repeat=\"item in foo\">{{item}}</li>\n\t</ul>\n\n\t<ul ng-sortable=\"barConfig\">\n\t\t<li ng-repeat=\"item in bar\">{{item}}</li>\n\t</ul>\n</div>\n```\n\n\n```js\nangular.module('myApp', ['ng-sortable'])\n\t.controller('demo', ['$scope', function ($scope) {\n\t\t$scope.items = ['item 1', 'item 2'];\n\t\t$scope.foo = ['foo 1', '..'];\n\t\t$scope.bar = ['bar 1', '..'];\n\t\t$scope.barConfig = {\n\t\t\tgroup: 'foobar',\n\t\t\tanimation: 150,\n\t\t\tonSort: function (/** ngSortEvent */evt){\n\t\t\t\t// @see https://github.com/RubaXa/Sortable/blob/master/ng-sortable.js#L18-L24\n\t\t\t}\n\t\t};\n\t}]);\n```\n\n\n---\n\n\n<a name=\"react\"></a>\n### Support React\nInclude [react-sortable-mixin.js](react-sortable-mixin.js).\nSee [more options](react-sortable-mixin.js#L26).\n\n\n```jsx\nvar SortableList = React.createClass({\n\tmixins: [SortableMixin],\n\n\tgetInitialState: function() {\n\t\treturn {\n\t\t\titems: ['Mixin', 'Sortable']\n\t\t};\n\t},\n\n\thandleSort: function (/** Event */evt) { /*..*/ },\n\n\trender: function() {\n\t\treturn <ul>{\n\t\t\tthis.state.items.map(function (text) {\n\t\t\t\treturn <li>{text}</li>\n\t\t\t})\n\t\t}</ul>\n\t}\n});\n\nReact.render(<SortableList />, document.body);\n\n\n//\n// Groups\n//\nvar AllUsers = React.createClass({\n\tmixins: [SortableMixin],\n\n\tsortableOptions: {\n\t\tref: \"user\",\n\t\tgroup: \"shared\",\n\t\tmodel: \"users\"\n\t},\n\n\tgetInitialState: function() {\n\t\treturn { users: ['Abbi', 'Adela', 'Bud', 'Cate', 'Davis', 'Eric']; };\n\t},\n\n\trender: function() {\n\t\treturn (\n\t\t\t<h1>Users</h1>\n\t\t\t<ul ref=\"user\">{\n\t\t\t\tthis.state.users.map(function (text) {\n\t\t\t\t\treturn <li>{text}</li>\n\t\t\t\t})\n\t\t\t}</ul>\n\t\t);\n\t}\n});\n\nvar ApprovedUsers = React.createClass({\n\tmixins: [SortableMixin],\n\tsortableOptions: { group: \"shared\" },\n\n\tgetInitialState: function() {\n\t\treturn { items: ['Hal', 'Judy']; };\n\t},\n\n\trender: function() {\n\t\treturn <ul>{\n\t\t\tthis.state.items.map(function (text) {\n\t\t\t\treturn <li>{text}</li>\n\t\t\t})\n\t\t}</ul>\n\t}\n});\n\nReact.render(<div>\n\t<AllUsers/>\n\t<hr/>\n\t<ApprovedUsers/>\n</div>, document.body);\n```\n\n\n---\n\n\n<a name=\"ko\"></a>\n### Support KnockoutJS\nInclude [knockout-sortable.js](knockout-sortable.js)\n\n```html\n<div data-bind=\"sortable: {foreach: yourObservableArray, options: {/* sortable options here */}}\">\n\t<!-- optional item template here -->\n</div>\n\n<div data-bind=\"draggable: {foreach: yourObservableArray, options: {/* sortable options here */}}\">\n\t<!-- optional item template here -->\n</div>\n```\n\nUsing this bindingHandler sorts the observableArray when the user sorts the HTMLElements.\n\nThe sortable/draggable bindingHandlers supports the same syntax as Knockouts built in [template](http://knockoutjs.com/documentation/template-binding.html) binding except for the `data` option, meaning that you could supply the name of a template or specify a separate templateEngine. The difference between the sortable and draggable handlers is that the draggable has the sortable `group` option set to `{pull:'clone',put: false}` and the `sort` option set to false by default (overridable).\n\nOther attributes are:\n*\toptions: an object that contains settings for the underlaying sortable, ie `group`,`handle`, events etc.\n*\tcollection: if your `foreach` array is a computed then you would supply the underlaying observableArray that you would like to sort here.\n\n\n---\n\n<a name=\"polymer\"></a>\n### Support Polymer\n```html\n\n<link rel=\"import\" href=\"bower_components/Sortable/Sortable-js.html\">\n\n<sortable-js handle=\".handle\">\n  <template is=\"dom-repeat\" items={{names}}>\n    <div>{{item}}</div>\n  </template>\n<sortable-js>\n```\n\n### Method\n\n\n##### option(name:`String`[, value:`*`]):`*`\nGet or set the option.\n\n\n\n##### closest(el:`String`[, selector:`HTMLElement`]):`HTMLElement|null`\nFor each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.\n\n\n##### toArray():`String[]`\nSerializes the sortable's item `data-id`'s (`dataIdAttr` option) into an array of string.\n\n\n##### sort(order:`String[]`)\nSorts the elements according to the array.\n\n```js\nvar order = sortable.toArray();\nsortable.sort(order.reverse()); // apply\n```\n\n\n##### save()\nSave the current sorting (see [store](#store))\n\n\n##### destroy()\nRemoves the sortable functionality completely.\n\n\n---\n\n\n<a name=\"store\"></a>\n### Store\nSaving and restoring of the sort.\n\n```html\n<ul>\n\t<li data-id=\"1\">order</li>\n\t<li data-id=\"2\">save</li>\n\t<li data-id=\"3\">restore</li>\n</ul>\n```\n\n```js\nSortable.create(el, {\n\tgroup: \"localStorage-example\",\n\tstore: {\n\t\t/**\n\t\t * Get the order of elements. Called once during initialization.\n\t\t * @param   {Sortable}  sortable\n\t\t * @returns {Array}\n\t\t */\n\t\tget: function (sortable) {\n\t\t\tvar order = localStorage.getItem(sortable.options.group);\n\t\t\treturn order ? order.split('|') : [];\n\t\t},\n\n\t\t/**\n\t\t * Save the order of elements. Called onEnd (when the item is dropped).\n\t\t * @param {Sortable}  sortable\n\t\t */\n\t\tset: function (sortable) {\n\t\t\tvar order = sortable.toArray();\n\t\t\tlocalStorage.setItem(sortable.options.group, order.join('|'));\n\t\t}\n\t}\n})\n```\n\n\n---\n\n\n<a name=\"bs\"></a>\n### Bootstrap\nDemo: http://jsbin.com/luxero/2/edit?html,js,output\n\n```html\n<!-- Latest compiled and minified CSS -->\n<link rel=\"stylesheet\" href=\"https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css\"/>\n\n\n<!-- Latest Sortable -->\n<script src=\"http://rubaxa.github.io/Sortable/Sortable.js\"></script>\n\n\n<!-- Simple List -->\n<ul id=\"simpleList\" class=\"list-group\">\n\t<li class=\"list-group-item\">This is <a href=\"http://rubaxa.github.io/Sortable/\">Sortable</a></li>\n\t<li class=\"list-group-item\">It works with Bootstrap...</li>\n\t<li class=\"list-group-item\">...out of the box.</li>\n\t<li class=\"list-group-item\">It has support for touch devices.</li>\n\t<li class=\"list-group-item\">Just drag some elements around.</li>\n</ul>\n\n<script>\n    // Simple list\n    Sortable.create(simpleList, { /* options */ });\n</script>\n```\n\n\n---\n\n\n### Static methods & properties\n\n\n\n##### Sortable.create(el:`HTMLElement`[, options:`Object`]):`Sortable`\nCreate new instance.\n\n\n---\n\n\n##### Sortable.active:`Sortable`\nLink to the active instance.\n\n\n---\n\n\n##### Sortable.utils\n* on(el`:HTMLElement`, event`:String`, fn`:Function`) — attach an event handler function\n* off(el`:HTMLElement`, event`:String`, fn`:Function`) — remove an event handler\n* css(el`:HTMLElement`)`:Object` — get the values of all the CSS properties\n* css(el`:HTMLElement`, prop`:String`)`:Mixed` — get the value of style properties\n* css(el`:HTMLElement`, prop`:String`, value`:String`) — set one CSS properties\n* css(el`:HTMLElement`, props`:Object`) — set more CSS properties\n* find(ctx`:HTMLElement`, tagName`:String`[, iterator`:Function`])`:Array` — get elements by tag name\n* bind(ctx`:Mixed`, fn`:Function`)`:Function` — Takes a function and returns a new one that will always have a particular context\n* is(el`:HTMLElement`, selector`:String`)`:Boolean` — check the current matched set of elements against a selector\n* closest(el`:HTMLElement`, selector`:String`[, ctx`:HTMLElement`])`:HTMLElement|Null` — for each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree\n* toggleClass(el`:HTMLElement`, name`:String`, state`:Boolean`) — add or remove one classes from each element\n\n\n---\n\n\n<a name=\"cdn\"></a>\n### CDN\n\n```html\n<!-- CDNJS :: Sortable (https://cdnjs.com/) -->\n<script src=\"//cdnjs.cloudflare.com/ajax/libs/Sortable/1.3.0-rc1/Sortable.min.js\"></script>\n\n\n<!-- jsDelivr :: Sortable (http://www.jsdelivr.com/) -->\n<script src=\"//cdn.jsdelivr.net/sortable/1.3.0-rc1/Sortable.min.js\"></script>\n\n\n<!-- jsDelivr :: Sortable :: Latest (http://www.jsdelivr.com/) -->\n<script src=\"//cdn.jsdelivr.net/sortable/latest/Sortable.min.js\"></script>\n```\n\n\n---\n\n\n<a name=\"jq\"></a>\n### jQuery compatibility\nTo assemble plugin for jQuery, perform the following steps:\n\n```bash\n  cd Sortable\n  npm install\n  grunt jquery\n```\n\nNow you can use `jquery.fn.sortable.js`:<br/>\n(or `jquery.fn.sortable.min.js` if you run `grunt jquery:min`)\n\n```js\n  $(\"#list\").sortable({ /* options */ }); // init\n  \n  $(\"#list\").sortable(\"widget\"); // get Sortable instance\n  \n  $(\"#list\").sortable(\"destroy\"); // destroy Sortable instance\n  \n  $(\"#list\").sortable(\"{method-name}\"); // call an instance method\n  \n  $(\"#list\").sortable(\"{method-name}\", \"foo\", \"bar\"); // call an instance method with parameters\n```\n\nAnd `grunt jquery:mySortableFunc` → `jquery.fn.mySortableFunc.js`\n\n---\n\n\n### Contributing (Issue/PR)\n\nPlease, [read this](CONTRIBUTING.md). \n\n\n---\n\n\n## MIT LICENSE\nCopyright 2013-2015 Lebedev Konstantin <ibnRubaXa@gmail.com>\nhttp://rubaxa.github.io/Sortable/\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n"
  },
  {
    "path": "public/Sortable-1.4.0/Sortable.js",
    "content": "/**!\n * Sortable\n * @author\tRubaXa   <trash@rubaxa.org>\n * @license MIT\n */\n\n\n(function (factory) {\n\t\"use strict\";\n\n\tif (typeof define === \"function\" && define.amd) {\n\t\tdefine(factory);\n\t}\n\telse if (typeof module != \"undefined\" && typeof module.exports != \"undefined\") {\n\t\tmodule.exports = factory();\n\t}\n\telse if (typeof Package !== \"undefined\") {\n\t\tSortable = factory();  // export for Meteor.js\n\t}\n\telse {\n\t\t/* jshint sub:true */\n\t\twindow[\"Sortable\"] = factory();\n\t}\n})(function () {\n\t\"use strict\";\n\n\tvar dragEl,\n\t\tparentEl,\n\t\tghostEl,\n\t\tcloneEl,\n\t\trootEl,\n\t\tnextEl,\n\n\t\tscrollEl,\n\t\tscrollParentEl,\n\n\t\tlastEl,\n\t\tlastCSS,\n\t\tlastParentCSS,\n\n\t\toldIndex,\n\t\tnewIndex,\n\n\t\tactiveGroup,\n\t\tautoScroll = {},\n\n\t\ttapEvt,\n\t\ttouchEvt,\n\n\t\tmoved,\n\n\t\t/** @const */\n\t\tRSPACE = /\\s+/g,\n\n\t\texpando = 'Sortable' + (new Date).getTime(),\n\n\t\twin = window,\n\t\tdocument = win.document,\n\t\tparseInt = win.parseInt,\n\n\t\tsupportDraggable = !!('draggable' in document.createElement('div')),\n\t\tsupportCssPointerEvents = (function (el) {\n\t\t\tel = document.createElement('x');\n\t\t\tel.style.cssText = 'pointer-events:auto';\n\t\t\treturn el.style.pointerEvents === 'auto';\n\t\t})(),\n\n\t\t_silent = false,\n\n\t\tabs = Math.abs,\n\t\tslice = [].slice,\n\n\t\ttouchDragOverListeners = [],\n\n\t\t_autoScroll = _throttle(function (/**Event*/evt, /**Object*/options, /**HTMLElement*/rootEl) {\n\t\t\t// Bug: https://bugzilla.mozilla.org/show_bug.cgi?id=505521\n\t\t\tif (rootEl && options.scroll) {\n\t\t\t\tvar el,\n\t\t\t\t\trect,\n\t\t\t\t\tsens = options.scrollSensitivity,\n\t\t\t\t\tspeed = options.scrollSpeed,\n\n\t\t\t\t\tx = evt.clientX,\n\t\t\t\t\ty = evt.clientY,\n\n\t\t\t\t\twinWidth = window.innerWidth,\n\t\t\t\t\twinHeight = window.innerHeight,\n\n\t\t\t\t\tvx,\n\t\t\t\t\tvy\n\t\t\t\t;\n\n\t\t\t\t// Delect scrollEl\n\t\t\t\tif (scrollParentEl !== rootEl) {\n\t\t\t\t\tscrollEl = options.scroll;\n\t\t\t\t\tscrollParentEl = rootEl;\n\n\t\t\t\t\tif (scrollEl === true) {\n\t\t\t\t\t\tscrollEl = rootEl;\n\n\t\t\t\t\t\tdo {\n\t\t\t\t\t\t\tif ((scrollEl.offsetWidth < scrollEl.scrollWidth) ||\n\t\t\t\t\t\t\t\t(scrollEl.offsetHeight < scrollEl.scrollHeight)\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t/* jshint boss:true */\n\t\t\t\t\t\t} while (scrollEl = scrollEl.parentNode);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (scrollEl) {\n\t\t\t\t\tel = scrollEl;\n\t\t\t\t\trect = scrollEl.getBoundingClientRect();\n\t\t\t\t\tvx = (abs(rect.right - x) <= sens) - (abs(rect.left - x) <= sens);\n\t\t\t\t\tvy = (abs(rect.bottom - y) <= sens) - (abs(rect.top - y) <= sens);\n\t\t\t\t}\n\n\n\t\t\t\tif (!(vx || vy)) {\n\t\t\t\t\tvx = (winWidth - x <= sens) - (x <= sens);\n\t\t\t\t\tvy = (winHeight - y <= sens) - (y <= sens);\n\n\t\t\t\t\t/* jshint expr:true */\n\t\t\t\t\t(vx || vy) && (el = win);\n\t\t\t\t}\n\n\n\t\t\t\tif (autoScroll.vx !== vx || autoScroll.vy !== vy || autoScroll.el !== el) {\n\t\t\t\t\tautoScroll.el = el;\n\t\t\t\t\tautoScroll.vx = vx;\n\t\t\t\t\tautoScroll.vy = vy;\n\n\t\t\t\t\tclearInterval(autoScroll.pid);\n\n\t\t\t\t\tif (el) {\n\t\t\t\t\t\tautoScroll.pid = setInterval(function () {\n\t\t\t\t\t\t\tif (el === win) {\n\t\t\t\t\t\t\t\twin.scrollTo(win.pageXOffset + vx * speed, win.pageYOffset + vy * speed);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tvy && (el.scrollTop += vy * speed);\n\t\t\t\t\t\t\t\tvx && (el.scrollLeft += vx * speed);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}, 24);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}, 30),\n\n\t\t_prepareGroup = function (options) {\n\t\t\tvar group = options.group;\n\n\t\t\tif (!group || typeof group != 'object') {\n\t\t\t\tgroup = options.group = {name: group};\n\t\t\t}\n\n\t\t\t['pull', 'put'].forEach(function (key) {\n\t\t\t\tif (!(key in group)) {\n\t\t\t\t\tgroup[key] = true;\n\t\t\t\t}\n\t\t\t});\n\n\t\t\toptions.groups = ' ' + group.name + (group.put.join ? ' ' + group.put.join(' ') : '') + ' ';\n\t\t}\n\t;\n\n\n\n\t/**\n\t * @class  Sortable\n\t * @param  {HTMLElement}  el\n\t * @param  {Object}       [options]\n\t */\n\tfunction Sortable(el, options) {\n\t\tif (!(el && el.nodeType && el.nodeType === 1)) {\n\t\t\tthrow 'Sortable: `el` must be HTMLElement, and not ' + {}.toString.call(el);\n\t\t}\n\n\t\tthis.el = el; // root element\n\t\tthis.options = options = _extend({}, options);\n\n\n\t\t// Export instance\n\t\tel[expando] = this;\n\n\n\t\t// Default options\n\t\tvar defaults = {\n\t\t\tgroup: Math.random(),\n\t\t\tsort: true,\n\t\t\tdisabled: false,\n\t\t\tstore: null,\n\t\t\thandle: null,\n\t\t\tscroll: true,\n\t\t\tscrollSensitivity: 30,\n\t\t\tscrollSpeed: 10,\n\t\t\tdraggable: /[uo]l/i.test(el.nodeName) ? 'li' : '>*',\n\t\t\tghostClass: 'sortable-ghost',\n\t\t\tchosenClass: 'sortable-chosen',\n\t\t\tignore: 'a, img',\n\t\t\tfilter: null,\n\t\t\tanimation: 0,\n\t\t\tsetData: function (dataTransfer, dragEl) {\n\t\t\t\tdataTransfer.setData('Text', dragEl.textContent);\n\t\t\t},\n\t\t\tdropBubble: false,\n\t\t\tdragoverBubble: false,\n\t\t\tdataIdAttr: 'data-id',\n\t\t\tdelay: 0,\n\t\t\tforceFallback: false,\n\t\t\tfallbackClass: 'sortable-fallback',\n\t\t\tfallbackOnBody: false\n\t\t};\n\n\n\t\t// Set default options\n\t\tfor (var name in defaults) {\n\t\t\t!(name in options) && (options[name] = defaults[name]);\n\t\t}\n\n\t\t_prepareGroup(options);\n\n\t\t// Bind all private methods\n\t\tfor (var fn in this) {\n\t\t\tif (fn.charAt(0) === '_') {\n\t\t\t\tthis[fn] = this[fn].bind(this);\n\t\t\t}\n\t\t}\n\n\t\t// Setup drag mode\n\t\tthis.nativeDraggable = options.forceFallback ? false : supportDraggable;\n\n\t\t// Bind events\n\t\t_on(el, 'mousedown', this._onTapStart);\n\t\t_on(el, 'touchstart', this._onTapStart);\n\n\t\tif (this.nativeDraggable) {\n\t\t\t_on(el, 'dragover', this);\n\t\t\t_on(el, 'dragenter', this);\n\t\t}\n\n\t\ttouchDragOverListeners.push(this._onDragOver);\n\n\t\t// Restore sorting\n\t\toptions.store && this.sort(options.store.get(this));\n\t}\n\n\n\tSortable.prototype = /** @lends Sortable.prototype */ {\n\t\tconstructor: Sortable,\n\n\t\t_onTapStart: function (/** Event|TouchEvent */evt) {\n\t\t\tvar _this = this,\n\t\t\t\tel = this.el,\n\t\t\t\toptions = this.options,\n\t\t\t\ttype = evt.type,\n\t\t\t\ttouch = evt.touches && evt.touches[0],\n\t\t\t\ttarget = (touch || evt).target,\n\t\t\t\toriginalTarget = target,\n\t\t\t\tfilter = options.filter;\n\n\n\t\t\tif (type === 'mousedown' && evt.button !== 0 || options.disabled) {\n\t\t\t\treturn; // only left button or enabled\n\t\t\t}\n\n\t\t\ttarget = _closest(target, options.draggable, el);\n\n\t\t\tif (!target) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// get the index of the dragged element within its parent\n\t\t\toldIndex = _index(target);\n\n\t\t\t// Check filter\n\t\t\tif (typeof filter === 'function') {\n\t\t\t\tif (filter.call(this, evt, target, this)) {\n\t\t\t\t\t_dispatchEvent(_this, originalTarget, 'filter', target, el, oldIndex);\n\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\treturn; // cancel dnd\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (filter) {\n\t\t\t\tfilter = filter.split(',').some(function (criteria) {\n\t\t\t\t\tcriteria = _closest(originalTarget, criteria.trim(), el);\n\n\t\t\t\t\tif (criteria) {\n\t\t\t\t\t\t_dispatchEvent(_this, criteria, 'filter', target, el, oldIndex);\n\t\t\t\t\t\treturn true;\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tif (filter) {\n\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\treturn; // cancel dnd\n\t\t\t\t}\n\t\t\t}\n\n\n\t\t\tif (options.handle && !_closest(originalTarget, options.handle, el)) {\n\t\t\t\treturn;\n\t\t\t}\n\n\n\t\t\t// Prepare `dragstart`\n\t\t\tthis._prepareDragStart(evt, touch, target);\n\t\t},\n\n\t\t_prepareDragStart: function (/** Event */evt, /** Touch */touch, /** HTMLElement */target) {\n\t\t\tvar _this = this,\n\t\t\t\tel = _this.el,\n\t\t\t\toptions = _this.options,\n\t\t\t\townerDocument = el.ownerDocument,\n\t\t\t\tdragStartFn;\n\n\t\t\tif (target && !dragEl && (target.parentNode === el)) {\n\t\t\t\ttapEvt = evt;\n\n\t\t\t\trootEl = el;\n\t\t\t\tdragEl = target;\n\t\t\t\tparentEl = dragEl.parentNode;\n\t\t\t\tnextEl = dragEl.nextSibling;\n\t\t\t\tactiveGroup = options.group;\n\n\t\t\t\tdragStartFn = function () {\n\t\t\t\t\t// Delayed drag has been triggered\n\t\t\t\t\t// we can re-enable the events: touchmove/mousemove\n\t\t\t\t\t_this._disableDelayedDrag();\n\n\t\t\t\t\t// Make the element draggable\n\t\t\t\t\tdragEl.draggable = true;\n\n\t\t\t\t\t// Chosen item\n\t\t\t\t\t_toggleClass(dragEl, _this.options.chosenClass, true);\n\n\t\t\t\t\t// Bind the events: dragstart/dragend\n\t\t\t\t\t_this._triggerDragStart(touch);\n\t\t\t\t};\n\n\t\t\t\t// Disable \"draggable\"\n\t\t\t\toptions.ignore.split(',').forEach(function (criteria) {\n\t\t\t\t\t_find(dragEl, criteria.trim(), _disableDraggable);\n\t\t\t\t});\n\n\t\t\t\t_on(ownerDocument, 'mouseup', _this._onDrop);\n\t\t\t\t_on(ownerDocument, 'touchend', _this._onDrop);\n\t\t\t\t_on(ownerDocument, 'touchcancel', _this._onDrop);\n\n\t\t\t\tif (options.delay) {\n\t\t\t\t\t// If the user moves the pointer or let go the click or touch\n\t\t\t\t\t// before the delay has been reached:\n\t\t\t\t\t// disable the delayed drag\n\t\t\t\t\t_on(ownerDocument, 'mouseup', _this._disableDelayedDrag);\n\t\t\t\t\t_on(ownerDocument, 'touchend', _this._disableDelayedDrag);\n\t\t\t\t\t_on(ownerDocument, 'touchcancel', _this._disableDelayedDrag);\n\t\t\t\t\t_on(ownerDocument, 'mousemove', _this._disableDelayedDrag);\n\t\t\t\t\t_on(ownerDocument, 'touchmove', _this._disableDelayedDrag);\n\n\t\t\t\t\t_this._dragStartTimer = setTimeout(dragStartFn, options.delay);\n\t\t\t\t} else {\n\t\t\t\t\tdragStartFn();\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t_disableDelayedDrag: function () {\n\t\t\tvar ownerDocument = this.el.ownerDocument;\n\n\t\t\tclearTimeout(this._dragStartTimer);\n\t\t\t_off(ownerDocument, 'mouseup', this._disableDelayedDrag);\n\t\t\t_off(ownerDocument, 'touchend', this._disableDelayedDrag);\n\t\t\t_off(ownerDocument, 'touchcancel', this._disableDelayedDrag);\n\t\t\t_off(ownerDocument, 'mousemove', this._disableDelayedDrag);\n\t\t\t_off(ownerDocument, 'touchmove', this._disableDelayedDrag);\n\t\t},\n\n\t\t_triggerDragStart: function (/** Touch */touch) {\n\t\t\tif (touch) {\n\t\t\t\t// Touch device support\n\t\t\t\ttapEvt = {\n\t\t\t\t\ttarget: dragEl,\n\t\t\t\t\tclientX: touch.clientX,\n\t\t\t\t\tclientY: touch.clientY\n\t\t\t\t};\n\n\t\t\t\tthis._onDragStart(tapEvt, 'touch');\n\t\t\t}\n\t\t\telse if (!this.nativeDraggable) {\n\t\t\t\tthis._onDragStart(tapEvt, true);\n\t\t\t}\n\t\t\telse {\n\t\t\t\t_on(dragEl, 'dragend', this);\n\t\t\t\t_on(rootEl, 'dragstart', this._onDragStart);\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tif (document.selection) {\n\t\t\t\t\tdocument.selection.empty();\n\t\t\t\t} else {\n\t\t\t\t\twindow.getSelection().removeAllRanges();\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t}\n\t\t},\n\n\t\t_dragStarted: function () {\n\t\t\tif (rootEl && dragEl) {\n\t\t\t\t// Apply effect\n\t\t\t\t_toggleClass(dragEl, this.options.ghostClass, true);\n\n\t\t\t\tSortable.active = this;\n\n\t\t\t\t// Drag start event\n\t\t\t\t_dispatchEvent(this, rootEl, 'start', dragEl, rootEl, oldIndex);\n\t\t\t}\n\t\t},\n\n\t\t_emulateDragOver: function () {\n\t\t\tif (touchEvt) {\n\t\t\t\tif (this._lastX === touchEvt.clientX && this._lastY === touchEvt.clientY) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis._lastX = touchEvt.clientX;\n\t\t\t\tthis._lastY = touchEvt.clientY;\n\n\t\t\t\tif (!supportCssPointerEvents) {\n\t\t\t\t\t_css(ghostEl, 'display', 'none');\n\t\t\t\t}\n\n\t\t\t\tvar target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY),\n\t\t\t\t\tparent = target,\n\t\t\t\t\tgroupName = ' ' + this.options.group.name + '',\n\t\t\t\t\ti = touchDragOverListeners.length;\n\n\t\t\t\tif (parent) {\n\t\t\t\t\tdo {\n\t\t\t\t\t\tif (parent[expando] && parent[expando].options.groups.indexOf(groupName) > -1) {\n\t\t\t\t\t\t\twhile (i--) {\n\t\t\t\t\t\t\t\ttouchDragOverListeners[i]({\n\t\t\t\t\t\t\t\t\tclientX: touchEvt.clientX,\n\t\t\t\t\t\t\t\t\tclientY: touchEvt.clientY,\n\t\t\t\t\t\t\t\t\ttarget: target,\n\t\t\t\t\t\t\t\t\trootEl: parent\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttarget = parent; // store last element\n\t\t\t\t\t}\n\t\t\t\t\t/* jshint boss:true */\n\t\t\t\t\twhile (parent = parent.parentNode);\n\t\t\t\t}\n\n\t\t\t\tif (!supportCssPointerEvents) {\n\t\t\t\t\t_css(ghostEl, 'display', '');\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\n\t\t_onTouchMove: function (/**TouchEvent*/evt) {\n\t\t\tif (tapEvt) {\n\t\t\t\t// only set the status to dragging, when we are actually dragging\n\t\t\t\tif (!Sortable.active) {\n\t\t\t\t\tthis._dragStarted();\n\t\t\t\t}\n\n\t\t\t\t// as well as creating the ghost element on the document body\n\t\t\t\tthis._appendGhost();\n\n\t\t\t\tvar touch = evt.touches ? evt.touches[0] : evt,\n\t\t\t\t\tdx = touch.clientX - tapEvt.clientX,\n\t\t\t\t\tdy = touch.clientY - tapEvt.clientY,\n\t\t\t\t\ttranslate3d = evt.touches ? 'translate3d(' + dx + 'px,' + dy + 'px,0)' : 'translate(' + dx + 'px,' + dy + 'px)';\n\n\t\t\t\tmoved = true;\n\t\t\t\ttouchEvt = touch;\n\n\t\t\t\t_css(ghostEl, 'webkitTransform', translate3d);\n\t\t\t\t_css(ghostEl, 'mozTransform', translate3d);\n\t\t\t\t_css(ghostEl, 'msTransform', translate3d);\n\t\t\t\t_css(ghostEl, 'transform', translate3d);\n\n\t\t\t\tevt.preventDefault();\n\t\t\t}\n\t\t},\n\n\t\t_appendGhost: function () {\n\t\t\tif (!ghostEl) {\n\t\t\t\tvar rect = dragEl.getBoundingClientRect(),\n\t\t\t\t\tcss = _css(dragEl),\n\t\t\t\t\toptions = this.options,\n\t\t\t\t\tghostRect;\n\n\t\t\t\tghostEl = dragEl.cloneNode(true);\n\n\t\t\t\t_toggleClass(ghostEl, options.ghostClass, false);\n\t\t\t\t_toggleClass(ghostEl, options.fallbackClass, true);\n\n\t\t\t\t_css(ghostEl, 'top', rect.top - parseInt(css.marginTop, 10));\n\t\t\t\t_css(ghostEl, 'left', rect.left - parseInt(css.marginLeft, 10));\n\t\t\t\t_css(ghostEl, 'width', rect.width);\n\t\t\t\t_css(ghostEl, 'height', rect.height);\n\t\t\t\t_css(ghostEl, 'opacity', '0.8');\n\t\t\t\t_css(ghostEl, 'position', 'fixed');\n\t\t\t\t_css(ghostEl, 'zIndex', '100000');\n\t\t\t\t_css(ghostEl, 'pointerEvents', 'none');\n\n\t\t\t\toptions.fallbackOnBody && document.body.appendChild(ghostEl) || rootEl.appendChild(ghostEl);\n\n\t\t\t\t// Fixing dimensions.\n\t\t\t\tghostRect = ghostEl.getBoundingClientRect();\n\t\t\t\t_css(ghostEl, 'width', rect.width * 2 - ghostRect.width);\n\t\t\t\t_css(ghostEl, 'height', rect.height * 2 - ghostRect.height);\n\t\t\t}\n\t\t},\n\n\t\t_onDragStart: function (/**Event*/evt, /**boolean*/useFallback) {\n\t\t\tvar dataTransfer = evt.dataTransfer,\n\t\t\t\toptions = this.options;\n\n\t\t\tthis._offUpEvents();\n\n\t\t\tif (activeGroup.pull == 'clone') {\n\t\t\t\tcloneEl = dragEl.cloneNode(true);\n\t\t\t\t_css(cloneEl, 'display', 'none');\n\t\t\t\trootEl.insertBefore(cloneEl, dragEl);\n\t\t\t}\n\n\t\t\tif (useFallback) {\n\n\t\t\t\tif (useFallback === 'touch') {\n\t\t\t\t\t// Bind touch events\n\t\t\t\t\t_on(document, 'touchmove', this._onTouchMove);\n\t\t\t\t\t_on(document, 'touchend', this._onDrop);\n\t\t\t\t\t_on(document, 'touchcancel', this._onDrop);\n\t\t\t\t} else {\n\t\t\t\t\t// Old brwoser\n\t\t\t\t\t_on(document, 'mousemove', this._onTouchMove);\n\t\t\t\t\t_on(document, 'mouseup', this._onDrop);\n\t\t\t\t}\n\n\t\t\t\tthis._loopId = setInterval(this._emulateDragOver, 50);\n\t\t\t}\n\t\t\telse {\n\t\t\t\tif (dataTransfer) {\n\t\t\t\t\tdataTransfer.effectAllowed = 'move';\n\t\t\t\t\toptions.setData && options.setData.call(this, dataTransfer, dragEl);\n\t\t\t\t}\n\n\t\t\t\t_on(document, 'drop', this);\n\t\t\t\tsetTimeout(this._dragStarted, 0);\n\t\t\t}\n\t\t},\n\n\t\t_onDragOver: function (/**Event*/evt) {\n\t\t\tvar el = this.el,\n\t\t\t\ttarget,\n\t\t\t\tdragRect,\n\t\t\t\trevert,\n\t\t\t\toptions = this.options,\n\t\t\t\tgroup = options.group,\n\t\t\t\tgroupPut = group.put,\n\t\t\t\tisOwner = (activeGroup === group),\n\t\t\t\tcanSort = options.sort;\n\n\t\t\tif (evt.preventDefault !== void 0) {\n\t\t\t\tevt.preventDefault();\n\t\t\t\t!options.dragoverBubble && evt.stopPropagation();\n\t\t\t}\n\n\t\t\tmoved = true;\n\n\t\t\tif (activeGroup && !options.disabled &&\n\t\t\t\t(isOwner\n\t\t\t\t\t? canSort || (revert = !rootEl.contains(dragEl)) // Reverting item into the original list\n\t\t\t\t\t: activeGroup.pull && groupPut && (\n\t\t\t\t\t\t(activeGroup.name === group.name) || // by Name\n\t\t\t\t\t\t(groupPut.indexOf && ~groupPut.indexOf(activeGroup.name)) // by Array\n\t\t\t\t\t)\n\t\t\t\t) &&\n\t\t\t\t(evt.rootEl === void 0 || evt.rootEl === this.el) // touch fallback\n\t\t\t) {\n\t\t\t\t// Smart auto-scrolling\n\t\t\t\t_autoScroll(evt, options, this.el);\n\n\t\t\t\tif (_silent) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\ttarget = _closest(evt.target, options.draggable, el);\n\t\t\t\tdragRect = dragEl.getBoundingClientRect();\n\n\t\t\t\tif (revert) {\n\t\t\t\t\t_cloneHide(true);\n\n\t\t\t\t\tif (cloneEl || nextEl) {\n\t\t\t\t\t\trootEl.insertBefore(dragEl, cloneEl || nextEl);\n\t\t\t\t\t}\n\t\t\t\t\telse if (!canSort) {\n\t\t\t\t\t\trootEl.appendChild(dragEl);\n\t\t\t\t\t}\n\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\n\t\t\t\tif ((el.children.length === 0) || (el.children[0] === ghostEl) ||\n\t\t\t\t\t(el === evt.target) && (target = _ghostIsLast(el, evt))\n\t\t\t\t) {\n\n\t\t\t\t\tif (target) {\n\t\t\t\t\t\tif (target.animated) {\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttargetRect = target.getBoundingClientRect();\n\t\t\t\t\t}\n\n\t\t\t\t\t_cloneHide(isOwner);\n\n\t\t\t\t\tif (_onMove(rootEl, el, dragEl, dragRect, target, targetRect) !== false) {\n\t\t\t\t\t\tif (!dragEl.contains(el)) {\n\t\t\t\t\t\t\tel.appendChild(dragEl);\n\t\t\t\t\t\t\tparentEl = el; // actualization\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis._animate(dragRect, dragEl);\n\t\t\t\t\t\ttarget && this._animate(targetRect, target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if (target && !target.animated && target !== dragEl && (target.parentNode[expando] !== void 0)) {\n\t\t\t\t\tif (lastEl !== target) {\n\t\t\t\t\t\tlastEl = target;\n\t\t\t\t\t\tlastCSS = _css(target);\n\t\t\t\t\t\tlastParentCSS = _css(target.parentNode);\n\t\t\t\t\t}\n\n\n\t\t\t\t\tvar targetRect = target.getBoundingClientRect(),\n\t\t\t\t\t\twidth = targetRect.right - targetRect.left,\n\t\t\t\t\t\theight = targetRect.bottom - targetRect.top,\n\t\t\t\t\t\tfloating = /left|right|inline/.test(lastCSS.cssFloat + lastCSS.display)\n\t\t\t\t\t\t\t|| (lastParentCSS.display == 'flex' && lastParentCSS['flex-direction'].indexOf('row') === 0),\n\t\t\t\t\t\tisWide = (target.offsetWidth > dragEl.offsetWidth),\n\t\t\t\t\t\tisLong = (target.offsetHeight > dragEl.offsetHeight),\n\t\t\t\t\t\thalfway = (floating ? (evt.clientX - targetRect.left) / width : (evt.clientY - targetRect.top) / height) > 0.5,\n\t\t\t\t\t\tnextSibling = target.nextElementSibling,\n\t\t\t\t\t\tmoveVector = _onMove(rootEl, el, dragEl, dragRect, target, targetRect),\n\t\t\t\t\t\tafter\n\t\t\t\t\t;\n\n\t\t\t\t\tif (moveVector !== false) {\n\t\t\t\t\t\t_silent = true;\n\t\t\t\t\t\tsetTimeout(_unsilent, 30);\n\n\t\t\t\t\t\t_cloneHide(isOwner);\n\n\t\t\t\t\t\tif (moveVector === 1 || moveVector === -1) {\n\t\t\t\t\t\t\tafter = (moveVector === 1);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse if (floating) {\n\t\t\t\t\t\t\tvar elTop = dragEl.offsetTop,\n\t\t\t\t\t\t\t\ttgTop = target.offsetTop;\n\n\t\t\t\t\t\t\tif (elTop === tgTop) {\n\t\t\t\t\t\t\t\tafter = (target.previousElementSibling === dragEl) && !isWide || halfway && isWide;\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tafter = tgTop > elTop;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tafter = (nextSibling !== dragEl) && !isLong || halfway && isLong;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (!dragEl.contains(el)) {\n\t\t\t\t\t\t\tif (after && !nextSibling) {\n\t\t\t\t\t\t\t\tel.appendChild(dragEl);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\ttarget.parentNode.insertBefore(dragEl, after ? nextSibling : target);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tparentEl = dragEl.parentNode; // actualization\n\n\t\t\t\t\t\tthis._animate(dragRect, dragEl);\n\t\t\t\t\t\tthis._animate(targetRect, target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t_animate: function (prevRect, target) {\n\t\t\tvar ms = this.options.animation;\n\n\t\t\tif (ms) {\n\t\t\t\tvar currentRect = target.getBoundingClientRect();\n\n\t\t\t\t_css(target, 'transition', 'none');\n\t\t\t\t_css(target, 'transform', 'translate3d('\n\t\t\t\t\t+ (prevRect.left - currentRect.left) + 'px,'\n\t\t\t\t\t+ (prevRect.top - currentRect.top) + 'px,0)'\n\t\t\t\t);\n\n\t\t\t\ttarget.offsetWidth; // repaint\n\n\t\t\t\t_css(target, 'transition', 'all ' + ms + 'ms');\n\t\t\t\t_css(target, 'transform', 'translate3d(0,0,0)');\n\n\t\t\t\tclearTimeout(target.animated);\n\t\t\t\ttarget.animated = setTimeout(function () {\n\t\t\t\t\t_css(target, 'transition', '');\n\t\t\t\t\t_css(target, 'transform', '');\n\t\t\t\t\ttarget.animated = false;\n\t\t\t\t}, ms);\n\t\t\t}\n\t\t},\n\n\t\t_offUpEvents: function () {\n\t\t\tvar ownerDocument = this.el.ownerDocument;\n\n\t\t\t_off(document, 'touchmove', this._onTouchMove);\n\t\t\t_off(ownerDocument, 'mouseup', this._onDrop);\n\t\t\t_off(ownerDocument, 'touchend', this._onDrop);\n\t\t\t_off(ownerDocument, 'touchcancel', this._onDrop);\n\t\t},\n\n\t\t_onDrop: function (/**Event*/evt) {\n\t\t\tvar el = this.el,\n\t\t\t\toptions = this.options;\n\n\t\t\tclearInterval(this._loopId);\n\t\t\tclearInterval(autoScroll.pid);\n\t\t\tclearTimeout(this._dragStartTimer);\n\n\t\t\t// Unbind events\n\t\t\t_off(document, 'mousemove', this._onTouchMove);\n\n\t\t\tif (this.nativeDraggable) {\n\t\t\t\t_off(document, 'drop', this);\n\t\t\t\t_off(el, 'dragstart', this._onDragStart);\n\t\t\t}\n\n\t\t\tthis._offUpEvents();\n\n\t\t\tif (evt) {\n\t\t\t\tif (moved) {\n\t\t\t\t\tevt.preventDefault();\n\t\t\t\t\t!options.dropBubble && evt.stopPropagation();\n\t\t\t\t}\n\n\t\t\t\tghostEl && ghostEl.parentNode.removeChild(ghostEl);\n\n\t\t\t\tif (dragEl) {\n\t\t\t\t\tif (this.nativeDraggable) {\n\t\t\t\t\t\t_off(dragEl, 'dragend', this);\n\t\t\t\t\t}\n\n\t\t\t\t\t_disableDraggable(dragEl);\n\n\t\t\t\t\t// Remove class's\n\t\t\t\t\t_toggleClass(dragEl, this.options.ghostClass, false);\n\t\t\t\t\t_toggleClass(dragEl, this.options.chosenClass, false);\n\n\t\t\t\t\tif (rootEl !== parentEl) {\n\t\t\t\t\t\tnewIndex = _index(dragEl);\n\n\t\t\t\t\t\tif (newIndex >= 0) {\n\t\t\t\t\t\t\t// drag from one list and drop into another\n\t\t\t\t\t\t\t_dispatchEvent(null, parentEl, 'sort', dragEl, rootEl, oldIndex, newIndex);\n\t\t\t\t\t\t\t_dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);\n\n\t\t\t\t\t\t\t// Add event\n\t\t\t\t\t\t\t_dispatchEvent(null, parentEl, 'add', dragEl, rootEl, oldIndex, newIndex);\n\n\t\t\t\t\t\t\t// Remove event\n\t\t\t\t\t\t\t_dispatchEvent(this, rootEl, 'remove', dragEl, rootEl, oldIndex, newIndex);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse {\n\t\t\t\t\t\t// Remove clone\n\t\t\t\t\t\tcloneEl && cloneEl.parentNode.removeChild(cloneEl);\n\n\t\t\t\t\t\tif (dragEl.nextSibling !== nextEl) {\n\t\t\t\t\t\t\t// Get the index of the dragged element within its parent\n\t\t\t\t\t\t\tnewIndex = _index(dragEl);\n\n\t\t\t\t\t\t\tif (newIndex >= 0) {\n\t\t\t\t\t\t\t\t// drag & drop within the same list\n\t\t\t\t\t\t\t\t_dispatchEvent(this, rootEl, 'update', dragEl, rootEl, oldIndex, newIndex);\n\t\t\t\t\t\t\t\t_dispatchEvent(this, rootEl, 'sort', dragEl, rootEl, oldIndex, newIndex);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif (Sortable.active) {\n\t\t\t\t\t\tif (newIndex === null || newIndex === -1) {\n\t\t\t\t\t\t\tnewIndex = oldIndex;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t_dispatchEvent(this, rootEl, 'end', dragEl, rootEl, oldIndex, newIndex);\n\n\t\t\t\t\t\t// Save sorting\n\t\t\t\t\t\tthis.save();\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Nulling\n\t\t\t\trootEl =\n\t\t\t\tdragEl =\n\t\t\t\tparentEl =\n\t\t\t\tghostEl =\n\t\t\t\tnextEl =\n\t\t\t\tcloneEl =\n\n\t\t\t\tscrollEl =\n\t\t\t\tscrollParentEl =\n\n\t\t\t\ttapEvt =\n\t\t\t\ttouchEvt =\n\n\t\t\t\tmoved =\n\t\t\t\tnewIndex =\n\n\t\t\t\tlastEl =\n\t\t\t\tlastCSS =\n\n\t\t\t\tactiveGroup =\n\t\t\t\tSortable.active = null;\n\t\t\t}\n\t\t},\n\n\n\t\thandleEvent: function (/**Event*/evt) {\n\t\t\tvar type = evt.type;\n\n\t\t\tif (type === 'dragover' || type === 'dragenter') {\n\t\t\t\tif (dragEl) {\n\t\t\t\t\tthis._onDragOver(evt);\n\t\t\t\t\t_globalDragOver(evt);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (type === 'drop' || type === 'dragend') {\n\t\t\t\tthis._onDrop(evt);\n\t\t\t}\n\t\t},\n\n\n\t\t/**\n\t\t * Serializes the item into an array of string.\n\t\t * @returns {String[]}\n\t\t */\n\t\ttoArray: function () {\n\t\t\tvar order = [],\n\t\t\t\tel,\n\t\t\t\tchildren = this.el.children,\n\t\t\t\ti = 0,\n\t\t\t\tn = children.length,\n\t\t\t\toptions = this.options;\n\n\t\t\tfor (; i < n; i++) {\n\t\t\t\tel = children[i];\n\t\t\t\tif (_closest(el, options.draggable, this.el)) {\n\t\t\t\t\torder.push(el.getAttribute(options.dataIdAttr) || _generateId(el));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn order;\n\t\t},\n\n\n\t\t/**\n\t\t * Sorts the elements according to the array.\n\t\t * @param  {String[]}  order  order of the items\n\t\t */\n\t\tsort: function (order) {\n\t\t\tvar items = {}, rootEl = this.el;\n\n\t\t\tthis.toArray().forEach(function (id, i) {\n\t\t\t\tvar el = rootEl.children[i];\n\n\t\t\t\tif (_closest(el, this.options.draggable, rootEl)) {\n\t\t\t\t\titems[id] = el;\n\t\t\t\t}\n\t\t\t}, this);\n\n\t\t\torder.forEach(function (id) {\n\t\t\t\tif (items[id]) {\n\t\t\t\t\trootEl.removeChild(items[id]);\n\t\t\t\t\trootEl.appendChild(items[id]);\n\t\t\t\t}\n\t\t\t});\n\t\t},\n\n\n\t\t/**\n\t\t * Save the current sorting\n\t\t */\n\t\tsave: function () {\n\t\t\tvar store = this.options.store;\n\t\t\tstore && store.set(this);\n\t\t},\n\n\n\t\t/**\n\t\t * For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.\n\t\t * @param   {HTMLElement}  el\n\t\t * @param   {String}       [selector]  default: `options.draggable`\n\t\t * @returns {HTMLElement|null}\n\t\t */\n\t\tclosest: function (el, selector) {\n\t\t\treturn _closest(el, selector || this.options.draggable, this.el);\n\t\t},\n\n\n\t\t/**\n\t\t * Set/get option\n\t\t * @param   {string} name\n\t\t * @param   {*}      [value]\n\t\t * @returns {*}\n\t\t */\n\t\toption: function (name, value) {\n\t\t\tvar options = this.options;\n\n\t\t\tif (value === void 0) {\n\t\t\t\treturn options[name];\n\t\t\t} else {\n\t\t\t\toptions[name] = value;\n\n\t\t\t\tif (name === 'group') {\n\t\t\t\t\t_prepareGroup(options);\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\n\t\t/**\n\t\t * Destroy\n\t\t */\n\t\tdestroy: function () {\n\t\t\tvar el = this.el;\n\n\t\t\tel[expando] = null;\n\n\t\t\t_off(el, 'mousedown', this._onTapStart);\n\t\t\t_off(el, 'touchstart', this._onTapStart);\n\n\t\t\tif (this.nativeDraggable) {\n\t\t\t\t_off(el, 'dragover', this);\n\t\t\t\t_off(el, 'dragenter', this);\n\t\t\t}\n\n\t\t\t// Remove draggable attributes\n\t\t\tArray.prototype.forEach.call(el.querySelectorAll('[draggable]'), function (el) {\n\t\t\t\tel.removeAttribute('draggable');\n\t\t\t});\n\n\t\t\ttouchDragOverListeners.splice(touchDragOverListeners.indexOf(this._onDragOver), 1);\n\n\t\t\tthis._onDrop();\n\n\t\t\tthis.el = el = null;\n\t\t}\n\t};\n\n\n\tfunction _cloneHide(state) {\n\t\tif (cloneEl && (cloneEl.state !== state)) {\n\t\t\t_css(cloneEl, 'display', state ? 'none' : '');\n\t\t\t!state && cloneEl.state && rootEl.insertBefore(cloneEl, dragEl);\n\t\t\tcloneEl.state = state;\n\t\t}\n\t}\n\n\n\tfunction _closest(/**HTMLElement*/el, /**String*/selector, /**HTMLElement*/ctx) {\n\t\tif (el) {\n\t\t\tctx = ctx || document;\n\t\t\tselector = selector.split('.');\n\n\t\t\tvar tag = selector.shift().toUpperCase(),\n\t\t\t\tre = new RegExp('\\\\s(' + selector.join('|') + ')(?=\\\\s)', 'g');\n\n\t\t\tdo {\n\t\t\t\tif (\n\t\t\t\t\t(tag === '>*' && el.parentNode === ctx) || (\n\t\t\t\t\t\t(tag === '' || el.nodeName.toUpperCase() == tag) &&\n\t\t\t\t\t\t(!selector.length || ((' ' + el.className + ' ').match(re) || []).length == selector.length)\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\treturn el;\n\t\t\t\t}\n\t\t\t}\n\t\t\twhile (el !== ctx && (el = el.parentNode));\n\t\t}\n\n\t\treturn null;\n\t}\n\n\n\tfunction _globalDragOver(/**Event*/evt) {\n\t\tif (evt.dataTransfer) {\n\t\t\tevt.dataTransfer.dropEffect = 'move';\n\t\t}\n\t\tevt.preventDefault();\n\t}\n\n\n\tfunction _on(el, event, fn) {\n\t\tel.addEventListener(event, fn, false);\n\t}\n\n\n\tfunction _off(el, event, fn) {\n\t\tel.removeEventListener(event, fn, false);\n\t}\n\n\n\tfunction _toggleClass(el, name, state) {\n\t\tif (el) {\n\t\t\tif (el.classList) {\n\t\t\t\tel.classList[state ? 'add' : 'remove'](name);\n\t\t\t}\n\t\t\telse {\n\t\t\t\tvar className = (' ' + el.className + ' ').replace(RSPACE, ' ').replace(' ' + name + ' ', ' ');\n\t\t\t\tel.className = (className + (state ? ' ' + name : '')).replace(RSPACE, ' ');\n\t\t\t}\n\t\t}\n\t}\n\n\n\tfunction _css(el, prop, val) {\n\t\tvar style = el && el.style;\n\n\t\tif (style) {\n\t\t\tif (val === void 0) {\n\t\t\t\tif (document.defaultView && document.defaultView.getComputedStyle) {\n\t\t\t\t\tval = document.defaultView.getComputedStyle(el, '');\n\t\t\t\t}\n\t\t\t\telse if (el.currentStyle) {\n\t\t\t\t\tval = el.currentStyle;\n\t\t\t\t}\n\n\t\t\t\treturn prop === void 0 ? val : val[prop];\n\t\t\t}\n\t\t\telse {\n\t\t\t\tif (!(prop in style)) {\n\t\t\t\t\tprop = '-webkit-' + prop;\n\t\t\t\t}\n\n\t\t\t\tstyle[prop] = val + (typeof val === 'string' ? '' : 'px');\n\t\t\t}\n\t\t}\n\t}\n\n\n\tfunction _find(ctx, tagName, iterator) {\n\t\tif (ctx) {\n\t\t\tvar list = ctx.getElementsByTagName(tagName), i = 0, n = list.length;\n\n\t\t\tif (iterator) {\n\t\t\t\tfor (; i < n; i++) {\n\t\t\t\t\titerator(list[i], i);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn list;\n\t\t}\n\n\t\treturn [];\n\t}\n\n\n\n\tfunction _dispatchEvent(sortable, rootEl, name, targetEl, fromEl, startIndex, newIndex) {\n\t\tvar evt = document.createEvent('Event'),\n\t\t\toptions = (sortable || rootEl[expando]).options,\n\t\t\tonName = 'on' + name.charAt(0).toUpperCase() + name.substr(1);\n\n\t\tevt.initEvent(name, true, true);\n\n\t\tevt.to = rootEl;\n\t\tevt.from = fromEl || rootEl;\n\t\tevt.item = targetEl || rootEl;\n\t\tevt.clone = cloneEl;\n\n\t\tevt.oldIndex = startIndex;\n\t\tevt.newIndex = newIndex;\n\n\t\trootEl.dispatchEvent(evt);\n\n\t\tif (options[onName]) {\n\t\t\toptions[onName].call(sortable, evt);\n\t\t}\n\t}\n\n\n\tfunction _onMove(fromEl, toEl, dragEl, dragRect, targetEl, targetRect) {\n\t\tvar evt,\n\t\t\tsortable = fromEl[expando],\n\t\t\tonMoveFn = sortable.options.onMove,\n\t\t\tretVal;\n\n\t\tevt = document.createEvent('Event');\n\t\tevt.initEvent('move', true, true);\n\n\t\tevt.to = toEl;\n\t\tevt.from = fromEl;\n\t\tevt.dragged = dragEl;\n\t\tevt.draggedRect = dragRect;\n\t\tevt.related = targetEl || toEl;\n\t\tevt.relatedRect = targetRect || toEl.getBoundingClientRect();\n\n\t\tfromEl.dispatchEvent(evt);\n\n\t\tif (onMoveFn) {\n\t\t\tretVal = onMoveFn.call(sortable, evt);\n\t\t}\n\n\t\treturn retVal;\n\t}\n\n\n\tfunction _disableDraggable(el) {\n\t\tel.draggable = false;\n\t}\n\n\n\tfunction _unsilent() {\n\t\t_silent = false;\n\t}\n\n\n\t/** @returns {HTMLElement|false} */\n\tfunction _ghostIsLast(el, evt) {\n\t\tvar lastEl = el.lastElementChild,\n\t\t\t\trect = lastEl.getBoundingClientRect();\n\n\t\treturn ((evt.clientY - (rect.top + rect.height) > 5) || (evt.clientX - (rect.right + rect.width) > 5)) && lastEl; // min delta\n\t}\n\n\n\t/**\n\t * Generate id\n\t * @param   {HTMLElement} el\n\t * @returns {String}\n\t * @private\n\t */\n\tfunction _generateId(el) {\n\t\tvar str = el.tagName + el.className + el.src + el.href + el.textContent,\n\t\t\ti = str.length,\n\t\t\tsum = 0;\n\n\t\twhile (i--) {\n\t\t\tsum += str.charCodeAt(i);\n\t\t}\n\n\t\treturn sum.toString(36);\n\t}\n\n\t/**\n\t * Returns the index of an element within its parent\n\t * @param  {HTMLElement} el\n\t * @return {number}\n\t */\n\tfunction _index(el) {\n\t\tvar index = 0;\n\n\t\tif (!el || !el.parentNode) {\n\t\t\treturn -1;\n\t\t}\n\n\t\twhile (el && (el = el.previousElementSibling)) {\n\t\t\tif (el.nodeName.toUpperCase() !== 'TEMPLATE') {\n\t\t\t\tindex++;\n\t\t\t}\n\t\t}\n\n\t\treturn index;\n\t}\n\n\tfunction _throttle(callback, ms) {\n\t\tvar args, _this;\n\n\t\treturn function () {\n\t\t\tif (args === void 0) {\n\t\t\t\targs = arguments;\n\t\t\t\t_this = this;\n\n\t\t\t\tsetTimeout(function () {\n\t\t\t\t\tif (args.length === 1) {\n\t\t\t\t\t\tcallback.call(_this, args[0]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcallback.apply(_this, args);\n\t\t\t\t\t}\n\n\t\t\t\t\targs = void 0;\n\t\t\t\t}, ms);\n\t\t\t}\n\t\t};\n\t}\n\n\tfunction _extend(dst, src) {\n\t\tif (dst && src) {\n\t\t\tfor (var key in src) {\n\t\t\t\tif (src.hasOwnProperty(key)) {\n\t\t\t\t\tdst[key] = src[key];\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn dst;\n\t}\n\n\n\t// Export utils\n\tSortable.utils = {\n\t\ton: _on,\n\t\toff: _off,\n\t\tcss: _css,\n\t\tfind: _find,\n\t\tis: function (el, selector) {\n\t\t\treturn !!_closest(el, selector, el);\n\t\t},\n\t\textend: _extend,\n\t\tthrottle: _throttle,\n\t\tclosest: _closest,\n\t\ttoggleClass: _toggleClass,\n\t\tindex: _index\n\t};\n\n\n\t/**\n\t * Create sortable instance\n\t * @param {HTMLElement}  el\n\t * @param {Object}      [options]\n\t */\n\tSortable.create = function (el, options) {\n\t\treturn new Sortable(el, options);\n\t};\n\n\n\t// Export\n\tSortable.version = '1.3.0';\n\treturn Sortable;\n});\n"
  },
  {
    "path": "public/Sortable-1.4.0/bower.json",
    "content": "{\n  \"name\": \"Sortable\",\n  \"main\": [\n  \t\"Sortable.js\",\n  \t\"ng-sortable.js\",\n  \t\"knockout-sortable.js\",\n  \t\"react-sortable-mixin.js\"\n  ],\n  \"homepage\": \"http://rubaxa.github.io/Sortable/\",\n  \"authors\": [\n    \"RubaXa <ibnRubaXa@gmail.com>\"\n  ],\n  \"description\": \"Minimalist library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery.\",\n  \"keywords\": [\n    \"sortable\",\n    \"reorder\",\n    \"list\",\n    \"html5\",\n    \"drag\",\n    \"and\",\n    \"drop\",\n    \"dnd\",\n\t\t\"web-components\"\n  ],\n  \"license\": \"MIT\",\n  \"ignore\": [\n    \"node_modules\",\n    \"bower_components\",\n    \"test\",\n    \"tests\"\n  ],\n  \"dependencies\": {\n    \"polymer\": \"Polymer/polymer#~1.1.4\",\n\t}\n}\n"
  },
  {
    "path": "public/Sortable-1.4.0/component.json",
    "content": "{\n  \"name\": \"Sortable\",\n  \"main\": \"Sortable.js\",\n  \"version\": \"1.3.0\",\n  \"homepage\": \"http://rubaxa.github.io/Sortable/\",\n  \"repo\": \"RubaXa/Sortable\",\n  \"authors\": [\n    \"RubaXa <ibnRubaXa@gmail.com>\"\n  ],\n  \"description\": \"Minimalist library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery.\",\n  \"keywords\": [\n    \"sortable\",\n    \"reorder\",\n    \"list\",\n    \"html5\",\n    \"drag\",\n    \"and\",\n    \"drop\",\n    \"dnd\"\n  ],\n  \"license\": \"MIT\",\n  \"ignore\": [\n    \"node_modules\",\n    \"bower_components\",\n    \"test\",\n    \"tests\"\n  ],\n  \n  \"scripts\": [\n    \"Sortable.js\"\n  ]\n}\n"
  },
  {
    "path": "public/Sortable-1.4.0/jquery.binding.js",
    "content": "/**\n * jQuery plugin for Sortable\n * @author\tRubaXa   <trash@rubaxa.org>\n * @license MIT\n */\n(function (factory) {\n\t\"use strict\";\n\n\tif (typeof define === \"function\" && define.amd) {\n\t\tdefine([\"jquery\"], factory);\n\t}\n\telse {\n\t\t/* jshint sub:true */\n\t\tfactory(jQuery);\n\t}\n})(function ($) {\n\t\"use strict\";\n\n\n\t/* CODE */\n\n\n\t/**\n\t * jQuery plugin for Sortable\n\t * @param   {Object|String} options\n\t * @param   {..*}           [args]\n\t * @returns {jQuery|*}\n\t */\n\t$.fn.sortable = function (options) {\n\t\tvar retVal,\n\t\t\targs = arguments;\n\n\t\tthis.each(function () {\n\t\t\tvar $el = $(this),\n\t\t\t\tsortable = $el.data('sortable');\n\n\t\t\tif (!sortable && (options instanceof Object || !options)) {\n\t\t\t\tsortable = new Sortable(this, options);\n\t\t\t\t$el.data('sortable', sortable);\n\t\t\t}\n\n\t\t\tif (sortable) {\n\t\t\t\tif (options === 'widget') {\n\t\t\t\t\treturn sortable;\n\t\t\t\t}\n\t\t\t\telse if (options === 'destroy') {\n\t\t\t\t\tsortable.destroy();\n\t\t\t\t\t$el.removeData('sortable');\n\t\t\t\t}\n\t\t\t\telse if (typeof sortable[options] === 'function') {\n\t\t\t\t\tretVal = sortable[options].apply(sortable, [].slice.call(args, 1));\n\t\t\t\t}\n\t\t\t\telse if (options in sortable.options) {\n\t\t\t\t\tretVal = sortable.option.apply(sortable, args);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\treturn (retVal === void 0) ? this : retVal;\n\t};\n});\n"
  },
  {
    "path": "public/Sortable-1.4.0/package.json",
    "content": "{\n  \"name\": \"sortablejs\",\n  \"exportName\": \"Sortable\",\n  \"version\": \"1.4.0\",\n  \"devDependencies\": {\n    \"grunt\": \"*\",\n    \"grunt-version\": \"*\",\n    \"grunt-exec\": \"*\",\n    \"grunt-contrib-jshint\": \"0.9.2\",\n    \"grunt-contrib-uglify\": \"*\",\n    \"spacejam\": \"*\"\n  },\n  \"description\": \"Minimalist JavaScript library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery. Supports AngularJS and any CSS library, e.g. Bootstrap.\",\n  \"main\": \"Sortable.js\",\n  \"scripts\": {\n    \"test\": \"grunt\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git://github.com/rubaxa/Sortable.git\"\n  },\n  \"keywords\": [\n    \"sortable\",\n    \"reorder\",\n    \"drag\",\n    \"meteor\",\n    \"angular\",\n    \"ng-sortable\",\n    \"react\",\n    \"mixin\"\n  ],\n  \"author\": \"Konstantin Lebedev <ibnRubaXa@gmail.com>\",\n  \"license\": \"MIT\",\n  \"spm\": {\n    \"main\": \"Sortable.js\",\n    \"ignore\": [\n      \"meteor\",\n      \"st\"\n    ]\n  }\n}\n"
  },
  {
    "path": "public/account.js",
    "content": "\"use strict\";\n\n(() => {\naddInitHook(\"end_init\", () => {\n\t$(\"#dash_username input\").click(()=>{\n\t\t$(\"#dash_username button\").show();\n\t});\n});\n})()"
  },
  {
    "path": "public/analytics.js",
    "content": "function memStuff(window,document,Chartist) {\n\t'use strict';\n\tChartist.plugins = Chartist.plugins || {};\n\tChartist.plugins.byteUnits = function(options) {\n\toptions = Chartist.extend({},{},options);\n\n    return function byteUnits(chart) {\n    \tif(!chart instanceof Chartist.Line) return;\n\t\t\t\n\t\tchart.on('created', function() {\n\t\t\tlog(\"running created\")\n\t\t\tconst vbits = document.getElementsByClassName(\"ct-vertical\");\n\t\t\tif(vbits==null) return;\n\n\t\t\tlet tbits = [];\n\t\t\tfor(let i=0; i<vbits.length; i++) {\n\t\t\t\ttbits[i] = vbits[i].innerHTML;\n\t\t\t}\n\t\t\tlog(\"tbits\",tbits);\n\t\t\t\n\t\t\tconst calc = (places) => {\n\t\t\t\tif(places==3) return;\n\t\t\t\n\t\t\t\tconst matcher = vbits[0].innerHTML;\n\t\t\t\tlet allMatch = true;\n       \t\t\tfor(let i=0; i<tbits.length; i++) {\n\t\t\t\t\tlet val = convertByteUnit(tbits[i], places);\n\t\t\t\t\tif(val!=matcher) allMatch = false;\n\t\t\t\t\tvbits[i].innerHTML = val;\n\t\t\t\t}\n\t\t\t\tif(allMatch) calc(places + 1);\n\t\t\t}\n\t\t\tcalc(0);\n       });\n    };\n  };\n}\n\nfunction perfStuff(window,document,Chartist) {\n\t'use strict';\n\tChartist.plugins = Chartist.plugins || {};\n\tChartist.plugins.perfUnits = function(options) {\n\toptions = Chartist.extend({},{},options);\n\n    return function perfUnits(chart) {\n    \tif(!chart instanceof Chartist.Line) return;\n\t\t\t\n\t\tchart.on('created', function() {\n\t\t\tlog(\"running created\")\n\t\t\tconst vbits = document.getElementsByClassName(\"ct-vertical\");\n\t\t\tif(vbits==null) return;\n\n\t\t\tlet tbits = [];\n\t\t\tfor(let i=0; i<vbits.length; i++) {\n\t\t\t\ttbits[i] = vbits[i].innerHTML;\n\t\t\t}\n\t\t\tlog(\"tbits:\",tbits);\n\t\t\t\n\t\t\tconst calc = (places) => {\n\t\t\t\tif(places==3) return;\n\t\t\t\n\t\t\t\tconst matcher = vbits[0].innerHTML;\n\t\t\t\tlet allMatch = true;\n       \t\t\tfor(let i=0; i<tbits.length; i++) {\n\t\t\t\t\tlet val = convertPerfUnit(tbits[i], places);\n\t\t\t\t\tif(val!=matcher) allMatch = false;\n\t\t\t\t\tvbits[i].innerHTML = val;\n\t\t\t\t}\n\t\t\t\tif(allMatch) calc(places + 1);\n\t\t\t}\n\t\t\tcalc(0);\n       });\n    };\n  };\n}\n\nconst Kilobyte = 1024;\nconst Megabyte = Kilobyte * 1024;\nconst Gigabyte = Megabyte * 1024;\nconst Terabyte = Gigabyte * 1024;\nconst Petabyte = Terabyte * 1024;\n\nfunction convertByteUnit(bytes, places = 0) {\n\tlet o;\n\tif(bytes >= Petabyte) o = [bytes / Petabyte, \"PB\"];\n\telse if(bytes >= Terabyte) o = [bytes / Terabyte, \"TB\"];\n\telse if(bytes >= Gigabyte) o = [bytes / Gigabyte, \"GB\"];\n\telse if(bytes >= Megabyte) o = [bytes / Megabyte, \"MB\"];\n\telse if(bytes >= Kilobyte) o = [bytes / Kilobyte, \"KB\"];\n\telse o = [bytes,\"b\"];\n\n\tif(places==0) return Math.ceil(o[0]) + o[1];\n\telse {\n\t\tlet ex = Math.pow(10, places);\n\t\treturn (Math.round(o[0], ex) / ex) + o[1];\n\t}\n}\n\nlet ms = 1000;\nlet sec = ms * 1000;\nlet min = sec * 60;\nlet hour = min * 60;\nlet day = hour * 24;\nfunction convertPerfUnit(quan, places = 0) {\n\tlet o;\n\tif(quan >= day) o = [quan / day, \"d\"];\n\telse if(quan >= hour) o = [quan / hour, \"h\"];\n\telse if(quan >= min) o = [quan / min, \"m\"];\n\telse if(quan >= sec) o = [quan / sec, \"s\"];\n\telse if(quan >= ms) o = [quan / ms, \"ms\"];\n\telse o = [quan,\"μs\"];\n\n\tif(places==0) return Math.ceil(o[0]) + o[1];\n\telse {\n\t\tlet ex = Math.pow(10, places);\n\t\treturn (Math.round(o[0], ex) / ex) + o[1];\n\t}\n}\n\n// TODO: Fully localise this\n// TODO: Load rawLabels and seriesData dynamically rather than potentially fiddling with nonces for the CSP?\nfunction buildStatsChart(rawLabels, seriesData, timeRange, legendNames, typ=0) {\n\tlog(\"buildStatsChart\");\n\tlog(\"seriesData\",seriesData);\n\tlet labels = [];\n\tlet aphrases = phraseBox[\"analytics\"];\n\tif(timeRange==\"one-year\") {\n\t\tlabels = [aphrases[\"analytics.now\"],\"1\" + aphrases[\"analytics.months_short\"]];\n\t\tfor(let i = 2; i < 12; i++) {\n\t\t\tlabels.push(i + aphrases[\"analytics.months_short\"]);\n\t\t}\n\t} else if(timeRange==\"three-months\") {\n\t\tlabels = [aphrases[\"analytics.now\"],\"3\" + aphrases[\"analytics.days_short\"]]\n\t\tfor(let i = 6; i < 90; i = i + 3) {\n\t\t\tif (i%2==0) labels.push(\"\");\n\t\t\telse labels.push(i + aphrases[\"analytics.days_short\"]);\n\t\t}\n\t} else if(timeRange==\"one-month\") {\n\t\tlabels = [aphrases[\"analytics.now\"],\"1\" + aphrases[\"analytics.days_short\"]];\n\t\tfor(let i = 2; i < 30; i++) {\n\t\t\tif (i%2==0) labels.push(\"\");\n\t\t\telse labels.push(i + aphrases[\"analytics.days_short\"]);\n\t\t}\n\t} else if(timeRange==\"one-week\") {\n\t\tlabels = [aphrases[\"analytics.now\"]];\n\t\tfor(let i = 2; i < 14; i++) {\n\t\t\tif (i%2==0) labels.push(\"\");\n\t\t\telse labels.push(Math.floor(i/2) + aphrases[\"analytics.days\"]);\n\t\t}\n\t} else if (timeRange==\"two-days\" || timeRange == \"one-day\" || timeRange == \"twelve-hours\") {\n\t\tfor(const i in rawLabels) {\n\t\t\tif (i%2==0) {\n\t\t\t\tlabels.push(\"\");\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tlet date = new Date(rawLabels[i]*1000);\n\t\t\tlog(\"date\",date);\n\t\t\tlet minutes = \"0\" + date.getMinutes();\n\t\t\tlet label = date.getHours() + \":\" + minutes.substr(-2);\n\t\t\tlog(\"label\",label);\n\t\t\tlabels.push(label);\n\t\t}\n\t} else {\n\t\tfor(const i in rawLabels) {\n\t\t\tlet date = new Date(rawLabels[i]*1000);\n\t\t\tlog(\"date\",date);\n\t\t\tlet minutes = \"0\" + date.getMinutes();\n\t\t\tlet label = date.getHours() + \":\" + minutes.substr(-2);\n\t\t\tlog(\"label\",label);\n\t\t\tlabels.push(label);\n\t\t}\n\t}\n\tlabels = labels.reverse()\n\tfor(let i = 0; i < seriesData.length; i++) {\n\t\tseriesData[i] = seriesData[i].reverse();\n\t}\n\n\tlet config = {height: '250px', plugins:[]};\n\tif(legendNames.length > 0) config.plugins = [\n\t\tChartist.plugins.legend({legendNames: legendNames})\n\t\t];\n\tif(typ==1) config.plugins.push(Chartist.plugins.byteUnits());\n\telse if(typ==2) config.plugins.push(Chartist.plugins.perfUnits());\n\tChartist.Line('.ct_chart', {\n\t\tlabels: labels,\n\t\tseries: seriesData,\n\t}, config);\n}\n\nrunInitHook(\"analytics_loaded\");"
  },
  {
    "path": "public/chartist/chartist-plugin-legend.css",
    "content": ".ct-legend {\n    position: relative;\n           z-index: 10;\n           list-style: none;\n           text-align: center;\n       }\n       .ct-legend li {\n           position: relative;\n           padding-left: 23px;\n           margin-right: 10px;\n           margin-bottom: 3px;\n           cursor: pointer;\n           display: inline-block;\n       }\n       .ct-legend li:before {\n           width: 12px;\n           height: 12px;\n           position: absolute;\n           left: 0;\n           content: '';\n           border: 3px solid transparent;\n           border-radius: 2px;\n       }\n       .ct-legend li.inactive:before {\n           background: transparent;\n       }\n       .ct-legend.ct-legend-inside {\n           position: absolute;\n           top: 0;\n           right: 0;\n       }\n       .ct-legend.ct-legend-inside li{\n           display: block;\n           margin: 0;\n       }\n       .ct-legend .ct-series-0:before {\n           background-color: #d70206;\n           border-color: #d70206;\n       }\n       .ct-legend .ct-series-1:before {\n           background-color: #f05b4f;\n           border-color: #f05b4f;\n       }\n       .ct-legend .ct-series-2:before {\n           background-color: #f4c63d;\n           border-color: #f4c63d;\n       }\n       .ct-legend .ct-series-3:before {\n           background-color: #d17905;\n           border-color: #d17905;\n       }\n       .ct-legend .ct-series-4:before {\n           background-color: #453d3f;\n           border-color: #453d3f;\n       }\n       .ct-legend .ct-series-5:before {\n           background-color: #59922b;\n           border-color: #59922b;\n       }\n       .ct-legend .ct-series-6:before {\n           background-color: #0544d3;\n           border-color: #0544d3;\n       }\n\n       .ct-chart-line-multipleseries .ct-legend .ct-series-0:before {\n          background-color: #d70206;\n          border-color: #d70206;\n       }\n       .ct-chart-line-multipleseries .ct-legend .ct-series-1:before {\n          background-color: #f4c63d;\n          border-color: #f4c63d;\n       }\n       .ct-chart-line-multipleseries .ct-legend li.inactive:before {\n          background: transparent;\n        }"
  },
  {
    "path": "public/chartist/chartist.css",
    "content": ".ct-label {\n  fill: rgba(0, 0, 0, 0.4);\n  color: rgba(0, 0, 0, 0.4);\n  font-size: 0.75rem;\n  line-height: 1; }\n\n.ct-chart-line .ct-label,\n.ct-chart-bar .ct-label {\n  display: block;\n  display: -webkit-box;\n  display: -moz-box;\n  display: -ms-flexbox;\n  display: -webkit-flex;\n  display: flex; }\n\n.ct-chart-pie .ct-label,\n.ct-chart-donut .ct-label {\n  dominant-baseline: central; }\n\n.ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-vertical.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-label.ct-vertical.ct-end {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: end; }\n\n.ct-grid {\n  stroke: rgba(0, 0, 0, 0.2);\n  stroke-width: 1px;\n  stroke-dasharray: 2px; }\n\n.ct-grid-background {\n  fill: none; }\n\n.ct-point {\n  stroke-width: 10px;\n  stroke-linecap: round; }\n\n.ct-line {\n  fill: none;\n  stroke-width: 4px; }\n\n.ct-area {\n  stroke: none;\n  fill-opacity: 0.1; }\n\n.ct-bar {\n  fill: none;\n  stroke-width: 10px; }\n\n.ct-slice-donut {\n  fill: none;\n  stroke-width: 60px; }\n\n.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {\n  stroke: #d70206; }\n\n.ct-series-a .ct-slice-pie, .ct-series-a .ct-slice-donut-solid, .ct-series-a .ct-area {\n  fill: #d70206; }\n\n.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-b .ct-slice-pie, .ct-series-b .ct-slice-donut-solid, .ct-series-b .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {\n  stroke: #f4c63d; }\n\n.ct-series-c .ct-slice-pie, .ct-series-c .ct-slice-donut-solid, .ct-series-c .ct-area {\n  fill: #f4c63d; }\n\n.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {\n  stroke: #d17905; }\n\n.ct-series-d .ct-slice-pie, .ct-series-d .ct-slice-donut-solid, .ct-series-d .ct-area {\n  fill: #d17905; }\n\n.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {\n  stroke: #453d3f; }\n\n.ct-series-e .ct-slice-pie, .ct-series-e .ct-slice-donut-solid, .ct-series-e .ct-area {\n  fill: #453d3f; }\n\n.ct-series-f .ct-point, .ct-series-f .ct-line, .ct-series-f .ct-bar, .ct-series-f .ct-slice-donut {\n  stroke: #59922b; }\n\n.ct-series-f .ct-slice-pie, .ct-series-f .ct-slice-donut-solid, .ct-series-f .ct-area {\n  fill: #59922b; }\n\n.ct-series-g .ct-point, .ct-series-g .ct-line, .ct-series-g .ct-bar, .ct-series-g .ct-slice-donut {\n  stroke: #0544d3; }\n\n.ct-series-g .ct-slice-pie, .ct-series-g .ct-slice-donut-solid, .ct-series-g .ct-area {\n  fill: #0544d3; }\n\n.ct-series-h .ct-point, .ct-series-h .ct-line, .ct-series-h .ct-bar, .ct-series-h .ct-slice-donut {\n  stroke: #6b0392; }\n\n.ct-series-h .ct-slice-pie, .ct-series-h .ct-slice-donut-solid, .ct-series-h .ct-area {\n  fill: #6b0392; }\n\n.ct-series-i .ct-point, .ct-series-i .ct-line, .ct-series-i .ct-bar, .ct-series-i .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-i .ct-slice-pie, .ct-series-i .ct-slice-donut-solid, .ct-series-i .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-j .ct-point, .ct-series-j .ct-line, .ct-series-j .ct-bar, .ct-series-j .ct-slice-donut {\n  stroke: #dda458; }\n\n.ct-series-j .ct-slice-pie, .ct-series-j .ct-slice-donut-solid, .ct-series-j .ct-area {\n  fill: #dda458; }\n\n.ct-series-k .ct-point, .ct-series-k .ct-line, .ct-series-k .ct-bar, .ct-series-k .ct-slice-donut {\n  stroke: #eacf7d; }\n\n.ct-series-k .ct-slice-pie, .ct-series-k .ct-slice-donut-solid, .ct-series-k .ct-area {\n  fill: #eacf7d; }\n\n.ct-series-l .ct-point, .ct-series-l .ct-line, .ct-series-l .ct-bar, .ct-series-l .ct-slice-donut {\n  stroke: #86797d; }\n\n.ct-series-l .ct-slice-pie, .ct-series-l .ct-slice-donut-solid, .ct-series-l .ct-area {\n  fill: #86797d; }\n\n.ct-series-m .ct-point, .ct-series-m .ct-line, .ct-series-m .ct-bar, .ct-series-m .ct-slice-donut {\n  stroke: #b2c326; }\n\n.ct-series-m .ct-slice-pie, .ct-series-m .ct-slice-donut-solid, .ct-series-m .ct-area {\n  fill: #b2c326; }\n\n.ct-series-n .ct-point, .ct-series-n .ct-line, .ct-series-n .ct-bar, .ct-series-n .ct-slice-donut {\n  stroke: #6188e2; }\n\n.ct-series-n .ct-slice-pie, .ct-series-n .ct-slice-donut-solid, .ct-series-n .ct-area {\n  fill: #6188e2; }\n\n.ct-series-o .ct-point, .ct-series-o .ct-line, .ct-series-o .ct-bar, .ct-series-o .ct-slice-donut {\n  stroke: #a748ca; }\n\n.ct-series-o .ct-slice-pie, .ct-series-o .ct-slice-donut-solid, .ct-series-o .ct-area {\n  fill: #a748ca; }\n\n.ct-square {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-square:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 100%; }\n  .ct-square:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-square > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 93.75%; }\n  .ct-minor-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 88.8888888889%; }\n  .ct-major-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 83.3333333333%; }\n  .ct-minor-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 80%; }\n  .ct-major-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fourth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fourth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 75%; }\n  .ct-perfect-fourth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fourth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fifth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fifth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 66.6666666667%; }\n  .ct-perfect-fifth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fifth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 62.5%; }\n  .ct-minor-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-golden-section {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-golden-section:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 61.804697157%; }\n  .ct-golden-section:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-golden-section > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 60%; }\n  .ct-major-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 56.25%; }\n  .ct-minor-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 53.3333333333%; }\n  .ct-major-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 50%; }\n  .ct-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-tenth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-tenth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 40%; }\n  .ct-major-tenth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-tenth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-eleventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-eleventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 37.5%; }\n  .ct-major-eleventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-eleventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-twelfth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-twelfth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 33.3333333333%; }\n  .ct-major-twelfth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-twelfth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-double-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-double-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 25%; }\n  .ct-double-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-double-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n/*# sourceMappingURL=chartist.css.map */"
  },
  {
    "path": "public/convo.js",
    "content": "(() => {\n\taddInitHook(\"end_init\", () => {\n\t\t$(\".create_convo_link\").click((event) => {\n\t\t\tevent.preventDefault();\n\t\t\t$(\".convo_create_form\").removeClass(\"auto_hide\");\n\t\t});\n\t\t$(\".convo_create_form .close_form\").click((event) => {\n\t\t\tevent.preventDefault();\n\t\t\t$(\".convo_create_form\").addClass(\"auto_hide\");\n\t\t});\n\t});\n})();"
  },
  {
    "path": "public/global.js",
    "content": "'use strict';\nvar formVars={};\nvar alertMapping={};\nvar alertList=[];\nvar alertCount=0;\nvar moreTopicCount=0;\nvar conn=false;\nvar selectedTopics=[];\nvar attachItemCallback=()=>{}\nvar quoteItemCallback=()=>{}\nvar baseTitle=document.title;\nvar wsBackoff=0;\nvar noAlerts=false;\n\n// Topic move\nvar forumToMoveTo=0;\n\nfunction pushNotice(msg) {\n\tlet aBox = document.getElementsByClassName(\"alertbox\")[0];\n\tlet n = document.createElement('div');\n\tn.innerHTML = Tmpl_notice(msg).trim();\n\taBox.appendChild(n);\n\trunInitHook(\"after_notice\");\n}\n\n// TODO: Write a friendlier error handler which uses a .notice or something, we could have a specialised one for alerts\nfunction ajaxError(xhr,status,e) {\n\tlog(\"The AJAX request failed\");\n\tlog(\"xhr\",xhr);\n\tlog(\"status\",status);\n\tlog(\"e\",e);\n\tif(status==\"parsererror\") log(\"The server didn't respond with a valid JSON response\");\n\tconsole.trace();\n}\n\nfunction postLink(ev) {\n\tev.preventDefault();\n\tlet formAction = $(ev.target).closest('a').attr(\"href\");\n\t$.ajax({ url:formAction, type:\"POST\", dataType:\"json\", error: ajaxError, data: {js: 1} });\n}\n\nfunction bindToAlerts() {\n\tlog(\"bindToAlerts\");\n\t$(\".alertItem.withAvatar a\").unbind(\"click\");\n\t$(\".alertItem.withAvatar a\").click(function(ev) {\n\t\tev.stopPropagation();\n\t\tev.preventDefault();\n\t\t$.ajax({\n\t\t\turl: \"/api/?a=set&m=dismiss-alert\",\n\t\t\ttype: \"POST\",\n\t\t\tdataType: \"json\",\n\t\t\tdata: { id: $(this).attr(\"data-asid\") },\n\t\t\t//error: ajaxError,\n\t\t\tsuccess: () => {\n\t\t\t\twindow.location.href = this.getAttribute(\"href\");\n\t\t\t}\n\t\t});\n\t});\n}\n\nfunction addAlert(msg,notice=false) {\n\tvar mmsg = msg.msg;\n\tif(mmsg[0]==\".\") mmsg = phraseBox[\"alerts\"][\"alerts\"+mmsg];\n\tif(\"sub\" in msg) {\n\t\tfor(var i=0; i<msg.sub.length; i++) mmsg = mmsg.replace(\"\\{\"+i+\"\\}\",msg.sub[i]);\n\t}\n\n\tlet aItem = Tmpl_alert({\n\t\tASID: msg.id,\n\t\tPath: msg.path,\n\t\tAvatar: msg.img || \"\",\n\t\tMessage: mmsg\n\t})\n\t//alertMapping[msg.id] = aItem;\n\tlet div = document.createElement('div');\n\tdiv.innerHTML = aItem.trim();\n\talertMapping[msg.id] = div.firstChild;\n\talertList.push(msg.id);\n\n\tif(notice) {\n\t\t// TODO: Add some sort of notification queue to avoid flooding the end-user with notices?\n\t\t// TODO: Use the site name instead of \"Something Happened\"\n\t\tif(Notification.permission===\"granted\") {\n\t\t\tvar n = new Notification(\"Something Happened\",{\n\t\t\t\tbody: mmsg,\n\t\t\t\ticon: msg.img,\n\t\t\t});\n\t\t\tsetTimeout(n.close.bind(n),8000);\n\t\t}\n\t}\n\n\trunInitHook(\"after_add_alert\");\n}\n\nfunction updateAlertList(menuAlerts) {\n\tlog(\"enter updateAlertList\");\n\tlog(\"alertList:\",alertList);\n\tlog(\"alertMapping:\",alertMapping);\n\tlog(\"alertCount:\",alertCount);\n\tlet alertListNode = menuAlerts.getElementsByClassName(\"alertList\")[0];\n\tlet alertCounterNode = menuAlerts.getElementsByClassName(\"alert_counter\")[0];\n\talertCounterNode.textContent = \"0\";\n\t\n\talertListNode.innerHTML = \"\";\n\tlet any = false;\n\tlet j = 0;\n\tfor(var i=0; i<alertList.length && j<8; i++) {\n\t\tany = true;\n\t\talertListNode.appendChild(alertMapping[alertList[i]]);\n\t\t//outList += alertMapping[alertList[i]];\n\t\tj++;\n\t}\n\tif(!any) alertListNode.innerHTML = \"<div class='alertItem'>\"+phraseBox[\"alerts\"][\"alerts.no_alerts\"]+\"</div>\";\n\n\tif(alertCount!=0) {\n\t\talertCounterNode.textContent = alertCount;\n\t\tmenuAlerts.classList.add(\"has_alerts\");\n\t\tlet nTitle = \"(\"+alertCount+\") \"+baseTitle;\n\t\tif(document.title!=nTitle) document.title = nTitle;\n\t} else {\n\t\tmenuAlerts.classList.remove(\"has_alerts\");\n\t\tif(document.title!=baseTitle) document.title = baseTitle;\n\t}\n\n\tbindToAlerts();\n\tlog(\"alertCount\",alertCount)\n\trunInitHook(\"after_update_alert_list\",alertCount);\n}\n\nfunction setAlertError(menuAlerts,msg) {\n\tlet n = menuAlerts.getElementsByClassName(\"alertList\")[0];\n\tn.innerHTML = \"<div class='alertItem'>\"+msg+\"</div>\";\n}\n\nvar alertsInitted = false;\nvar lastTc = 0;\nfunction loadAlerts(menuAlerts,eTc=false) {\n\tif(!alertsInitted) return;\n\tlet tc = \"\";\n\tif(eTc && lastTc!=0) tc = \"&t=\"+lastTc+\"&c=\"+alertCount;\n\t$.ajax({\n\t\ttype:'get',\n\t\tdataType:'json',\n\t\turl:'/api/?m=alerts'+tc,\n\t\tsuccess: data => {\n\t\t\tif(\"errmsg\" in data) {\n\t\t\t\tsetAlertError(menuAlerts,data.errmsg)\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif(!eTc) {\n\t\t\t\talertList=[];\n\t\t\t\talertMapping={};\n\t\t\t}\n\t\t\tif(!data.hasOwnProperty(\"msgs\")) data = {\"msgs\":[],\"count\":alertCount,\"tc\":lastTc};\n\t\t\t/*else if(data.count != (alertCount + data.msgs.length)) tc = false;\n\t\t\tif(eTc && lastTc!=0) {\n\t\t\t\tfor(var i in data.msgs) wsAlertEvent(data.msgs[i]);\n\t\t\t} else {*/\n\t\t\tlog(\"data\",data);\n\t\t\tfor(var i in data.msgs) addAlert(data.msgs[i]);\n\t\t\talertCount = data.count;\n\t\t\tupdateAlertList(menuAlerts);\n\t\t\ttry {\n\t\t\t\tlocalStorage.setItem(\"alertList\",JSON.stringify(alertList));\n\t\t\t\tlocalStorage.setItem(\"alertMapping\",JSON.stringify(alertMapping));\n\t\t\t\tlocalStorage.setItem(\"alertCount\",alertCount);\n\t\t\t} catch(e) {\n\t\t\t\tlocalStorage.clear();\n\t\t\t}\n\t\t\t//}\n\t\t\tlastTc = data.tc;\n\t\t},\n\t\terror: (magic,status,er) => {\n\t\t\tlet errtxt = \"Unable to get the alerts\";\n\t\t\ttry {\n\t\t\t\tlet dat = JSON.parse(magic.responseText);\n\t\t\t\tif(\"errmsg\" in dat) errtxt = dat.errmsg;\n\t\t\t} catch(e) {\n\t\t\t\tlog(magic.responseText);\n\t\t\t\tlog(e);\n\t\t\t}\n\t\t\tlog(\"er\",er);\n\t\t\tsetAlertError(menuAlerts,errtxt);\n\t\t}\n\t});\n}\n\nfunction SplitN(data,ch,n) {\n\tvar o = [];\n\tif(data.length===0) return o;\n\n\tvar lastI = 0;\n\tvar j = 0;\n\tvar lastN = 1;\n\tfor(let i=0; i<data.length; i++) {\n\t\tif(data[i]===ch) {\n\t\t\to[j++] = data.substring(lastI,i);\n\t\t\tlastI = i;\n\t\t\tif(lastN===n) break;\n\t\t\tlastN++;\n\t\t}\n\t}\n\tif(data.length > lastI) o[o.length-1] += data.substring(lastI);\n\treturn o;\n}\n\nfunction wsAlertEvent(dat) {\n\tlog(\"wsAlertEvent\",dat)\n\taddAlert(dat,true);\n\talertCount++;\n\n\tlet aTmp = alertList;\n\talertList = [alertList[alertList.length-1]];\n\taTmp = aTmp.slice(0,-1);\n\tfor(let i=0; i<aTmp.length; i++) alertList.push(aTmp[i]);\n\t// TODO: Add support for other alert feeds like PM Alerts\n\tlet n = document.getElementById(\"general_alerts\");\n\t// TODO: Make sure we update alertCount here\n\tlastTc = 0;\n\tupdateAlertList(n/*,alist*/);\n}\n\nfunction runWebSockets(resume=false) {\n\tlet s = \"\";\n\tif(window.location.protocol == \"https:\") s = \"s\";\n\tconn = new WebSocket(\"ws\"+s+\"://\" + document.location.host + \"/ws/\");\n\n\tconn.onerror = e => {\n\t\tlog(e);\n\t}\n\n\t// TODO: Sync alerts, topic list, etc.\n\tconn.onopen = () => {\n\t\tlog(\"The WebSockets connection was opened\");\n\t\tif(resume) conn.send(\"resume \" + document.location.pathname + \" \" + Math.round((new Date()).getTime() / 1000) + '\\r');\n\t\telse conn.send(\"page \" + document.location.pathname + '\\r');\n\t\t// TODO: Don't ask again, if it's denied. We could have a setting in the UCP which automatically requests this when someone flips desktop notifications on\n\t\tif(me.User.ID > 0) Notification.requestPermission();\n\t}\n\n\tconn.onclose = () => {\n\t\tconn = false;\n\t\tlog(\"The WebSockets connection was closed\");\n\t\tlet backoff = 0.8;\n\t\tif(wsBackoff < 0) wsBackoff = 0;\n\t\telse if(wsBackoff > 12) backoff = 11;\n\t\telse if(wsBackoff > 5) backoff = 5;\n\t\twsBackoff++;\n\n\t\tsetTimeout(() => {\n\t\t\tif(!noAlerts) {\n\t\t\t\tlet nl = document.getElementsByClassName(\"menu_alerts\");\n\t\t\t\tfor(var i=0; i < nl.length; i++) loadAlerts(nl[i],true);\n\t\t\t}\n\t\t\trunWebSockets(true);\n\t\t}, backoff * 60 * 1000);\n\n\t\tif(wsBackoff > 0) {\n\t\t\tif(wsBackoff <= 5) setTimeout(() => wsBackoff--, 5.5 * 60 * 1000);\n\t\t\telse if(wsBackoff <= 12) setTimeout(() => wsBackoff--, 11.5 * 60 * 1000);\n\t\t\telse setTimeout(() => wsBackoff--, 20 * 60 * 1000);\n\t\t}\n\t}\n\n\tconn.onmessage = (event) => {\n\t\tif(!noAlerts && event.data[0] == \"{\") {\n\t\t\tlog(\"json message\");\n\t\t\tlet data = \"\";\n\t\t\ttry {\n\t\t\t\tdata = JSON.parse(event.data);\n\t\t\t} catch(e) {\n\t\t\t\tlog(e);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif(\"msg\" in data) wsAlertEvent(data);\n\t\t\telse if(\"event\" in data) {\n\t\t\t\tif(data.event==\"dismiss-alert\"){\n\t\t\t\t\tObject.keys(alertMapping).forEach((key) => {\n\t\t\t\t\t\tif(key!=data.id) return;\n\t\t\t\t\t\talertCount--;\n\t\t\t\t\t\tlet index = -1;\n\t\t\t\t\t\tfor(var i=0; i < alertList.length; i++) {\n\t\t\t\t\t\t\tif(alertList[i]==key) {\n\t\t\t\t\t\t\t\talertList[i] = 0;\n\t\t\t\t\t\t\t\tindex = i;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif(index==-1) return;\n\n\t\t\t\t\t\tfor(var i = index; (i+1) < alertList.length; i++) alertList[i] = alertList[i+1];\n\t\t\t\t\t\talertList.splice(alertList.length-1,1);\n\t\t\t\t\t\tdelete alertMapping[key];\n\n\t\t\t\t\t\t// TODO: Add support for other alert feeds like PM Alerts\n\t\t\t\t\t\tlet generalAlerts = document.getElementById(\"general_alerts\");\n\t\t\t\t\t\tif(alertList.length < 8) loadAlerts(generalAlerts,true);\n\t\t\t\t\t\telse updateAlertList(generalAlerts);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else if(\"Topics\" in data) {\n\t\t\t\tlog(\"topic in data\");\n\t\t\t\tlog(\"data\",data);\n\t\t\t\t// TODO: Handle desyncs more gracefully?\n\t\t\t\t// TODO: Send less unneccessary data?\n\t\t\t\tlet topic = data.Topics[0];\n\t\t\t\tif(topic===undefined){\n\t\t\t\t\tlog(\"empty topic list\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif(\"mod\" in data) {\n\t\t\t\t\ttopic.CanMod = data.mod==1 || data.mod[0]==1;\n\t\t\t\t\tif(data.lock==1) {\n\t\t\t\t\t\t$(\".val_lock\").each(function(){\n\t\t\t\t\t\t\tthis.classList.remove(\"auto_hide\");\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\tif(data.move==1) {\n\t\t\t\t\t\t$(\".val_move\").each(function(){\n\t\t\t\t\t\t\tthis.classList.remove(\"auto_hide\");\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// TODO: Fix the data race where the function hasn't been loaded yet\n\t\t\t\tlet renTopic = Tmpl_topics_topic(topic);\n\t\t\t\t$(\".topic_row[data-tid='\"+topic.ID+\"']\").addClass(\"ajax_topic_dupe\");\n\n\t\t\t\tlet node = $(renTopic);\n\t\t\t\tnode.addClass(\"new_item hide_ajax_topic\");\n\t\t\t\tlog(\"Prepending to topic list\");\n\t\t\t\t$(\".topic_list\").prepend(node);\n\t\t\t\tmoreTopicCount++;\n\n\t\t\t\tlet blocks = document.getElementsByClassName(\"more_topic_block_initial\");\n\t\t\t\tfor(let i=0; i<blocks.length; i++) {\n\t\t\t\t\tlet block = blocks[i];\n\t\t\t\t\tblock.classList.remove(\"more_topic_block_initial\");\n\t\t\t\t\tblock.classList.add(\"more_topic_block_active\");\n\n\t\t\t\t\tlog(\"phraseBox\",phraseBox);\n\t\t\t\t\tlet msgBox = block.getElementsByClassName(\"more_topics\")[0];\n\t\t\t\t\tmsgBox.innerText = phraseBox[\"topic_list\"][\"topic_list.changed_topics\"].replace(\"%d\",moreTopicCount);\n\t\t\t\t}\n\t\t\t} else log(\"unknown message\",data);\n\t\t}\n\n\t\tlet messages = event.data.split('\\r');\n\t\tfor(var i=0; i<messages.length; i++) {\n\t\t\tlet message = messages[i];\n\t\t\t//log(\"message\",message);\n\t\t\tlet msgblocks = SplitN(message,\" \",3);\n\t\t\tif(msgblocks.length < 3) continue;\n\t\t\tif(message.startsWith(\"set \")) {\n\t\t\t\tlet oldInnerHTML = document.querySelector(msgblocks[1]).innerHTML;\n\t\t\t\tif(msgblocks[2]==oldInnerHTML) continue;\n\t\t\t\tdocument.querySelector(msgblocks[1]).innerHTML = msgblocks[2];\n\t\t\t} else if(message.startsWith(\"set-class \")) {\n\t\t\t\t// Fix to stop the inspector from getting all jittery\n\t\t\t\tlet oldClassName = document.querySelector(msgblocks[1]).className;\n\t\t\t\tif(msgblocks[2]==oldClassName) continue;\n\t\t\t\tdocument.querySelector(msgblocks[1]).className = msgblocks[2];\n\t\t\t}\n\t\t}\n\t}\n}\n\n// TODO: Surely, there's a prettier and more elegant way of doing this?\nfunction getExt(name) {\n\tif(!(name.indexOf('.') > -1)) throw(\"This file doesn't have an extension\");\n\treturn name.split('.').pop();\n}\n\n(() => {\n\taddInitHook(\"pre_init\", () => {\n\t\trunInitHook(\"pre_global\");\n\t\tlog(\"before notify on alert\")\n\t\t// We can only get away with this because template_alert has no phrases, otherwise it too would have to be part of the \"dance\", I miss Go concurrency :(\n\t\tlog(\"noAlerts:\",noAlerts);\n\t\tif(!noAlerts) {\n\t\tnotifyOnScriptW(\"tmpl_alert\", e => {\n\t\t\tif(e!=undefined) log(\"failed alert? why?\",e)\n\t\t}, () => {\n\t\t\tif(!Tmpl_alert) throw(\"tmpl func not found\");\n\t\t\taddInitHook(\"after_phrases\", () => {\n\t\t\t\t// TODO: The load part of loadAlerts could be done asynchronously while the update of the DOM could be deferred\n\t\t\t\t$(document).ready(() => {\n\t\t\t\t\tlog(\"checking local storage cache\");\n\t\t\t\t\talertsInitted = true;\n\t\t\t\t\tlet al = document.getElementsByClassName(\"menu_alerts\");\n\t\t\t\t\tlet sAlertList = localStorage.getItem(\"alertList\");\n\t\t\t\t\tlet sAlertMapping = localStorage.getItem(\"alertMapping\");\n\t\t\t\t\tlet sAlertCount = localStorage.getItem(\"alertCount\");\n\t\t\t\t\tif(sAlertList!=null && sAlertList!=\"\" &&\n\t\t\t\t\t\tsAlertMapping!=null && sAlertMapping!=\"\" &&sAlertCount!=null && sAlertCount!=\"\" && sAlertCount!=\"0\"\n\t\t\t\t\t) {\n\t\t\t\t\t\tlog(\"sAlertList\",sAlertList)\n\t\t\t\t\t\tlog(\"sAlertMapping\",sAlertMapping)\n\t\t\t\t\t\tlog(\"sAlertCount\",sAlertCount)\n\t\t\t\t\t\talertList = JSON.parse(sAlertList)\n\t\t\t\t\t\talertMapping = JSON.parse(sAlertMapping)\n\t\t\t\t\t\talertCount =  parseInt(sAlertCount)\n\t\t\t\t\t\tlog(\"alertList\",alertList)\n\t\t\t\t\t\tlog(\"alertMapping\",alertMapping)\n\t\t\t\t\t\tlog(\"alertCount\",alertCount)\n\t\t\t\t\t\tfor(var i=0; i<al.length; i++) loadAlerts(al[i],true);\n\t\t\t\t\t} else for(var i=0; i<al.length; i++) loadAlerts(al[i]);\n\t\t\t\t\tif(window[\"WebSocket\"]) runWebSockets();\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\t\t} else {\n\t\t\taddInitHook(\"after_phrases\", () => {\n\t\t\t\t$(document).ready(() => {\n\t\t\t\t\tif(window[\"WebSocket\"]) runWebSockets();\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\n\t\t$(document).ready(mainInit);\n\t});\n})();\n\n// TODO: Use these in .filter_item and pass back an item count from the backend to work with here\n// Ported from common/parser.go\nfunction PageOffset(count,page,perPage) {\n\tlet offset = 0;\n\tlet lastPage = LastPage(count, perPage)\n\tif(page > 1) offset = (perPage * page) - perPage;\n\telse if (page == -1) {\n\t\tpage = lastPage;\n\t\toffset = (perPage * page) - perPage;\n\t} else page = 1;\n\n\t// We don't want the offset to overflow the slices, if everything's in memory\n\t//if(offset >= (count - 1)) offset = 0;\n\treturn {Offset:offset,Page:page,LastPage:lastPage};\n}\nfunction LastPage(count,perPage) {\n\treturn (count / perPage) + 1\n}\nfunction Paginate(currentPage,lastPage,maxPages) {\n\tlet diff = lastPage - currentPage;\n\tlet pre = 3;\n\tif(diff < 3) pre = maxPages - diff;\n\t\n\tlet page = currentPage - pre;\n\tif(page < 0) page = 0;\n\tlet o = [];\n\twhile(o.length < maxPages && page < lastPage){\n\t\tpage++;\n\t\to.push(page);\n\t}\n\treturn o;\n}\n\nfunction mainInit(){\n\tlog(\"enter mainInit\");\n\trunInitHook(\"start_init\");\n\n\t$(\".more_topics\").click(ev => {\n\t\tev.preventDefault();\n\t\tlet blocks = document.getElementsByClassName(\"more_topic_block_active\");\n\t\tfor(let i=0; i<blocks.length; i++) {\n\t\t\tlet bl = blocks[i];\n\t\t\tbl.classList.remove(\"more_topic_block_active\");\n\t\t\tbl.classList.add(\"more_topic_block_initial\");\n\t\t}\n\t\t$(\".ajax_topic_dupe\").fadeOut(\"slow\", function(){\n\t\t\t$(this).remove();\n\t\t});\n\t\t$(\".hide_ajax_topic\").removeClass(\"hide_ajax_topic\"); // TODO: Do Fade\n\t\tmoreTopicCount = 0;\n\t})\n\n\t$(\".add_like,.remove_like\").click(function(ev) {\n\t\tev.preventDefault();\n\t\t//$(this).unbind(\"click\");\n\t\tlet target = this.closest(\"a\").getAttribute(\"href\");\n\t\tlog(\"target\",target);\n\n\t\tlet controls = this.closest(\".controls\");\n\t\tlet hadLikes = controls.classList.contains(\"has_likes\");\n\t\tlet likeCountNode = controls.getElementsByClassName(\"like_count\")[0];\n\t\tlog(\"likeCountNode\",likeCountNode);\n\t\tif(this.classList.contains(\"add_like\")) {\n\t\t\tthis.classList.remove(\"add_like\");\n\t\t\tthis.classList.add(\"remove_like\");\n\t\t\tif(!hadLikes) controls.classList.add(\"has_likes\");\n\t\t\tthis.closest(\"a\").setAttribute(\"href\",target.replace(\"like\",\"unlike\"));\n\t\t\tlikeCountNode.innerHTML = parseInt(likeCountNode.innerHTML) + 1;\n\t\t} else {\n\t\t\tthis.classList.remove(\"remove_like\");\n\t\t\tthis.classList.add(\"add_like\");\n\t\t\tlet likeCount = parseInt(likeCountNode.innerHTML);\n\t\t\tif(likeCount==1) controls.classList.remove(\"has_likes\");\n\t\t\tthis.closest(\"a\").setAttribute(\"href\",target.replace(\"unlike\",\"like\"));\n\t\t\tlikeCountNode.innerHTML = parseInt(likeCountNode.innerHTML) - 1;\n\t\t}\n\n\t\t//let likeButton = this;\n\t\t$.ajax({\n\t\t\turl:target,\n\t\t\ttype:\"POST\",\n\t\t\tdataType:\"json\",\n\t\t\tdata: { js: 1 },\n\t\t\terror: ajaxError,\n\t\t\tsuccess: function (dat,status,xhr) {\n\t\t\t\tif(\"success\" in dat && dat[\"success\"] == \"1\") return;\n\t\t\t\t// addNotice(\"Failed to add a like: {err}\")\n\t\t\t\t//likeCountNode.innerHTML = parseInt(likeCountNode.innerHTML)-1;\n\t\t\t\tlog(\"data\",dat);\n\t\t\t\tlog(\"status\",status);\n\t\t\t\tlog(\"xhr\",xhr);\n\t\t\t}\n\t\t});\n\t});\n\n\t$(\".link_label\").click(function(ev) {\n\t\tev.preventDefault();\n\t\tlet linkSel = $('#'+$(this).attr(\"data-for\"));\n\t\tif(!linkSel.hasClass(\"link_opened\")) {\n\t\t\tev.stopPropagation();\n\t\t\tlinkSel.addClass(\"link_opened\");\n\t\t}\n\t});\n\n\tfunction rebuildPaginator(lastPage) {\n\t\tlet urlParams = new URLSearchParams(window.location.search);\n\t\tlet page = urlParams.get('page');\n\t\tif(page==\"\") page = 1;\n\n\t\tlet pageList = Paginate(page,lastPage,5)\n\t\t//$(\".pageset\").html(Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage}));\n\t\tlet ok = false;\n\t\t$(\".pageset\").each(function(){\n\t\t\tthis.outerHTML = Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage});\n\t\t\tok = true;\n\t\t});\n\t\tif(!ok) $(Tmpl_paginator({PageList:pageList,Page:page,LastPage:lastPage})).insertAfter(\"#topic_list\");\n\t}\n\n\tfunction rebindPaginator() {\n\t\t// TODO: Take mostviewed into account\n\t\t// TODO: Get this to work with topics too\n\t\t$(\".pageitem a\").unbind(\"click\");\n\t\t$(\".pageitem a\").click(function(ev) {\n\t\t\tev.preventDefault();\n\t\t\tlet url = \"//\"+window.location.host+window.location.pathname;\n\t\t\tlet urlParams = new URLSearchParams(window.location.search);\n\t\t\turlParams.set(\"page\",new URLSearchParams(this.getAttribute(\"href\")).get(\"page\"));\n\t\t\tlet q = \"?\";\n\t\t\tfor(let item of urlParams.entries()) q += item[0]+\"=\"+item[1]+\"&\";\n\t\t\tif(q.length>1) q = q.slice(0,-1);\n\n\t\t\t// TODO: Try to de-duplicate some of these fetch calls\n\t\t\tfetch(url+q+\"&js=1\",{credentials:\"same-origin\"})\n\t\t\t\t.then(r => {\n\t\t\t\t\tif(!r.ok) throw(url+q+\"&js=1 failed to load\");\n\t\t\t\t\treturn r.json();\n\t\t\t\t}).then(d => {\n\t\t\t\t\tif(!\"Topics\" in d) throw(\"no Topics in data\");\n\t\t\t\t\tlet topics = d[\"Topics\"];\n\t\t\t\t\tlog(\"ajax navigated to different page\");\n\n\t\t\t\t\t// TODO: Fix the data race where the function hasn't been loaded yet\n\t\t\t\t\tlet out = \"\";\n\t\t\t\t\tfor(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);\n\t\t\t\t\t$(\".topic_list\").html(out);\n\n\t\t\t\t\tlet obj = {Title:document.title,Url:url+q};\n\t\t\t\t\thistory.pushState(obj,obj.Title,obj.Url);\n\t\t\t\t\trebuildPaginator(d.LastPage);\n\t\t\t\t\trebindPaginator();\n\t\t\t\t}).catch(e => {\n\t\t\t\t\tlog(\"Unable to get script \"+url+q+\"&js=1\",e);\n\t\t\t\t\tconsole.trace();\n\t\t\t\t});\n\t\t});\n\t}\n\n\t// TODO: Render a headless topics.html instead of the individual topic rows and a bit of JS glue\n\t$(\".filter_item\").click(function(ev) {\n\t\tif(!window.location.pathname.startsWith(\"/topics/\")) return\n\t\tev.preventDefault();\n\t\tlet that = this;\n\t\tlet fid = this.getAttribute(\"data-fid\");\n\t\t// TODO: Take mostviewed into account\n\t\tlet url = \"//\"+window.location.host+\"/topics/?fids=\"+fid;\n\n\t\tfetch(url+\"&js=1\",{credentials:\"same-origin\"})\n\t\t.then(r => {\n\t\t\tif(!r.ok) throw(url+\"&js=1 failed to load\");\n\t\t\treturn r.json();\n\t\t}).then(d => {\n\t\t\tlog(\"data\",d);\n\t\t\tif(!\"Topics\" in d) throw(\"no Topics in data\");\n\t\t\tlet topics = d[\"Topics\"];\n\t\t\tlog(\"ajax navigated to \"+that.innerText);\n\t\t\t\n\t\t\t// TODO: Fix the data race where the function hasn't been loaded yet\n\t\t\tlet out = \"\";\n\t\t\tfor(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);\n\t\t\t$(\".topic_list\").html(out);\n\t\t\t//$(\".topic_list\").addClass(\"single_forum\");\n\n\t\t\tbaseTitle = that.innerText;\n\t\t\tif(alertCount > 0) document.title = \"(\"+alertCount+\") \"+baseTitle;\n\t\t\telse document.title = baseTitle;\n\t\t\tlet obj = {Title:document.title,Url:url};\n\t\t\thistory.pushState(obj,obj.Title,obj.Url);\n\t\t\trebuildPaginator(d.LastPage)\n\t\t\trebindPaginator();\n\n\t\t\t$(\".filter_item\").each(function(){\n\t\t\t\tthis.classList.remove(\"filter_selected\");\n\t\t\t});\n\t\t\tthat.classList.add(\"filter_selected\");\n\t\t\t$(\".topic_list_title h1\").text(that.innerText);\n\t\t\t$(\".link_select .link_option .link_recent\").attr(\"href\",\"//\"+window.location.host+\"/topics/?fids=\"+fid);\n\t\t\t$(\".link_select .link_option .link_most_viewed\").attr(\"href\",\"//\"+window.location.host+\"/topics/most-viewed/?fids=\"+fid);\n\t\t\tunbindPage();\n\t\t\tbindPage();\n\t\t}).catch(e => {\n\t\t\tlog(\"Unable to get script \"+url+\"&js=1\",e);\n\t\t\tconsole.trace();\n\t\t});\n\t});\n\n\tif(document.getElementById(\"topicsItemList\")!==null) rebindPaginator();\n\tif(document.getElementById(\"forumItemList\")!==null) rebindPaginator();\n\n\t// TODO: Show a search button when JS is disabled?\n\t$(\".widget_search_input\").keypress(function(e) {\n\t\tif(e.keyCode!='13') return;\n\t\t// TODO: Only fire on /topics/\n\t\tevent.preventDefault();\n\t\t// TODO: Take mostviewed into account\n\t\tlet url = \"//\"+window.location.host+window.location.pathname;\n\t\tlet urlParams = new URLSearchParams(window.location.search);\n\t\turlParams.set(\"q\",this.value);\n\t\tlet q = \"?\";\n\t\tfor(let item of urlParams.entries()) q += item[0]+\"=\"+item[1]+\"&\";\n\t\tif(q.length>1) q = q.slice(0,-1);\n\n\t\t// TODO: Try to de-duplicate some of these fetch calls\n\t\tfetch(url+q+\"&js=1\",{credentials:\"same-origin\"})\n\t\t\t.then(r => {\n\t\t\t\tif(!r.ok) throw(url+q+\"&js=1 failed to load\");\n\t\t\t\treturn r.json();\n\t\t\t}).then(d => {\n\t\t\t\tif(!\"Topics\" in d) throw(\"no Topics in data\");\n\t\t\t\tlet topics = d[\"Topics\"];\n\t\t\t\tlog(\"ajax navigated to search page\");\n\n\t\t\t\t// TODO: Fix the data race where the function hasn't been loaded yet\n\t\t\t\tlet out = \"\";\n\t\t\t\tfor(let i=0;i<topics.length;i++) out += Tmpl_topics_topic(topics[i]);\n\t\t\t\t$(\".topic_list\").html(out);\n\n\t\t\t\tbaseTitle = phraseBox[\"topic_list\"][\"topic_list.search_head\"];\n\t\t\t\t$(\".topic_list_title h1\").text(phraseBox[\"topic_list\"][\"topic_list.search_head\"]);\n\t\t\t\tif(alertCount > 0) document.title = \"(\"+alertCount+\") \"+baseTitle;\n\t\t\t\telse document.title = baseTitle;\n\t\t\t\tlet obj = {Title: document.title, Url: url+q};\n\t\t\t\thistory.pushState(obj,obj.Title,obj.Url);\n\t\t\t\trebuildPaginator(d.LastPage);\n\t\t\t\trebindPaginator();\n\t\t\t\t$(\".link_select .link_option .link_recent\").attr(\"href\",url+q);\n\t\t\t\t$(\".link_select .link_option .link_most_viewed\").attr(\"href\",url+q);\n\t\t}).catch(e => {\n\t\t\tlog(\"Unable to get script \"+url+q+\"&js=1\",e);\n\t\t\tconsole.trace();\n\t\t});\n\t});\n\n\trunInitHook(\"before_init_bind_page\");\n\tbindPage();\n\trunInitHook(\"after_init_bind_page\");\n\n\t$(\".edit_field\").click(function(ev) {\n\t\tev.preventDefault();\n\t\tlet bp = $(this).closest('.editable_parent');\n\t\tlet block = bp.find('.editable_block').eq(0);\n\t\tblock.html(\"<input name='edit_field'value='\"+block.text()+\"'type='text'><a href='\"+$(this).closest('a').attr(\"href\")+\"'><button class='submit_edit'type='submit'>Update</button></a>\");\n\n\t\t$(\".submit_edit\").click(function(ev) {\n\t\t\tev.preventDefault();\n\t\t\tlet bp = $(this).closest('.editable_parent');\n\t\t\tlet bl = bp.find('.editable_block').eq(0);\n\t\t\tlet content = bl.find('input').eq(0).val();\n\t\t\tbl.html(content);\n\n\t\t\tlet formAction = $(this).closest('a').attr(\"href\");\n\t\t\t$.ajax({\n\t\t\t\turl: formAction+\"?s=\"+me.User.S,\n\t\t\t\ttype:\"POST\",\n\t\t\t\tdataType:\"json\",\n\t\t\t\terror: ajaxError,\n\t\t\t\tdata: { js: 1, edit_item: content }\n\t\t\t});\n\t\t});\n\t});\n\n\t$(\".edit_fields\").click(function(ev) {\n\t\tev.preventDefault();\n\t\tif($(this).find(\"input\").length!==0) return;\n\t\t//log(\"clicked .edit_fields\");\n\t\tvar bp = $(this).closest('.editable_parent');\n\t\tbp.find('.hide_on_edit').addClass(\"edit_opened\");\n\t\tbp.find('.show_on_edit').addClass(\"edit_opened\");\n\t\tbp.find('.editable_block').show();\n\t\tbp.find('.editable_block').each(function(){\n\t\t\tvar fieldName = this.getAttribute(\"data-field\");\n\t\t\tvar fieldType = this.getAttribute(\"data-type\");\n\t\t\tif(fieldType==\"list\") {\n\t\t\t\tvar fieldValue = this.getAttribute(\"data-value\");\n\t\t\t\tif(fieldName in formVars) var it = formVars[fieldName];\n\t\t\t\telse var it = ['No','Yes'];\n\t\t\t\tvar itLen = it.length;\n\t\t\t\tvar out = \"\";\n\t\t\t\tfor (var i=0; i<itLen; i++) {\n\t\t\t\t\tvar sel = \"\";\n\t\t\t\t\tif(fieldValue == i || fieldValue == it[i]) {\n\t\t\t\t\t\tsel = \"selected \";\n\t\t\t\t\t\tthis.classList.remove(fieldName+'_'+it[i]);\n\t\t\t\t\t\tthis.innerHTML = \"\";\n\t\t\t\t\t}\n\t\t\t\t\tout += \"<option \"+sel+\"value='\"+i+\"'>\"+it[i]+\"</option>\";\n\t\t\t\t}\n\t\t\t\tthis.innerHTML = \"<select data-field='\"+fieldName+\"'name='\"+fieldName+\"'>\"+out+\"</select>\";\n\t\t\t}\n\t\t\telse if(fieldType==\"hidden\") {}\n\t\t\telse this.innerHTML = \"<input name='\"+fieldName+\"'value='\"+this.textContent+\"'type='text'>\";\n\t\t});\n\n\t\t// Remove any handlers already attached to the submitter\n\t\t$(\".submit_edit\").unbind(\"click\");\n\n\t\t$(\".submit_edit\").click(function(ev) {\n\t\t\tev.preventDefault();\n\t\t\tvar outData = {js: 1}\n\t\t\tvar bp = $(this).closest('.editable_parent');\n\t\t\tbp.find('.editable_block').each(function() {\n\t\t\t\tvar fieldName = this.getAttribute(\"data-field\");\n\t\t\t\tvar fieldType = this.getAttribute(\"data-type\");\n\t\t\t\tif(fieldType==\"list\") {\n\t\t\t\t\tvar newContent = $(this).find('select :selected').text();\n\t\t\t\t\tthis.classList.add(fieldName+'_'+newContent);\n\t\t\t\t\tthis.innerHTML = \"\";\n\t\t\t\t} else if(fieldType==\"hidden\") {\n\t\t\t\t\tvar newContent = $(this).val();\n\t\t\t\t} else {\n\t\t\t\t\tvar newContent = $(this).find('input').eq(0).val();\n\t\t\t\t\tthis.innerHTML = newContent;\n\t\t\t\t}\n\t\t\t\tthis.setAttribute(\"data-value\",newContent);\n\t\t\t\toutData[fieldName] = newContent;\n\t\t\t});\n\n\t\t\tlet href = $(this).closest('a').attr(\"href\");\n\t\t\t//log(\"href\",href);\n\t\t\t//log(outData);\n\t\t\t$.ajax({ url: href+\"?s=\"+me.User.S, type:\"POST\", dataType:\"json\", data: outData, error: ajaxError });\n\t\t\tbp.find('.hide_on_edit').removeClass(\"edit_opened\");\n\t\t\tbp.find('.show_on_edit').removeClass(\"edit_opened\");\n\t\t});\n\t});\n\n\t$(this).click(() => {\n\t\t$(\".selectedAlert\").removeClass(\"selectedAlert\");\n\t\t$(\"#back\").removeClass(\"alertActive\");\n\t\t$(\".link_select\").removeClass(\"link_opened\");\n\t});\n\n\t$(\".alert_bell\").click(function(){\n\t\tlet menuAlerts = $(this).parent();\n\t\tif(menuAlerts.hasClass(\"selectedAlert\")) {\n\t\t\tevent.stopPropagation();\n\t\t\tmenuAlerts.removeClass(\"selectedAlert\");\n\t\t\t$(\"#back\").removeClass(\"alertActive\");\n\t\t}\n\t});\n\t$(\".menu_alerts\").click(function(ev) {\n\t\tev.stopPropagation();\n\t\tif($(this).hasClass(\"selectedAlert\")) return;\n\t\tif(!conn) loadAlerts(this,true);\n\t\tthis.className += \" selectedAlert\";\n\t\tdocument.getElementById(\"back\").className += \" alertActive\"\n\t});\n\t$(\".link_select\").click(ev => ev.stopPropagation());\n\n\t$(\"input,textarea,select,option\").keyup(ev => ev.stopPropagation())\n\n\t$(\"#themeSelectorSelect\").change(function(){\n\t\tlog(\"Changing the theme to \"+this.options[this.selectedIndex].getAttribute(\"value\"));\n\t\t$.ajax({\n\t\t\turl: this.form.getAttribute(\"action\")+\"?s=\"+me.User.S,\n\t\t\ttype:\"POST\",\n\t\t\tdataType:\"json\",\n\t\t\tdata: { \"theme\": this.options[this.selectedIndex].getAttribute(\"value\"), js: 1 },\n\t\t\terror: ajaxError,\n\t\t\tsuccess: function (dat,status,xhr) {\n\t\t\t\tlog(\"Theme successfully switched\");\n\t\t\t\tlog(\"dat\",dat);\n\t\t\t\tlog(\"status\",status);\n\t\t\t\tlog(\"xhr\",xhr);\n\t\t\t\twindow.location.reload();\n\t\t\t}\n\t\t});\n\t});\n\n\t// The time range selector for the time graphs in the Control Panel\n\t$(\".autoSubmitRedirect\").change(function(){\n\t\tlet els = this.form.elements;\n\t\tlet s = \"\";\n\t\tfor(let i=0; i<els.length; i++) {\n\t\t\tlet el = els[i];\n\t\t\tif(el.nodeName==\"SELECT\") {\n\t\t\t\ts += el.name+\"=\"+el.options[el.selectedIndex].getAttribute(\"value\")+\"&\";\n\t\t\t}\n\t\t\t// TODO: Implement other element types...\n\t\t}\n\t\tif(s.length > 0) s = \"?\"+s.substr(0, s.length-1);\n\n\t\twindow.location = this.form.getAttribute(\"action\")+s; // Do a redirect as a form submission refuses to work properly\n\t});\n\n\t$(\".unix_to_24_hour_time\").each(function(){\n\t\tlet unixTime = this.innerText;\n\t\tlet date = new Date(unixTime*1000);\n\t\tlog(\"date\",date);\n\t\tlet mins = \"0\"+date.getMinutes();\n\t\tlet formattedTime = date.getHours()+\":\"+mins.substr(-2);\n\t\tlog(\"formattedTime\",formattedTime);\n\t\tthis.innerText = formattedTime;\n\t});\n\n\t$(\".unix_to_date\").each(function(){\n\t\t// TODO: Localise this\n\t\tlet monthList = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];\n\t\tlet date = new Date(this.innerText * 1000);\n\t\tlog(\"date\",date);\n\t\tlet day = \"0\"+date.getDate();\n\t\tlet formattedTime = monthList[date.getMonth()]+\" \"+day.substr(-2)+\" \"+date.getFullYear();\n\t\tlog(\"formattedTime\",formattedTime);\n\t\tthis.innerText = formattedTime;\n\t});\n\n\t$(\"spoiler\").addClass(\"hide_spoil\");\n\t$(\".hide_spoil\").click(function(ev) {\n\t\tev.stopPropagation();\n\t\tev.preventDefault();\n\t\t$(this).removeClass(\"hide_spoil\");\n\t\t$(this).unbind(\"click\");\n\t});\n\n\tthis.onkeyup = function(ev) {\n\t\tif(ev.which==37) this.querySelectorAll(\"#prevFloat a\")[0].click();\n\t\tif(ev.which==39) this.querySelectorAll(\"#nextFloat a\")[0].click();\n\t};\n\n\tfunction asyncGetSheet(src) {\n\t\treturn new Promise((resolve,reject) => {\n\t\t\tlet res = document.createElement('link');\n\t\t\tres.async = true;\n\t\n\t\t\tconst onloadHandler = (e,isAbort) => {\n\t\t\t\tif (isAbort || !res.readyState || /loaded|complete/.test(res.readyState)) {\n\t\t\t\t\tres.onload = null;\n\t\t\t\t\tres.onreadystatechange = null;\n\t\t\t\t\tres = undefined;\n\t\n\t\t\t\t\tisAbort ? reject(e) : resolve();\n\t\t\t\t}\n\t\t\t}\n\t\n\t\t\tres.onerror = (e) => {\n\t\t\t\treject(e);\n\t\t\t};\n\t\t\tres.onload = onloadHandler;\n\t\t\tres.onreadystatechange = onloadHandler;\n\t\t\tres.href = src;\n\t\t\tres.rel = \"stylesheet\";\n\t\t\tres.type = \"text/css\";\n\t\n\t\t\tconst prior = document.getElementsByTagName('link')[0];\n\t\t\tprior.parentNode.insertBefore(res,prior);\n\t\t});\n\t}\n\n\tfunction stripQ(name) {\n\t\treturn name.split('?')[0];\n\t}\n\n\tfunction loadArb(base,href,h=null) {\n\t\tfetch(href,{credentials:\"same-origin\"})\n\t\t\t.then(resp => {\n\t\t\t\tif(!resp.ok) throw(href+\" failed to load\");\n\t\t\t\tlet xr = resp.headers.get(\"x-res\")\n\t\t\t\tif(xr!=null) {\n\t\t\t\t\tfor(let res of xr.split(\",\")) {\n\t\t\t\t\t\tlet pro;\n\t\t\t\t\t\tif(stripQ(getExt(res))==\"css\") pro = asyncGetSheet(pre+res)\n\t\t\t\t\t\telse pro = asyncGetScript(pre+res)\n\t\t\t\t\t\t\tpro.then(() => log(\"Loaded \"+res))\n\t\t\t\t\t\t\t.catch(e => {\n\t\t\t\t\t\t\t\tlog(\"Unable to get \"+res,e);\n\t\t\t\t\t\t\t\tconsole.trace();\n\t\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn resp.text();\n\t\t\t}).then(d => {\n\t\t\t\tdocument.querySelector(\"#back\").outerHTML = d;\n\t\t\t\tif(h!==null) h(d);\n\t\t\t\t$(\".elapsed\").remove();\n\t\t\t\tlet obj = {Title:document.title,Url:base};\n\t\t\t\thistory.pushState(obj,obj.Title,obj.Url);\n\t\t\t}).catch(e => {\n\t\t\t\tlog(\"Unable to get script \"+href,e);\n\t\t\t\tconsole.trace();\n\t\t\t});\n\t}\n\n\t/*$(\".rowtopic a,a.rowtopic,a.forum_poster\").click(function(ev) {\n\t\tlet base = this.getAttribute(\"href\");\n\t\tloadArb(base,base+\"?i=1\", () => {\n\t\t\tunbindTopic();\n\t\t\tbindTopic();\n\t\t});\n\t\tev.stopPropagation();\n\t\tev.preventDefault();\n\t})*/\n\t$(\"a\").click(function(ev) {\n\t\tlet base = this.getAttribute(\"href\");\n\t\tif(base!=\"/topics/\") return;\n\t\tloadArb(base,base+\"?i=1\", () => {\n\t\t\tunbindPage();\n\t\t\tbindPage();\n\t\t});\n\t\tev.stopPropagation();\n\t\tev.preventDefault();\n\t})\n\n\trunInitHook(\"almost_end_init\");\n\trunInitHook(\"end_init\");\n}\n\nfunction bindPage() {\n\tlog(\"enter bindPage\");\n\t$(\".create_topic_link\").click(ev => {\n\t\tev.preventDefault();\n\t\t$(\".topic_create_form\").removeClass(\"auto_hide\");\n\t});\n\t$(\".topic_create_form .close_form\").click(ev => {\n\t\tev.preventDefault();\n\t\t$(\".topic_create_form\").addClass(\"auto_hide\");\n\t});\n\t\n\tbindTopic();\n\trunInitHook(\"end_bind_page\")\n}\n\nfunction unbindPage() {\n\tlog(\"enter unbindPage\");\n\t$(\".create_topic_link\").unbind(\"click\");\n\t$(\".topic_create_form .close_form\").unbind(\"click\");\n\tunbindTopic();\n\trunHook(\"end_unbind_page\")\n}\n\nfunction bindTopic() {\n\tlog(\"enter bindTopic\");\n\t$(\".open_edit\").click(ev => {\n\t\tev.preventDefault();\n\t\t$('.hide_on_edit').addClass(\"edit_opened\");\n\t\t$('.show_on_edit').addClass(\"edit_opened\");\n\t\trunHook(\"open_edit\");\n\t});\n\t\n\t$(\".topic_item .submit_edit\").click(function(ev){\n\t\tev.preventDefault();\n\t\tlet nameInput = $(\".topic_name_input\").val();\n\t\t$(\".topic_name\").html(nameInput);\n\t\t$(\".topic_name\").attr(nameInput);\n\t\tlet contentInput = $('.topic_content_input').val();\n\t\t$(\".topic_content\").html(quickParse(contentInput));\n\t\tlet statusInput = $('.topic_status_input').val();\n\t\t$(\".topic_status_e:not(.open_edit)\").html(statusInput);\n\n\t\t$('.hide_on_edit').removeClass(\"edit_opened\");\n\t\t$('.show_on_edit').removeClass(\"edit_opened\");\n\t\trunHook(\"close_edit\");\n\n\t\t$.ajax({\n\t\t\turl: this.form.getAttribute(\"action\"),\n\t\t\ttype:\"POST\",\n\t\t\tdataType:\"json\",\n\t\t\tdata: {\n\t\t\t\tname: nameInput,\n\t\t\t\tstatus: statusInput,\n\t\t\t\tcontent: contentInput,\n\t\t\t\tjs: 1\n\t\t\t},\n\t\t\terror: ajaxError,\n\t\t\tsuccess: (dat,status,xhr) => {\n\t\t\t\tif(\"Content\" in dat) $(\".topic_content\").html(dat[\"Content\"]);\n\t\t\t}\n\t\t});\n\t});\n\t\n\t$(\".delete_item\").click(function(ev) {\n\t\tpostLink(ev);\n\t\t$(this).closest('.deletable_block').remove();\n\t});\n\n\t// Miniature implementation of the parser to avoid sending as much data back and forth\n\tfunction quickParse(m) {\n\t\tconst r = (o,n) => {\n\t\t\tm = m.replace(o,n)\n\t\t}\n\t\tr(\":)\", \"😀\")\n\t\tr(\":(\", \"😞\")\n\t\tr(\":D\", \"😃\")\n\t\tr(\":P\", \"😛\")\n\t\tr(\":O\", \"😲\")\n\t\tr(\":p\", \"😛\")\n\t\tr(\":o\", \"😲\")\n\t\tr(\";)\", \"😉\")\n\t\tr(\"\\n\",\"<br>\")\n\t\treturn m\n\t}\n\n\t$(\".edit_item\").click(function(ev){\n\t\tev.preventDefault();\n\n\t\tlet bp = this.closest('.editable_parent');\n\t\t$(bp).find('.hide_on_edit').addClass(\"edit_opened\");\n\t\t$(bp).find('.show_on_edit').addClass(\"edit_opened\");\n\t\t$(bp).find('.hide_on_block_edit').addClass(\"edit_opened\");\n\t\t$(bp).find('.show_on_block_edit').addClass(\"edit_opened\");\n\t\tlet srcNode = bp.querySelector(\".edit_source\");\n\t\tlet block = bp.querySelector('.editable_block');\n\t\tblock.classList.add(\"in_edit\");\n\n\t\tlet src = \"\";\n\t\tif(srcNode!=null) src = srcNode.innerText;\n\t\telse src = block.innerHTML;\n\t\tblock.innerHTML = Tmpl_topic_c_edit_post({\n\t\t\tID: bp.getAttribute(\"id\").slice(\"post-\".length),\n\t\t\tSource: src,\n\t\t\tRef: this.closest('a').getAttribute(\"href\")\n\t\t})\n\t\trunHook(\"edit_item_pre_bind\");\n\n\t\t$(\".submit_edit\").click(function(ev){\n\t\t\tev.preventDefault();\n\t\t\t$(bp).find('.hide_on_edit').removeClass(\"edit_opened\");\n\t\t\t$(bp).find('.show_on_edit').removeClass(\"edit_opened\");\n\t\t\t$(bp).find('.hide_on_block_edit').removeClass(\"edit_opened\");\n\t\t\t$(bp).find('.show_on_block_edit').removeClass(\"edit_opened\");\n\t\t\tblock.classList.remove(\"in_edit\");\n\t\t\tlet con = block.querySelector('textarea').value;\n\t\t\tblock.innerHTML = quickParse(con);\n\t\t\tif(srcNode!=null) srcNode.innerText = con;\n\n\t\t\tlet formAction = this.closest('a').getAttribute(\"href\");\n\t\t\t// TODO: Bounce the parsed post back and set innerHTML to it?\n\t\t\t$.ajax({\n\t\t\t\turl: formAction,\n\t\t\t\ttype:\"POST\",\n\t\t\t\tdataType:\"json\",\n\t\t\t\tdata: { js: 1, edit_item: con },\n\t\t\t\terror: ajaxError,\n\t\t\t\tsuccess: (dat,status,xhr) => {\n\t\t\t\t\tif(\"Content\" in dat) block.innerHTML = dat[\"Content\"];\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t});\n\n\t$(\".quote_item\").click(function(ev){\n\t\tev.preventDefault();\n\t\tev.stopPropagation();\n\t\tlet src = this.closest(\".post_item\").getElementsByClassName(\"edit_source\")[0];\n\t\tlet con = document.getElementById(\"input_content\")\n\t\tlog(\"con.value\",con.value);\n\n\t\tlet item;\n\t\tif(con.value==\"\") item = \"<blockquote>\"+src.innerHTML+\"</blockquote>\"\n\t\telse item = \"\\r\\n<blockquote>\"+src.innerHTML+\"</blockquote>\";\n\t\tcon.value = con.value+item;\n\t\tlog(\"con.value\",con.value);\n\n\t\t// For custom / third party text editors\n\t\tquoteItemCallback(src.innerHTML,item);\n\t});\n\n\t//id=\"poll_results_{pollid}\" class=\"poll_results auto_hide\"\n\t$(\".poll_results_button\").click(function(){\n\t\tlet pollID = $(this).attr(\"data-poll-id\");\n\t\t$(\"#poll_results_\"+pollID).removeClass(\"auto_hide\");\n\t\tfetch(\"/poll/results/\"+pollID, {\n\t\t\tcredentials: 'same-origin'\n\t\t}).then(resp => resp.text()).catch(e => console.error(\"e\",e)).then(rawData => {\n\t\t\t// TODO: Make sure the received data is actually a list of integers\n\t\t\tlet data = JSON.parse(rawData);\n\t\t\tlet allZero = true;\n\t\t\tfor(let i=0; i<data.length; i++) {\n\t\t\t\tif(data[i]!=\"0\") allZero = false;\n\t\t\t}\n\t\t\tif(allZero) {\n\t\t\t\t$(\"#poll_results_\"+pollID+\" .poll_no_results\").removeClass(\"auto_hide\");\n\t\t\t\tlog(\"all zero\")\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t$(\"#poll_results_\"+pollID+\" .user_content\").html(\"<div id='poll_results_chart_\"+pollID+\"'></div>\");\n\t\t\tlog(\"rawData\",rawData);\n\t\t\tlog(\"series\",data);\n\t\t\tChartist.Pie('#poll_results_chart_'+pollID, {\n \t\t\t\tseries: data,\n\t\t\t}, {\n\t\t\t\theight: '120px',\n\t\t\t});\n\t\t})\n\t});\n\n\trunInitHook(\"end_bind_topic\");\n}\n\nfunction unbindTopic() {\n\tlog(\"enter unbindTopic\");\n\t$(\".open_edit\").unbind(\"click\");\n\t$(\".topic_item .submit_edit\").unbind(\"click\");\n\t$(\".delete_item\").unbind(\"click\");\n\t$(\".edit_item\").unbind(\"click\");\n\t$(\".submit_edit\").unbind(\"click\");\n\t$(\".quote_item\").unbind(\"click\");\n\t$(\".poll_results_button\").unbind(\"click\");\n\trunHook(\"end_unbind_topic\");\n}"
  },
  {
    "path": "public/init.js",
    "content": "'use strict';\nvar me={};\nvar phraseBox={};\nif(tmplInits===undefined) var tmplInits={};\nvar tmplPhrases=[]; // [key] array of phrases indexed by order of use\nvar hooks={};\nvar ranInitHooks={}\nvar log=console.log;\nvar pre=\"/s/\";\n\nfunction runHook(name,...args) {\n\tif(!(name in hooks)) {\n\t\tlog(\"Couldn't find hook \"+name);\n\t\treturn;\n\t}\n\tlog(\"Running hook \"+name);\n\n\tlet hook = hooks[name];\n\tlet o;\n\tfor(const index in hook) o = hook[index](...args);\n\treturn o;\n}\nfunction addHook(name,h) {\n\tlog(\"Add hook \"+name);\n\tif(hooks[name]===undefined) hooks[name]=[];\n\thooks[name].push(h);\n}\n\n// InitHooks are slightly special, as if they are run, then any adds after the initial run will run immediately, this is to deal with the async nature of script loads\nfunction runInitHook(name,...args) {\n\tranInitHooks[name]=true;\n\treturn runHook(name,...args);\n}\nfunction addInitHook(name,h) {\n\taddHook(name,h);\n\tif(name in ranInitHooks) {\n\t\tlog(\"Delay running \"+name);\n\t\th();\n\t}\n}\n\n// Temporary hack for templates\nfunction len(d) {return d.length}\n\nfunction asyncGetScript(src) {\n\treturn new Promise((resolve,reject) => {\n\t\tlet script = document.createElement('script');\n\t\tscript.async = true;\n\n\t\tconst onloadHandler = (e,isAbort) => {\n\t\t\tif (isAbort||!script.readyState||/loaded|complete/.test(script.readyState)) {\n\t\t\t\tscript.onload=null;\n\t\t\t\tscript.onreadystatechange=null;\n\t\t\t\tscript=undefined;\n\n\t\t\t\tisAbort ? reject(e) : resolve();\n\t\t\t}\n\t\t}\n\t\tscript.onerror = e => {\n\t\t\treject(e);\n\t\t};\n\t\tscript.onload = onloadHandler;\n\t\tscript.onreadystatechange = onloadHandler;\n\t\tscript.src = src;\n\n\t\tconst prior = document.getElementsByTagName('script')[0];\n\t\tprior.parentNode.insertBefore(script,prior);\n\t});\n}\n\nfunction notifyOnScript(src) {\n\tsrc = pre+src;\n\treturn new Promise((resolve,reject) => {\n\t\tlet ss = src.replace(pre,\"\");\n\t\ttry {\n\t\t\tlet ssp = ss.charAt(0).toUpperCase() + ss.slice(1)\n\t\t\tlog(\"ssp\",ssp)\n\t\t\tif(window[ssp]) {\n\t\t\t\tresolve();\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch(e) {}\n\t\t\n\t\tlog(\"src\",src)\n\t\tlet script = document.querySelectorAll(`[src^=\"${src}\"]`)[0];\n\t\tlog(\"script\",script);\n\t\tif(script===undefined) {\n\t\t\treject(\"no script found\");\n\t\t\treturn;\n\t\t}\n\n\t\tconst onloadHandler = e => {\n\t\t\tscript.onload=null;\n\t\t\tscript.onreadystatechange=null;\n\t\t\tresolve();\n\t\t}\n\t\tscript.onerror = e => {\n\t\t\treject(e);\n\t\t};\n\t\tscript.onload = onloadHandler;\n\t\tscript.onreadystatechange = onloadHandler;\n\t});\n}\n\nfunction notifyOnScriptW(name,complete,success) {\n\tnotifyOnScript(name)\n\t\t.then(() => {\n\t\t\tlog(`Loaded ${name}.js`);\n\t\t\tcomplete();\n\t\t\tif(success!==undefined) success();\n\t\t}).catch(e => {\n\t\t\tlog(\"Unable to get \"+name,e);\n\t\t\tconsole.trace();\n\t\t\tcomplete(e);\n\t\t});\n}\n\n// TODO: Send data at load time so we don't have to rely on a fallback template here\nfunction loadScript(name,h,fail) {\n\tlet fname = name;\n\tlet value = \"; \"+document.cookie;\n\tlet parts = value.split(\"; current_theme=\");\n\tif(parts.length==2) fname += \"_\"+parts.pop().split(\";\").shift();\n\t\n\tlet url = pre+fname+\".js\"\n\tlet iurl = pre+name+\".js\"\n\tasyncGetScript(url)\n\t\t.then(h).catch(e => {\n\t\t\tlog(\"Unable to get \"+url,e);\n\t\t\tif(fname!=name) {\n\t\t\t\tasyncGetScript(iurl)\n\t\t\t\t\t.then(h).catch(e => {\n\t\t\t\t\t\tlog(\"Unable to get \"+iurl,e);\n\t\t\t\t\t\tconsole.trace();\n\t\t\t\t\t});\n\t\t\t}\n\t\t\tconsole.trace();\n\t\t\tfail(e);\n\t\t});\n}\n\nfunction RelativeTime(date) {return date}\n\nfunction initPhrases(member,acp=false) {\n\tlog(\"initPhrases\")\n\tlog(\"tmlInits\",tmplInits)\n\tlet e = \"\";\n\tif(member && !acp) e=\",status,topic_list,topic\";\n\telse if(acp) e=\",analytics,panel\"; // TODO: Request phrases for just one section of the acp?\n\telse e=\",status,topic_list\";\n\tfetchPhrases(\"alerts,paginator\"+e) // TODO: Break this up?\n}\n\nfunction fetchPhrases(plist) {\n\tfetch(\"/api/phrases/?q=\"+plist,{cache:\"no-cache\"})\n\t\t.then(r => r.json())\n\t\t.then(d => {\n\t\t\tlog(\"loaded phrase endpoint data\",d);\n\t\t\tObject.keys(tmplInits).forEach(key => {\n\t\t\t\tlet phrases=[];\n\t\t\t\tlet tmplInit = tmplInits[key];\n\t\t\t\tfor(let phraseName of tmplInit) phrases.push(d[phraseName]);\n\t\t\t\tlog(\"Adding phrases for \"+key,phrases);\n\t\t\t\ttmplPhrases[key] = phrases;\n\t\t\t});\n\n\t\t\tlet prefixes={};\n\t\t\tObject.keys(d).forEach(key => {\n\t\t\t\tlet prefix = key.split(\".\")[0];\n\t\t\t\tif(prefixes[prefix]===undefined) prefixes[prefix]={};\n\t\t\t\tprefixes[prefix][key] = d[key];\n\t\t\t});\n\t\t\tObject.keys(prefixes).forEach(prefix => {\n\t\t\t\tlog(`adding phrase prefix ${prefix} to box`);\n\t\t\t\tphraseBox[prefix] = prefixes[prefix];\n\t\t\t});\n\n\t\t\trunInitHook(\"after_phrases\");\n\t\t});\n}\n\n(() => {\n\trunInitHook(\"pre_iife\");\n\tlet member = document.head.querySelector(\"[property='x-mem']\")!=null;\n\tlet acp = window.location.pathname.startsWith(\"/panel/\");\n\n\tlet toLoad = 1;\n\t// TODO: Shunt this into member if there aren't any search and filter widgets?\n\tlet q = f => {\n\t\ttoLoad--;\n\t\tif(toLoad===0) initPhrases(member,acp);\n\t\tif(f) throw(\"tmpl func not found\");\n\t};\n\tlet l = (n,h) => notifyOnScriptW(\"tmpl_\"+n,h);\n\n\tif(!acp) {\n\t\ttoLoad += 2;\n\t\tif(member) {\n\t\t\ttoLoad += 3;\n\t\t\tl(\"topic_c_edit_post\", () => q(!Tmpl_topic_c_edit_post));\n\t\t\tl(\"topic_c_attach_item\", () => q(!Tmpl_topic_c_attach_item));\n\t\t\tl(\"topic_c_poll_input\", () => q(!Tmpl_topic_c_poll_input));\n\t\t}\n\t\tl(\"topics_topic\", () => q(!Tmpl_topics_topic));\n\t\tl(\"paginator\", () => q(!Tmpl_paginator));\n\t}\n\tl(\"notice\", () => q(!Tmpl_notice));\n\n\tif(member) {\n\t\tfetch(\"/api/me/\")\n\t\t.then(r => r.json())\n\t\t.then(d => {\n\t\t\tlog(\"me\",d);\n\t\t\tme=d;\n\t\t\tpre=d.StaticPrefix;\n\t\t\trunInitHook(\"pre_init\");\n\t\t});\n\t} else {\n\t\tme={User:{ID:0,S:\"\"},Site:{\"MaxReqSize\":0}};\n\t\trunInitHook(\"pre_init\");\n\t}\n})()"
  },
  {
    "path": "public/jquery-emojiarea/LICENSE",
    "content": "The MIT License\n\nCopyright (c) 2011-2012 by linyows <linyows@gmail.com>\n\nPermission is hereby granted, freef charge, to any personbtaining a copyf this software and associated documentation files (the 'Software'),\nto deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copiesf the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copiesr substantial portionsf the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,\nDAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "public/jquery-emojiarea/README.md",
    "content": "*NOTICE* This extends the [original plugin](https://github.com/diy/jquery-emojiarea) by groups. (See screenshot at dropdown menu)\n\n#### About this extension\nThis was originally created for my project *The Msngr*, which should have become an open-source end-to-end encrypted messanger. I am always happy about an attribution to me and my website, e.g. [\\<a href=\"https://pius-ladenburger.de\">Pius Ladenburger\\</a>](https://pius-ladenburger.de).\n\nI hope this helps you and makes your day easier.\n\n#.emojiarea()\n\nA small **6kb** [jQuery](http://jquery.com/) plugin for turning regular textareas into ones that support emojis, WYSIWYG style! Set up a list of available emojis, call `$('textarea').emojiarea()` and you're done (basically). There's a plain-text fallback, so if the browser doesn't support [contentEditable](http://caniuse.com/#search=contenteditable), it will degrade gracefully—the user will still be able to use the dropdown menu of emojis.\n\n![Screenshot](http://i.imgur.com/C4Z8F.gif)\n\n```html\n<textarea>Hello :smile:</textarea>\n<script type=\"text/javascript\">$('textarea').emojiarea();</script>\n```\n\n## Configuration\n\n### Dropdown Menu\n\n![Dropdown Screenshot](http://i.imgur.com/EuTTpHk.png)\n\nBy default, the plugin will insert a link after the editor that toggles the emoji selector when clicked.\n\n```html\n<a href=\"javascript:void(0)\" class=\"emoji-button\">Emojis</a>\n```\n\nIf you wish change this behavior and have the button placed before the editor, or change the label of the link, use:\n\n```javascript\n$('textarea').emojiarea({\n    buttonLabel: 'Add Emoji',\n    buttonPosition: 'before'\n});\n```\n\nAlternatively, if you wish to use your own button:\n\n```javascript\n$('textarea').emojiarea({button: '#your-button'});\n```\n\nFor customizing the visual appearance, see the [CSS / Skinning](#css--skinning) section.\n\n### Available Emojis\n\n```javascript\n$.emojiarea.path = '/path/to/folder/with/icons';\n$.emojiarea.icons = {\n    ':smile:'     : 'smile.png',\n    ':angry:'     : 'angry.png',\n    ':flushed:'   : 'flushed.png',\n    ':neckbeard:' : 'neckbeard.png',\n    ':laughing:'  : 'laughing.png'\n};\n```\n\n### Defaults\n\nIf you wish to set the defaults for `$().emojiarea()`, extend `$.emojiarea.defaults` like so:\n\n```javascript\n$.extend($.emojiarea.defaults, {\n    buttonPosition: 'before'\n});\n```\n\nFor a basic set of emojis, see \"packs/basic\". \n\n## CSS / Skinning\n\nSee [jquery.emojiarea.css](https://github.com/diy/jquery-emojiarea/blob/master/jquery.emojiarea.css) for the few fundamental CSS styles needed for this to work.\n\nBasically, you'll want to adjust the following styles:\n\n```css\n.emoji-wysiwyg-editor /* the editor box itself */\n.emoji-menu > div /* the dropdown menu with options */\n.emoji-wysiwyg-editor img /* the emoji images in the editor */\n```\n\n## Footnotes\n\n* Huge props to [Tim Down](http://stackoverflow.com/users/96100/tim-down) for the many insightful answers on Stack Overflow having to deal with cross-browser selection handling.\n* If you have a really rad set of emojis and would like to share, please fork this, add them to \"packs/\", and submit a pull request!\n* For a giant list of emojis (used by Github, Basecamp, et al), see [\"Emoji cheat sheet\"](http://www.emoji-cheat-sheet.com/).\n\n## License\n\nCopyright &copy; 2012 DIY Co and 2015 Pius Ladenburger\n\nLicensed 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\n\nUnless 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.\n\nI am always happy about an attribution to me and my website, e.g. [\\<a href=\"https://pius-ladenburger.de\">Pius Ladenburger\\</a>](https://pius-ladenburger.de)\n"
  },
  {
    "path": "public/jquery-emojiarea/emojis.js",
    "content": "$.emojiarea.path = '/s/smilies/emojiarea';\n$.emojiarea.icons = [{\"name\" : \"<i class='icon-smile'></i>\", \"icons\" : {':bowtie:' : 'bowtie.png',\n':smile:' : 'smile.png',\n':laughing:' : 'laughing.png',\n':blush:' : 'blush.png',\n':smiley:' : 'smiley.png',\n':relaxed:' : 'relaxed.png',\n':smirk:' : 'smirk.png',\n':heart_eyes:' : 'heart_eyes.png',\n':kissing_heart:' : 'kissing_heart.png',\n':kissing_closed_eyes:' : 'kissing_closed_eyes.png',\n':flushed:' : 'flushed.png',\n':relieved:' : 'relieved.png',\n':satisfied:' : 'satisfied.png',\n':grin:' : 'grin.png',\n':wink:' : 'wink.png',\n':stuck_out_tongue_winking_eye:' : 'stuck_out_tongue_winking_eye.png',\n':stuck_out_tongue_closed_eyes:' : 'stuck_out_tongue_closed_eyes.png',\n':grinning:' : 'grinning.png',\n':kissing:' : 'kissing.png',\n':kissing_smiling_eyes:' : 'kissing_smiling_eyes.png',\n':stuck_out_tongue:' : 'stuck_out_tongue.png',\n':sleeping:' : 'sleeping.png',\n':worried:' : 'worried.png',\n':frowning:' : 'frowning.png',\n':anguished:' : 'anguished.png',\n':open_mouth:' : 'open_mouth.png',\n':grimacing:' : 'grimacing.png',\n':confused:' : 'confused.png',\n':hushed:' : 'hushed.png',\n':expressionless:' : 'expressionless.png',\n':unamused:' : 'unamused.png',\n':sweat_smile:' : 'sweat_smile.png',\n':sweat:' : 'sweat.png',\n':weary:' : 'weary.png',\n':pensive:' : 'pensive.png',\n':disappointed:' : 'disappointed.png',\n':confounded:' : 'confounded.png',\n':fearful:' : 'fearful.png',\n':cold_sweat:' : 'cold_sweat.png',\n':persevere:' : 'persevere.png',\n':joy:' : 'joy.png',\n':astonished:' : 'astonished.png',\n':scream:' : 'scream.png',\n':neckbeard:' : 'neckbeard.png',\n':tired_face:' : 'tired_face.png',\n':angry:' : 'angry.png',\n':rage:' : 'rage.png',\n':triumph:' : 'triumph.png',\n':sleepy:' : 'sleepy.png',\n':yum:' : 'yum.png',\n':mask:' : 'mask.png',\n':sunglasses:' : 'sunglasses.png',\n':dizzy_face:' : 'dizzy_face.png',\n':imp:' : 'imp.png',\n':smiling_imp:' : 'smiling_imp.png',\n':neutral_face:' : 'neutral_face.png',\n':no_mouth:' : 'no_mouth.png',\n':innocent:' : 'innocent.png',\n':alien:' : 'alien.png',\n':yellow_heart:' : 'yellow_heart.png',\n':blue_heart:' : 'blue_heart.png',\n':purple_heart:' : 'purple_heart.png',\n':heart:' : 'heart.png',\n':green_heart:' : 'green_heart.png',\n':broken_heart:' : 'broken_heart.png',\n':heartbeat:' : 'heartbeat.png',\n':heartpulse:' : 'heartpulse.png',\n':two_hearts:' : 'two_hearts.png',\n':revolving_hearts:' : 'revolving_hearts.png',\n':cupid:' : 'cupid.png',\n':sparkling_heart:' : 'sparkling_heart.png',\n':sparkles:' : 'sparkles.png',\n':star:' : 'star.png',\n':star2:' : 'star2.png',\n':dizzy:' : 'dizzy.png',\n':boom:' : 'boom.png',\n':collision:' : 'collision.png',\n':anger:' : 'anger.png',\n':exclamation:' : 'exclamation.png',\n':question:' : 'question.png',\n':grey_exclamation:' : 'grey_exclamation.png',\n':grey_question:' : 'grey_question.png',\n':zzz:' : 'zzz.png',\n':dash:' : 'dash.png',\n':sweat_drops:' : 'sweat_drops.png',\n':notes:' : 'notes.png',\n':musical_note:' : 'musical_note.png',\n':fire:' : 'fire.png',\n':hankey:' : 'hankey.png',\n':poop:' : 'poop.png',\n':shit:' : 'shit.png',\n':+1:' : '+1.png',\n':thumbsup:' : 'thumbsup.png',\n':-1:' : '-1.png',\n':thumbsdown:' : 'thumbsdown.png',\n':ok_hand:' : 'ok_hand.png',\n':punch:' : 'punch.png',\n':facepunch:' : 'facepunch.png',\n':fist:' : 'fist.png',\n':v:' : 'v.png',\n':wave:' : 'wave.png',\n':hand:' : 'hand.png',\n':raised_hand:' : 'raised_hand.png',\n':open_hands:' : 'open_hands.png',\n':point_up:' : 'point_up.png',\n':point_down:' : 'point_down.png',\n':point_left:' : 'point_left.png',\n':point_right:' : 'point_right.png',\n':raised_hands:' : 'raised_hands.png',\n':pray:' : 'pray.png',\n':point_up_2:' : 'point_up_2.png',\n':clap:' : 'clap.png',\n':muscle:' : 'muscle.png',\n':metal:' : 'metal.png',\n':walking:' : 'walking.png',\n':runner:' : 'runner.png',\n':running:' : 'running.png',\n':couple:' : 'couple.png',\n':family:' : 'family.png',\n':two_men_holding_hands:' : 'two_men_holding_hands.png',\n':two_women_holding_hands:' : 'two_women_holding_hands.png',\n':ok_woman:' : 'ok_woman.png',\n':no_good:' : 'no_good.png',\n':information_desk_person:' : 'information_desk_person.png',\n':bride_with_veil:' : 'bride_with_veil.png',\n':person_with_pouting_face:' : 'person_with_pouting_face.png',\n':person_frowning:' : 'person_frowning.png',\n':bow:' : 'bow.png',\n':couplekiss:' : 'couplekiss.png',\n':couple_with_heart:' : 'couple_with_heart.png',\n':massage:' : 'massage.png',\n':haircut:' : 'haircut.png',\n':nail_care:' : 'nail_care.png',\n':boy:' : 'boy.png',\n':girl:' : 'girl.png',\n':woman:' : 'woman.png',\n':man:' : 'man.png',\n':baby:' : 'baby.png',\n':older_woman:' : 'older_woman.png',\n':older_man:' : 'older_man.png',\n':person_with_blond_hair:' : 'person_with_blond_hair.png',\n':man_with_gua_pi_mao:' : 'man_with_gua_pi_mao.png',\n':man_with_turban:' : 'man_with_turban.png',\n':construction_worker:' : 'construction_worker.png',\n':cop:' : 'cop.png',\n':angel:' : 'angel.png',\n':princess:' : 'princess.png',\n':smiley_cat:' : 'smiley_cat.png',\n':smile_cat:' : 'smile_cat.png',\n':heart_eyes_cat:' : 'heart_eyes_cat.png',\n':kissing_cat:' : 'kissing_cat.png',\n':smirk_cat:' : 'smirk_cat.png',\n':scream_cat:' : 'scream_cat.png',\n':crying_cat_face:' : 'crying_cat_face.png',\n':joy_cat:' : 'joy_cat.png',\n':pouting_cat:' : 'pouting_cat.png',\n':japanese_ogre:' : 'japanese_ogre.png',\n':japanese_goblin:' : 'japanese_goblin.png',\n':see_no_evil:' : 'see_no_evil.png',\n':hear_no_evil:' : 'hear_no_evil.png',\n':speak_no_evil:' : 'speak_no_evil.png',\n':guardsman:' : 'guardsman.png',\n':skull:' : 'skull.png',\n':feet:' : 'feet.png',\n':lips:' : 'lips.png',\n':kiss:' : 'kiss.png',\n':droplet:' : 'droplet.png',\n':ear:' : 'ear.png',\n':eyes:' : 'eyes.png',\n':nose:' : 'nose.png',\n':tongue:' : 'tongue.png',\n':love_letter:' : 'love_letter.png',\n':bust_in_silhouette:' : 'bust_in_silhouette.png',\n':busts_in_silhouette:' : 'busts_in_silhouette.png',\n':speech_balloon:' : 'speech_balloon.png',\n':thought_balloon:' : 'thought_balloon.png',\n':feelsgood:' : 'feelsgood.png',\n':finnadie:' : 'finnadie.png',\n':goberserk:' : 'goberserk.png',\n':godmode:' : 'godmode.png',\n':hurtrealbad:' : 'hurtrealbad.png',\n':rage1:' : 'rage1.png',\n':rage2:' : 'rage2.png',\n':rage3:' : 'rage3.png',\n':rage4:' : 'rage4.png',\n':suspect:' : 'suspect.png',\n':trollface:' : 'trollface.png'}},\n{'name': '<i class=\"icon-tree\"></i>', 'icons' : {':sunny:' : 'sunny.png',\n':umbrella:' : 'umbrella.png',\n':cloud:' : 'cloud.png',\n':snowflake:' : 'snowflake.png',\n':snowman:' : 'snowman.png',\n':zap:' : 'zap.png',\n':cyclone:' : 'cyclone.png',\n':foggy:' : 'foggy.png',\n':ocean:' : 'ocean.png',\n':cat:' : 'cat.png',\n':dog:' : 'dog.png',\n':mouse:' : 'mouse.png',\n':hamster:' : 'hamster.png',\n':rabbit:' : 'rabbit.png',\n':wolf:' : 'wolf.png',\n':frog:' : 'frog.png',\n':tiger:' : 'tiger.png',\n':koala:' : 'koala.png',\n':bear:' : 'bear.png',\n':pig:' : 'pig.png',\n':pig_nose:' : 'pig_nose.png',\n':cow:' : 'cow.png',\n':boar:' : 'boar.png',\n':monkey_face:' : 'monkey_face.png',\n':monkey:' : 'monkey.png',\n':horse:' : 'horse.png',\n':racehorse:' : 'racehorse.png',\n':camel:' : 'camel.png',\n':sheep:' : 'sheep.png',\n':elephant:' : 'elephant.png',\n':panda_face:' : 'panda_face.png',\n':snake:' : 'snake.png',\n':bird:' : 'bird.png',\n':baby_chick:' : 'baby_chick.png',\n':hatched_chick:' : 'hatched_chick.png',\n':hatching_chick:' : 'hatching_chick.png',\n':chicken:' : 'chicken.png',\n':penguin:' : 'penguin.png',\n':turtle:' : 'turtle.png',\n':bug:' : 'bug.png',\n':honeybee:' : 'honeybee.png',\n':ant:' : 'ant.png',\n':beetle:' : 'beetle.png',\n':snail:' : 'snail.png',\n':octopus:' : 'octopus.png',\n':tropical_fish:' : 'tropical_fish.png',\n':fish:' : 'fish.png',\n':whale:' : 'whale.png',\n':whale2:' : 'whale2.png',\n':dolphin:' : 'dolphin.png',\n':cow2:' : 'cow2.png',\n':ram:' : 'ram.png',\n':rat:' : 'rat.png',\n':water_buffalo:' : 'water_buffalo.png',\n':tiger2:' : 'tiger2.png',\n':rabbit2:' : 'rabbit2.png',\n':dragon:' : 'dragon.png',\n':goat:' : 'goat.png',\n':rooster:' : 'rooster.png',\n':dog2:' : 'dog2.png',\n':pig2:' : 'pig2.png',\n':mouse2:' : 'mouse2.png',\n':ox:' : 'ox.png',\n':dragon_face:' : 'dragon_face.png',\n':blowfish:' : 'blowfish.png',\n':crocodile:' : 'crocodile.png',\n':dromedary_camel:' : 'dromedary_camel.png',\n':leopard:' : 'leopard.png',\n':cat2:' : 'cat2.png',\n':poodle:' : 'poodle.png',\n':paw_prints:' : 'paw_prints.png',\n':bouquet:' : 'bouquet.png',\n':cherry_blossom:' : 'cherry_blossom.png',\n':tulip:' : 'tulip.png',\n':four_leaf_clover:' : 'four_leaf_clover.png',\n':rose:' : 'rose.png',\n':sunflower:' : 'sunflower.png',\n':hibiscus:' : 'hibiscus.png',\n':maple_leaf:' : 'maple_leaf.png',\n':leaves:' : 'leaves.png',\n':fallen_leaf:' : 'fallen_leaf.png',\n':herb:' : 'herb.png',\n':mushroom:' : 'mushroom.png',\n':cactus:' : 'cactus.png',\n':palm_tree:' : 'palm_tree.png',\n':evergreen_tree:' : 'evergreen_tree.png',\n':deciduous_tree:' : 'deciduous_tree.png',\n':chestnut:' : 'chestnut.png',\n':seedling:' : 'seedling.png',\n':blossom:' : 'blossom.png',\n':ear_of_rice:' : 'ear_of_rice.png',\n':shell:' : 'shell.png',\n':globe_with_meridians:' : 'globe_with_meridians.png',\n':sun_with_face:' : 'sun_with_face.png',\n':full_moon_with_face:' : 'full_moon_with_face.png',\n':new_moon_with_face:' : 'new_moon_with_face.png',\n':new_moon:' : 'new_moon.png',\n':waxing_crescent_moon:' : 'waxing_crescent_moon.png',\n':first_quarter_moon:' : 'first_quarter_moon.png',\n':waxing_gibbous_moon:' : 'waxing_gibbous_moon.png',\n':full_moon:' : 'full_moon.png',\n':waning_gibbous_moon:' : 'waning_gibbous_moon.png',\n':last_quarter_moon:' : 'last_quarter_moon.png',\n':waning_crescent_moon:' : 'waning_crescent_moon.png',\n':last_quarter_moon_with_face:' : 'last_quarter_moon_with_face.png',\n':first_quarter_moon_with_face:' : 'first_quarter_moon_with_face.png',\n':moon:' : 'moon.png',\n':earth_africa:' : 'earth_africa.png',\n':earth_americas:' : 'earth_americas.png',\n':earth_asia:' : 'earth_asia.png',\n':volcano:' : 'volcano.png',\n':milky_way:' : 'milky_way.png',\n':partly_sunny:' : 'partly_sunny.png',\n':octocat:' : 'octocat.png'}},\n{'name': '<i class=\"icon-bell-alt\"></i>', 'icons' : {':squirrel:' : 'squirrel.png',\n':bamboo:' : 'bamboo.png',\n':gift_heart:' : 'gift_heart.png',\n':dolls:' : 'dolls.png',\n':school_satchel:' : 'school_satchel.png',\n':mortar_board:' : 'mortar_board.png',\n':flags:' : 'flags.png',\n':fireworks:' : 'fireworks.png',\n':sparkler:' : 'sparkler.png',\n':wind_chime:' : 'wind_chime.png',\n':rice_scene:' : 'rice_scene.png',\n':jack_o_lantern:' : 'jack_o_lantern.png',\n':ghost:' : 'ghost.png',\n':santa:' : 'santa.png',\n':christmas_tree:' : 'christmas_tree.png',\n':gift:' : 'gift.png',\n':bell:' : 'bell.png',\n':no_bell:' : 'no_bell.png',\n':tanabata_tree:' : 'tanabata_tree.png',\n':tada:' : 'tada.png',\n':confetti_ball:' : 'confetti_ball.png',\n':balloon:' : 'balloon.png',\n':crystal_ball:' : 'crystal_ball.png',\n':cd:' : 'cd.png',\n':dvd:' : 'dvd.png',\n':floppy_disk:' : 'floppy_disk.png',\n':camera:' : 'camera.png',\n':video_camera:' : 'video_camera.png',\n':movie_camera:' : 'movie_camera.png',\n':computer:' : 'computer.png',\n':tv:' : 'tv.png',\n':iphone:' : 'iphone.png',\n':phone:' : 'phone.png',\n':telephone:' : 'telephone.png',\n':telephone_receiver:' : 'telephone_receiver.png',\n':pager:' : 'pager.png',\n':fax:' : 'fax.png',\n':minidisc:' : 'minidisc.png',\n':vhs:' : 'vhs.png',\n':sound:' : 'sound.png',\n':speaker:' : 'speaker.png',\n':mute:' : 'mute.png',\n':loudspeaker:' : 'loudspeaker.png',\n':mega:' : 'mega.png',\n':hourglass:' : 'hourglass.png',\n':hourglass_flowing_sand:' : 'hourglass_flowing_sand.png',\n':alarm_clock:' : 'alarm_clock.png',\n':watch:' : 'watch.png',\n':radio:' : 'radio.png',\n':satellite:' : 'satellite.png',\n':loop:' : 'loop.png',\n':mag:' : 'mag.png',\n':mag_right:' : 'mag_right.png',\n':unlock:' : 'unlock.png',\n':lock:' : 'lock.png',\n':lock_with_ink_pen:' : 'lock_with_ink_pen.png',\n':closed_lock_with_key:' : 'closed_lock_with_key.png',\n':key:' : 'key.png',\n':bulb:' : 'bulb.png',\n':flashlight:' : 'flashlight.png',\n':high_brightness:' : 'high_brightness.png',\n':low_brightness:' : 'low_brightness.png',\n':electric_plug:' : 'electric_plug.png',\n':battery:' : 'battery.png',\n':calling:' : 'calling.png',\n':email:' : 'email.png',\n':mailbox:' : 'mailbox.png',\n':postbox:' : 'postbox.png',\n':bath:' : 'bath.png',\n':bathtub:' : 'bathtub.png',\n':shower:' : 'shower.png',\n':toilet:' : 'toilet.png',\n':wrench:' : 'wrench.png',\n':nut_and_bolt:' : 'nut_and_bolt.png',\n':hammer:' : 'hammer.png',\n':seat:' : 'seat.png',\n':moneybag:' : 'moneybag.png',\n':yen:' : 'yen.png',\n':dollar:' : 'dollar.png',\n':pound:' : 'pound.png',\n':euro:' : 'euro.png',\n':credit_card:' : 'credit_card.png',\n':money_with_wings:' : 'money_with_wings.png',\n':e-mail:' : 'e-mail.png',\n':inbox_tray:' : 'inbox_tray.png',\n':outbox_tray:' : 'outbox_tray.png',\n':envelope:' : 'envelope.png',\n':incoming_envelope:' : 'incoming_envelope.png',\n':postal_horn:' : 'postal_horn.png',\n':mailbox_closed:' : 'mailbox_closed.png',\n':mailbox_with_mail:' : 'mailbox_with_mail.png',\n':mailbox_with_no_mail:' : 'mailbox_with_no_mail.png',\n':door:' : 'door.png',\n':smoking:' : 'smoking.png',\n':bomb:' : 'bomb.png',\n':gun:' : 'gun.png',\n':hocho:' : 'hocho.png',\n':pill:' : 'pill.png',\n':syringe:' : 'syringe.png',\n':page_facing_up:' : 'page_facing_up.png',\n':page_with_curl:' : 'page_with_curl.png',\n':bookmark_tabs:' : 'bookmark_tabs.png',\n':bar_chart:' : 'bar_chart.png',\n':chart_with_upwards_trend:' : 'chart_with_upwards_trend.png',\n':chart_with_downwards_trend:' : 'chart_with_downwards_trend.png',\n':scroll:' : 'scroll.png',\n':clipboard:' : 'clipboard.png',\n':calendar:' : 'calendar.png',\n':date:' : 'date.png',\n':card_index:' : 'card_index.png',\n':file_folder:' : 'file_folder.png',\n':open_file_folder:' : 'open_file_folder.png',\n':scissors:' : 'scissors.png',\n':pushpin:' : 'pushpin.png',\n':paperclip:' : 'paperclip.png',\n':black_nib:' : 'black_nib.png',\n':pencil2:' : 'pencil2.png',\n':straight_ruler:' : 'straight_ruler.png',\n':triangular_ruler:' : 'triangular_ruler.png',\n':closed_book:' : 'closed_book.png',\n':green_book:' : 'green_book.png',\n':blue_book:' : 'blue_book.png',\n':orange_book:' : 'orange_book.png',\n':notebook:' : 'notebook.png',\n':notebook_with_decorative_cover:' : 'notebook_with_decorative_cover.png',\n':ledger:' : 'ledger.png',\n':books:' : 'books.png',\n':bookmark:' : 'bookmark.png',\n':name_badge:' : 'name_badge.png',\n':microscope:' : 'microscope.png',\n':telescope:' : 'telescope.png',\n':newspaper:' : 'newspaper.png',\n':football:' : 'football.png',\n':basketball:' : 'basketball.png',\n':soccer:' : 'soccer.png',\n':baseball:' : 'baseball.png',\n':tennis:' : 'tennis.png',\n':8ball:' : '8ball.png',\n':rugby_football:' : 'rugby_football.png',\n':bowling:' : 'bowling.png',\n':golf:' : 'golf.png',\n':mountain_bicyclist:' : 'mountain_bicyclist.png',\n':bicyclist:' : 'bicyclist.png',\n':horse_racing:' : 'horse_racing.png',\n':snowboarder:' : 'snowboarder.png',\n':swimmer:' : 'swimmer.png',\n':surfer:' : 'surfer.png',\n':ski:' : 'ski.png',\n':spades:' : 'spades.png',\n':hearts:' : 'hearts.png',\n':clubs:' : 'clubs.png',\n':diamonds:' : 'diamonds.png',\n':gem:' : 'gem.png',\n':ring:' : 'ring.png',\n':trophy:' : 'trophy.png',\n':musical_score:' : 'musical_score.png',\n':musical_keyboard:' : 'musical_keyboard.png',\n':violin:' : 'violin.png',\n':space_invader:' : 'space_invader.png',\n':video_game:' : 'video_game.png',\n':black_joker:' : 'black_joker.png',\n':flower_playing_cards:' : 'flower_playing_cards.png',\n':game_die:' : 'game_die.png',\n':dart:' : 'dart.png',\n':mahjong:' : 'mahjong.png',\n':clapper:' : 'clapper.png',\n':memo:' : 'memo.png',\n':pencil:' : 'pencil.png',\n':book:' : 'book.png',\n':art:' : 'art.png',\n':microphone:' : 'microphone.png',\n':headphones:' : 'headphones.png',\n':trumpet:' : 'trumpet.png',\n':saxophone:' : 'saxophone.png',\n':guitar:' : 'guitar.png',\n':shoe:' : 'shoe.png',\n':sandal:' : 'sandal.png',\n':high_heel:' : 'high_heel.png',\n':lipstick:' : 'lipstick.png',\n':boot:' : 'boot.png',\n':shirt:' : 'shirt.png',\n':tshirt:' : 'tshirt.png',\n':necktie:' : 'necktie.png',\n':womans_clothes:' : 'womans_clothes.png',\n':dress:' : 'dress.png',\n':running_shirt_with_sash:' : 'running_shirt_with_sash.png',\n':jeans:' : 'jeans.png',\n':kimono:' : 'kimono.png',\n':bikini:' : 'bikini.png',\n':ribbon:' : 'ribbon.png',\n':tophat:' : 'tophat.png',\n':crown:' : 'crown.png',\n':womans_hat:' : 'womans_hat.png',\n':mans_shoe:' : 'mans_shoe.png',\n':closed_umbrella:' : 'closed_umbrella.png',\n':briefcase:' : 'briefcase.png',\n':handbag:' : 'handbag.png',\n':pouch:' : 'pouch.png',\n':purse:' : 'purse.png',\n':eyeglasses:' : 'eyeglasses.png',\n':fishing_pole_and_fish:' : 'fishing_pole_and_fish.png',\n':coffee:' : 'coffee.png',\n':tea:' : 'tea.png',\n':sake:' : 'sake.png',\n':baby_bottle:' : 'baby_bottle.png',\n':beer:' : 'beer.png',\n':beers:' : 'beers.png',\n':cocktail:' : 'cocktail.png',\n':tropical_drink:' : 'tropical_drink.png',\n':wine_glass:' : 'wine_glass.png',\n':fork_and_knife:' : 'fork_and_knife.png',\n':pizza:' : 'pizza.png',\n':hamburger:' : 'hamburger.png',\n':fries:' : 'fries.png',\n':poultry_leg:' : 'poultry_leg.png',\n':meat_on_bone:' : 'meat_on_bone.png',\n':spaghetti:' : 'spaghetti.png',\n':curry:' : 'curry.png',\n':fried_shrimp:' : 'fried_shrimp.png',\n':bento:' : 'bento.png',\n':sushi:' : 'sushi.png',\n':fish_cake:' : 'fish_cake.png',\n':rice_ball:' : 'rice_ball.png',\n':rice_cracker:' : 'rice_cracker.png',\n':rice:' : 'rice.png',\n':ramen:' : 'ramen.png',\n':stew:' : 'stew.png',\n':oden:' : 'oden.png',\n':dango:' : 'dango.png',\n':egg:' : 'egg.png',\n':bread:' : 'bread.png',\n':doughnut:' : 'doughnut.png',\n':custard:' : 'custard.png',\n':icecream:' : 'icecream.png',\n':ice_cream:' : 'ice_cream.png',\n':shaved_ice:' : 'shaved_ice.png',\n':birthday:' : 'birthday.png',\n':cake:' : 'cake.png',\n':cookie:' : 'cookie.png',\n':chocolate_bar:' : 'chocolate_bar.png',\n':candy:' : 'candy.png',\n':lollipop:' : 'lollipop.png',\n':honey_pot:' : 'honey_pot.png',\n':apple:' : 'apple.png',\n':green_apple:' : 'green_apple.png',\n':tangerine:' : 'tangerine.png',\n':lemon:' : 'lemon.png',\n':cherries:' : 'cherries.png',\n':grapes:' : 'grapes.png',\n':watermelon:' : 'watermelon.png',\n':strawberry:' : 'strawberry.png',\n':peach:' : 'peach.png',\n':melon:' : 'melon.png',\n':banana:' : 'banana.png',\n':pear:' : 'pear.png',\n':pineapple:' : 'pineapple.png',\n':sweet_potato:' : 'sweet_potato.png',\n':eggplant:' : 'eggplant.png',\n':tomato:' : 'tomato.png'}},\n{'name': '<i class=\"icon-location\"></i>', 'icons' : {':corn:' : 'corn.png',\n':house:' : 'house.png',\n':house_with_garden:' : 'house_with_garden.png',\n':school:' : 'school.png',\n':office:' : 'office.png',\n':post_office:' : 'post_office.png',\n':hospital:' : 'hospital.png',\n':bank:' : 'bank.png',\n':convenience_store:' : 'convenience_store.png',\n':love_hotel:' : 'love_hotel.png',\n':hotel:' : 'hotel.png',\n':wedding:' : 'wedding.png',\n':church:' : 'church.png',\n':department_store:' : 'department_store.png',\n':european_post_office:' : 'european_post_office.png',\n':city_sunrise:' : 'city_sunrise.png',\n':city_sunset:' : 'city_sunset.png',\n':japanese_castle:' : 'japanese_castle.png',\n':european_castle:' : 'european_castle.png',\n':tent:' : 'tent.png',\n':factory:' : 'factory.png',\n':tokyo_tower:' : 'tokyo_tower.png',\n':japan:' : 'japan.png',\n':mount_fuji:' : 'mount_fuji.png',\n':sunrise_over_mountains:' : 'sunrise_over_mountains.png',\n':sunrise:' : 'sunrise.png',\n':stars:' : 'stars.png',\n':statue_of_liberty:' : 'statue_of_liberty.png',\n':bridge_at_night:' : 'bridge_at_night.png',\n':carousel_horse:' : 'carousel_horse.png',\n':rainbow:' : 'rainbow.png',\n':ferris_wheel:' : 'ferris_wheel.png',\n':fountain:' : 'fountain.png',\n':roller_coaster:' : 'roller_coaster.png',\n':ship:' : 'ship.png',\n':speedboat:' : 'speedboat.png',\n':boat:' : 'boat.png',\n':sailboat:' : 'sailboat.png',\n':rowboat:' : 'rowboat.png',\n':anchor:' : 'anchor.png',\n':rocket:' : 'rocket.png',\n':airplane:' : 'airplane.png',\n':helicopter:' : 'helicopter.png',\n':steam_locomotive:' : 'steam_locomotive.png',\n':tram:' : 'tram.png',\n':mountain_railway:' : 'mountain_railway.png',\n':bike:' : 'bike.png',\n':aerial_tramway:' : 'aerial_tramway.png',\n':suspension_railway:' : 'suspension_railway.png',\n':mountain_cableway:' : 'mountain_cableway.png',\n':tractor:' : 'tractor.png',\n':blue_car:' : 'blue_car.png',\n':oncoming_automobile:' : 'oncoming_automobile.png',\n':car:' : 'car.png',\n':red_car:' : 'red_car.png',\n':taxi:' : 'taxi.png',\n':oncoming_taxi:' : 'oncoming_taxi.png',\n':articulated_lorry:' : 'articulated_lorry.png',\n':bus:' : 'bus.png',\n':oncoming_bus:' : 'oncoming_bus.png',\n':rotating_light:' : 'rotating_light.png',\n':police_car:' : 'police_car.png',\n':oncoming_police_car:' : 'oncoming_police_car.png',\n':fire_engine:' : 'fire_engine.png',\n':ambulance:' : 'ambulance.png',\n':minibus:' : 'minibus.png',\n':truck:' : 'truck.png',\n':train:' : 'train.png',\n':station:' : 'station.png',\n':train2:' : 'train2.png',\n':bullettrain_front:' : 'bullettrain_front.png',\n':bullettrain_side:' : 'bullettrain_side.png',\n':light_rail:' : 'light_rail.png',\n':monorail:' : 'monorail.png',\n':railway_car:' : 'railway_car.png',\n':trolleybus:' : 'trolleybus.png',\n':ticket:' : 'ticket.png',\n':fuelpump:' : 'fuelpump.png',\n':vertical_traffic_light:' : 'vertical_traffic_light.png',\n':traffic_light:' : 'traffic_light.png',\n':warning:' : 'warning.png',\n':construction:' : 'construction.png',\n':beginner:' : 'beginner.png',\n':atm:' : 'atm.png',\n':slot_machine:' : 'slot_machine.png',\n':busstop:' : 'busstop.png',\n':barber:' : 'barber.png',\n':hotsprings:' : 'hotsprings.png',\n':checkered_flag:' : 'checkered_flag.png',\n':crossed_flags:' : 'crossed_flags.png',\n':izakaya_lantern:' : 'izakaya_lantern.png',\n':moyai:' : 'moyai.png',\n':circus_tent:' : 'circus_tent.png',\n':performing_arts:' : 'performing_arts.png',\n':round_pushpin:' : 'round_pushpin.png',\n':triangular_flag_on_post:' : 'triangular_flag_on_post.png',\n':jp:' : 'jp.png',\n':kr:' : 'kr.png',\n':cn:' : 'cn.png',\n':us:' : 'us.png',\n':fr:' : 'fr.png',\n':es:' : 'es.png',\n':it:' : 'it.png',\n':ru:' : 'ru.png',\n':gb:' : 'gb.png',\n':uk:' : 'uk.png',\n':de:' : 'de.png'}},\n{'name': '<i class=\"icon-dollar\"></i>', 'icons' : {':one:' : 'one.png',\n':two:' : 'two.png',\n':three:' : 'three.png',\n':four:' : 'four.png',\n':five:' : 'five.png',\n':six:' : 'six.png',\n':seven:' : 'seven.png',\n':eight:' : 'eight.png',\n':nine:' : 'nine.png',\n':keycap_ten:' : 'keycap_ten.png',\n':1234:' : '1234.png',\n':zero:' : 'zero.png',\n':hash:' : 'hash.png',\n':symbols:' : 'symbols.png',\n':arrow_backward:' : 'arrow_backward.png',\n':arrow_down:' : 'arrow_down.png',\n':arrow_forward:' : 'arrow_forward.png',\n':arrow_left:' : 'arrow_left.png',\n':capital_abcd:' : 'capital_abcd.png',\n':abcd:' : 'abcd.png',\n':abc:' : 'abc.png',\n':arrow_lower_left:' : 'arrow_lower_left.png',\n':arrow_lower_right:' : 'arrow_lower_right.png',\n':arrow_right:' : 'arrow_right.png',\n':arrow_up:' : 'arrow_up.png',\n':arrow_upper_left:' : 'arrow_upper_left.png',\n':arrow_upper_right:' : 'arrow_upper_right.png',\n':arrow_double_down:' : 'arrow_double_down.png',\n':arrow_double_up:' : 'arrow_double_up.png',\n':arrow_down_small:' : 'arrow_down_small.png',\n':arrow_heading_down:' : 'arrow_heading_down.png',\n':arrow_heading_up:' : 'arrow_heading_up.png',\n':leftwards_arrow_with_hook:' : 'leftwards_arrow_with_hook.png',\n':arrow_right_hook:' : 'arrow_right_hook.png',\n':left_right_arrow:' : 'left_right_arrow.png',\n':arrow_up_down:' : 'arrow_up_down.png',\n':arrow_up_small:' : 'arrow_up_small.png',\n':arrows_clockwise:' : 'arrows_clockwise.png',\n':arrows_counterclockwise:' : 'arrows_counterclockwise.png',\n':rewind:' : 'rewind.png',\n':fast_forward:' : 'fast_forward.png',\n':information_source:' : 'information_source.png',\n':ok:' : 'ok.png',\n':twisted_rightwards_arrows:' : 'twisted_rightwards_arrows.png',\n':repeat:' : 'repeat.png',\n':repeat_one:' : 'repeat_one.png',\n':new:' : 'new.png',\n':top:' : 'top.png',\n':up:' : 'up.png',\n':cool:' : 'cool.png',\n':free:' : 'free.png',\n':ng:' : 'ng.png',\n':cinema:' : 'cinema.png',\n':koko:' : 'koko.png',\n':signal_strength:' : 'signal_strength.png',\n':u5272:' : 'u5272.png',\n':u5408:' : 'u5408.png',\n':u55b6:' : 'u55b6.png',\n':u6307:' : 'u6307.png',\n':u6708:' : 'u6708.png',\n':u6709:' : 'u6709.png',\n':u6e80:' : 'u6e80.png',\n':u7121:' : 'u7121.png',\n':u7533:' : 'u7533.png',\n':u7a7a:' : 'u7a7a.png',\n':u7981:' : 'u7981.png',\n':sa:' : 'sa.png',\n':restroom:' : 'restroom.png',\n':mens:' : 'mens.png',\n':womens:' : 'womens.png',\n':baby_symbol:' : 'baby_symbol.png',\n':no_smoking:' : 'no_smoking.png',\n':parking:' : 'parking.png',\n':wheelchair:' : 'wheelchair.png',\n':metro:' : 'metro.png',\n':baggage_claim:' : 'baggage_claim.png',\n':accept:' : 'accept.png',\n':wc:' : 'wc.png',\n':potable_water:' : 'potable_water.png',\n':put_litter_in_its_place:' : 'put_litter_in_its_place.png',\n':secret:' : 'secret.png',\n':congratulations:' : 'congratulations.png',\n':m:' : 'm.png',\n':passport_control:' : 'passport_control.png',\n':left_luggage:' : 'left_luggage.png',\n':customs:' : 'customs.png',\n':ideograph_advantage:' : 'ideograph_advantage.png',\n':cl:' : 'cl.png',\n':sos:' : 'sos.png',\n':id:' : 'id.png',\n':no_entry_sign:' : 'no_entry_sign.png',\n':underage:' : 'underage.png',\n':no_mobile_phones:' : 'no_mobile_phones.png',\n':do_not_litter:' : 'do_not_litter.png',\n':non-potable_water:' : 'non-potable_water.png',\n':no_bicycles:' : 'no_bicycles.png',\n':no_pedestrians:' : 'no_pedestrians.png',\n':children_crossing:' : 'children_crossing.png',\n':no_entry:' : 'no_entry.png',\n':eight_spoked_asterisk:' : 'eight_spoked_asterisk.png',\n':eight_pointed_black_star:' : 'eight_pointed_black_star.png',\n':heart_decoration:' : 'heart_decoration.png',\n':vs:' : 'vs.png',\n':vibration_mode:' : 'vibration_mode.png',\n':mobile_phone_off:' : 'mobile_phone_off.png',\n':chart:' : 'chart.png',\n':currency_exchange:' : 'currency_exchange.png',\n':aries:' : 'aries.png',\n':taurus:' : 'taurus.png',\n':gemini:' : 'gemini.png',\n':cancer:' : 'cancer.png',\n':leo:' : 'leo.png',\n':virgo:' : 'virgo.png',\n':libra:' : 'libra.png',\n':scorpius:' : 'scorpius.png',\n':sagittarius:' : 'sagittarius.png',\n':capricorn:' : 'capricorn.png',\n':aquarius:' : 'aquarius.png',\n':pisces:' : 'pisces.png',\n':ophiuchus:' : 'ophiuchus.png',\n':six_pointed_star:' : 'six_pointed_star.png',\n':negative_squared_cross_mark:' : 'negative_squared_cross_mark.png',\n':a:' : 'a.png',\n':b:' : 'b.png',\n':ab:' : 'ab.png',\n':o2:' : 'o2.png',\n':diamond_shape_with_a_dot_inside:' : 'diamond_shape_with_a_dot_inside.png',\n':recycle:' : 'recycle.png',\n':end:' : 'end.png',\n':on:' : 'on.png',\n':soon:' : 'soon.png',\n':clock1:' : 'clock1.png',\n':clock130:' : 'clock130.png',\n':clock10:' : 'clock10.png',\n':clock1030:' : 'clock1030.png',\n':clock11:' : 'clock11.png',\n':clock1130:' : 'clock1130.png',\n':clock12:' : 'clock12.png',\n':clock1230:' : 'clock1230.png',\n':clock2:' : 'clock2.png',\n':clock230:' : 'clock230.png',\n':clock3:' : 'clock3.png',\n':clock330:' : 'clock330.png',\n':clock4:' : 'clock4.png',\n':clock430:' : 'clock430.png',\n':clock5:' : 'clock5.png',\n':clock530:' : 'clock530.png',\n':clock6:' : 'clock6.png',\n':clock630:' : 'clock630.png',\n':clock7:' : 'clock7.png',\n':clock730:' : 'clock730.png',\n':clock8:' : 'clock8.png',\n':clock830:' : 'clock830.png',\n':clock9:' : 'clock9.png',\n':clock930:' : 'clock930.png',\n':heavy_dollar_sign:' : 'heavy_dollar_sign.png',\n':copyright:' : 'copyright.png',\n':registered:' : 'registered.png',\n':tm:' : 'tm.png',\n':x:' : 'x.png',\n':heavy_exclamation_mark:' : 'heavy_exclamation_mark.png',\n':bangbang:' : 'bangbang.png',\n':interrobang:' : 'interrobang.png',\n':o:' : 'o.png',\n':heavy_multiplication_x:' : 'heavy_multiplication_x.png',\n':heavy_plus_sign:' : 'heavy_plus_sign.png',\n':heavy_minus_sign:' : 'heavy_minus_sign.png',\n':heavy_division_sign:' : 'heavy_division_sign.png',\n':white_flower:' : 'white_flower.png',\n':100:' : '100.png',\n':heavy_check_mark:' : 'heavy_check_mark.png',\n':ballot_box_with_check:' : 'ballot_box_with_check.png',\n':radio_button:' : 'radio_button.png',\n':link:' : 'link.png',\n':curly_loop:' : 'curly_loop.png',\n':wavy_dash:' : 'wavy_dash.png',\n':part_alternation_mark:' : 'part_alternation_mark.png',\n':trident:' : 'trident.png',\n':black_square:' : 'black_square.png',\n':white_check_mark:' : 'white_check_mark.png',\n':black_square_button:' : 'black_square_button.png',\n':white_square_button:' : 'white_square_button.png',\n':black_circle:' : 'black_circle.png',\n':white_circle:' : 'white_circle.png',\n':red_circle:' : 'red_circle.png',\n':large_blue_circle:' : 'large_blue_circle.png',\n':large_blue_diamond:' : 'large_blue_diamond.png',\n':large_orange_diamond:' : 'large_orange_diamond.png',\n':small_blue_diamond:' : 'small_blue_diamond.png',\n':small_orange_diamond:' : 'small_orange_diamond.png',\n':small_red_triangle:' : 'small_red_triangle.png',\n':small_red_triangle_down:' : 'small_red_triangle_down.png',\n':shipit:' : 'shipit.png',\n}}, ];"
  },
  {
    "path": "public/jquery-emojiarea/jquery.emojiarea.css",
    "content": ".emoji-wysiwyg-editor {\n\tborder: 1px solid #d0d0d0;\n\toverflow: auto;\n\toutline: none;\n}\n.emoji-wysiwyg-editor img {\n\twidth: 20px;\n\theight: 20px;\n\tvertical-align: middle;\n\tmargin: -3px 0 0 0;\n}\n.emoji-menu {\n\tposition: absolute;\n\tz-index: 999;\n\twidth: 180px;\n\tmargin-left: -100px;\n\tpadding: 0;\n\toverflow: hidden;\n\t-webkit-box-sizing: border-box;\n\t-moz-box-sizing: border-box;\n\tbox-sizing: border-box;\n}\n.emoji-menu > div {\n\tmax-height: 200px;\n\toverflow: hidden;\n\tbackground: #fff;\n\twidth: 200px;\n\t-webkit-overflow-scrolling: touch;\n\toverflow: auto;\n\t-webkit-border-radius: 3px;\n\t-moz-border-radius: 3px;\n\tborder-radius: 3px;\n\t-webkit-box-sizing: border-box;\n\t-moz-box-sizing: border-box;\n\tbox-sizing: border-box;\n\t-webkit-box-shadow: 0 1px 5px rgba(0,0,0,0.3);\n\t-moz-box-shadow: 0 1px 5px rgba(0,0,0,0.3);\n\tbox-shadow: 0 1px 5px rgba(0,0,0,0.3);\n\tpadding-top: 40px;\n}\n.emoji-menu img {\n\twidth: 25px;\n\theight: 25px;\n\tvertical-align: middle;\n\tborder: 0 none;\n}\n.emoji-menu a {\n\tmargin: -1px 0 0 -1px;\n\tborder: 1px solid #f2f2f2;\n\tpadding: 5px;\n\tdisplay: block;\n\tfloat: left;\n}\n.emoji-menu a:hover {\n\tbackground-color: #fffae7;\n}\n.emoji-menu:after {\n\tcontent: ' ';\n\tdisplay: block;\n\tclear: left;\n}\n.emoji-menu a .label {\n\tdisplay: none;\n}\n\n.emoji-menu div {\n    overflow-x: hidden;\n    overflow-y: auto;   \n}\n\n.emoji-menu .group-selector {\n\tposition: absolute;\n\tlist-style-type: none;\n\theight: 40px;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\tbackground-color: rgb(255,255,255);\n\tbackground-color: rgba(255,255,255, .9);\n}\n.emoji-menu .group-selector li {\n\theight: 15px;\n\twidth: 17px;\n\tpadding: 5px;\n}\n.emoji-menu .group-selector a:last-child li {\n\twidth: 15px;\n}\n.emoji-menu .group-selector a {\n\tcolor: #EB7878;\n\ttext-decoration: none;\n\tborder: none;\n\tbackground-color: transparent;\n}\n.emoji-menu .group-selector a:hover, .emoji-menu .group-selector a.active {\n\tcolor:#000000;\n}"
  },
  {
    "path": "public/jquery-emojiarea/jquery.emojiarea.js",
    "content": "/**\n * emojiarea - A rich textarea control that supports emojis, WYSIWYG-style.\n * Copyright (c) 2012 DIY Co\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this\n * file except in compliance with the License. You may obtain a copy of the License at:\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software distributed under\n * the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF\n * ANY KIND, either express or implied. See the License for the specific language\n * governing permissions and limitations under the License.\n *\n * @author Brian Reavis <brian@diy.org>\n */\n\n(function($, window, document) {\n\n\tvar ELEMENT_NODE = 1;\n\tvar TEXT_NODE = 3;\n\tvar TAGS_BLOCK = ['p', 'div', 'pre', 'form'];\n\tvar KEY_ESC = 27;\n\tvar KEY_TAB = 9;\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\t$.emojiarea = {\n\t\tpath: '',\n\t\ticons: {},\n\t\tdefaults: {\n\t\t\tbutton: null,\n\t\t\tbuttonLabel: 'Emojis',\n\t\t\tbuttonPosition: 'after'\n\t\t}\n\t};\n\n\t$.fn.emojiarea = function(options) {\n\t\toptions = $.extend({}, $.emojiarea.defaults, options);\n\t\treturn this.each(function() {\n\t\t\tvar $textarea = $(this);\n\t\t\tif ('contentEditable' in document.body && options.wysiwyg !== false) {\n\t\t\t\tnew EmojiArea_WYSIWYG($textarea, options);\n\t\t\t} else {\n\t\t\t\tnew EmojiArea_Plain($textarea, options);\n\t\t\t}\n\t\t});\n\t};\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\tvar util = {};\n\n\tutil.restoreSelection = (function() {\n\t\tif (window.getSelection) {\n\t\t\treturn function(savedSelection) {\n\t\t\t\tvar sel = window.getSelection();\n\t\t\t\tsel.removeAllRanges();\n\t\t\t\tfor (var i = 0, len = savedSelection.length; i < len; ++i) {\n\t\t\t\t\tsel.addRange(savedSelection[i]);\n\t\t\t\t}\n\t\t\t};\n\t\t} else if (document.selection && document.selection.createRange) {\n\t\t\treturn function(savedSelection) {\n\t\t\t\tif (savedSelection) {\n\t\t\t\t\tsavedSelection.select();\n\t\t\t\t}\n\t\t\t};\n\t\t}\n\t})();\n\n\tutil.saveSelection = (function() {\n\t\tif (window.getSelection) {\n\t\t\treturn function() {\n\t\t\t\tvar sel = window.getSelection(), ranges = [];\n\t\t\t\tif (sel.rangeCount) {\n\t\t\t\t\tfor (var i = 0, len = sel.rangeCount; i < len; ++i) {\n\t\t\t\t\t\tranges.push(sel.getRangeAt(i));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn ranges;\n\t\t\t};\n\t\t} else if (document.selection && document.selection.createRange) {\n\t\t\treturn function() {\n\t\t\t\tvar sel = document.selection;\n\t\t\t\treturn (sel.type.toLowerCase() !== 'none') ? sel.createRange() : null;\n\t\t\t};\n\t\t}\n\t})();\n\n\tutil.replaceSelection = (function() {\n\t\tif (window.getSelection) {\n\t\t\treturn function(content) {\n\t\t\t\tvar range, sel = window.getSelection();\n\t\t\t\tvar node = typeof content === 'string' ? document.createTextNode(content) : content;\n\t\t\t\tif (sel.getRangeAt && sel.rangeCount) {\n\t\t\t\t\trange = sel.getRangeAt(0);\n\t\t\t\t\trange.deleteContents();\n\t\t\t\t\trange.insertNode(document.createTextNode(' '));\n\t\t\t\t\trange.insertNode(node);\n\t\t\t\t\trange.setStart(node, 0);\n\n\t\t\t\t\twindow.setTimeout(function() {\n\t\t\t\t\t\trange = document.createRange();\n\t\t\t\t\t\trange.setStartAfter(node);\n\t\t\t\t\t\trange.collapse(true);\n\t\t\t\t\t\tsel.removeAllRanges();\n\t\t\t\t\t\tsel.addRange(range);\n\t\t\t\t\t}, 0);\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (document.selection && document.selection.createRange) {\n\t\t\treturn function(content) {\n\t\t\t\tvar range = document.selection.createRange();\n\t\t\t\tif (typeof content === 'string') {\n\t\t\t\t\trange.text = content;\n\t\t\t\t} else {\n\t\t\t\t\trange.pasteHTML(content.outerHTML);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})();\n\n\tutil.insertAtCursor = function(text, el) {\n\t\ttext = ' ' + text;\n\t\tvar val = el.value, endIndex, startIndex, range;\n\t\tif (typeof el.selectionStart != 'undefined' && typeof el.selectionEnd != 'undefined') {\n\t\t\tstartIndex = el.selectionStart;\n\t\t\tendIndex = el.selectionEnd;\n\t\t\tel.value = val.substring(0, startIndex) + text + val.substring(el.selectionEnd);\n\t\t\tel.selectionStart = el.selectionEnd = startIndex + text.length;\n\t\t} else if (typeof document.selection != 'undefined' && typeof document.selection.createRange != 'undefined') {\n\t\t\tel.focus();\n\t\t\trange = document.selection.createRange();\n\t\t\trange.text = text;\n\t\t\trange.select();\n\t\t}\n\t};\n\n\tutil.extend = function(a, b) {\n\t\tif (typeof a === 'undefined' || !a) { a = {}; }\n\t\tif (typeof b === 'object') {\n\t\t\tfor (var key in b) {\n\t\t\t\tif (b.hasOwnProperty(key)) {\n\t\t\t\t\ta[key] = b[key];\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn a;\n\t};\n\n\tutil.escapeRegex = function(str) {\n\t\treturn (str + '').replace(/([.?*+^$[\\]\\\\(){}|-])/g, '\\\\$1');\n\t};\n\n\tutil.htmlEntities = function(str) {\n\t\treturn String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n\t};\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\tvar EmojiArea = function() {};\n\n\tEmojiArea.prototype.setup = function() {\n\t\tvar self = this;\n\n\t\tthis.$editor.on('focus', function() { self.hasFocus = true; });\n\t\tthis.$editor.on('blur', function() { self.hasFocus = false; });\n\n\t\tthis.setupButton();\n\t};\n\n\tEmojiArea.prototype.setupButton = function() {\n\t\tvar self = this;\n\t\tvar $button;\n\n\t\tif (this.options.button) {\n\t\t\t$button = $(this.options.button);\n\t\t} else if (this.options.button !== false) {\n\t\t\t$button = $('<a href=\"javascript:void(0)\">');\n\t\t\t$button.html(this.options.buttonLabel);\n\t\t\t$button.addClass('emoji-button');\n\t\t\t$button.attr({title: this.options.buttonLabel});\n\t\t\tthis.$editor[this.options.buttonPosition]($button);\n\t\t} else {\n\t\t\t$button = $('');\n\t\t}\n\n\t\t$button.on('click', function(e) {\n\t\t\tEmojiMenu.show(self);\n\t\t\te.stopPropagation();\n\t\t});\n\n\t\tthis.$button = $button;\n\t};\n\n\tEmojiArea.createIcon = function(group, emoji) {\n\t\tvar filename = $.emojiarea.icons[group]['icons'][emoji];\n\t\tvar path = $.emojiarea.path || '';\n\t\tif (path.length && path.charAt(path.length - 1) !== '/') {\n\t\t\tpath += '/';\n\t\t}\n\t\treturn '<img src=\"' + path + filename + '\" alt=\"' + util.htmlEntities(emoji) + '\">';\n\t};\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\t/**\n\t * Editor (plain-text)\n\t *\n\t * @constructor\n\t * @param {object} $textarea\n\t * @param {object} options\n\t */\n\n\tvar EmojiArea_Plain = function($textarea, options) {\n\t\tthis.options = options;\n\t\tthis.$textarea = $textarea;\n\t\tthis.$editor = $textarea;\n\t\tthis.setup();\n\t};\n\n\tEmojiArea_Plain.prototype.insert = function(group, emoji) {\n\t\tif (!$.emojiarea.icons[group]['icons'].hasOwnProperty(emoji)) return;\n\t\tutil.insertAtCursor(emoji, this.$textarea[0]);\n\t\tthis.$textarea.trigger('change');\n\t};\n\n\tEmojiArea_Plain.prototype.val = function() {\n\t\treturn this.$textarea.val();\n\t};\n\n\tutil.extend(EmojiArea_Plain.prototype, EmojiArea.prototype);\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\t/**\n\t * Editor (rich)\n\t *\n\t * @constructor\n\t * @param {object} $textarea\n\t * @param {object} options\n\t */\n\n\tvar EmojiArea_WYSIWYG = function($textarea, options) {\n\t\tvar self = this;\n\n\t\tthis.options = options;\n\t\tthis.$textarea = $textarea;\n\t\tthis.$editor = $('<div>').addClass('emoji-wysiwyg-editor');\n\t\tthis.$editor.text($textarea.val());\n\t\tthis.$editor.attr({contenteditable: 'true'});\n\t\tthis.$editor.on('blur keyup paste', function() { return self.onChange.apply(self, arguments); });\n\t\tthis.$editor.on('mousedown focus', function() { document.execCommand('enableObjectResizing', false, false); });\n\t\tthis.$editor.on('blur', function() { document.execCommand('enableObjectResizing', true, true); });\n\n\t\tvar html = this.$editor.text();\n\t\tvar emojis = $.emojiarea.icons;\n\t\tfor (var group in emojis) {\n\t\t\tfor (var key in emojis[group]['icons']) {\n\t\t\t\tif (emojis[group]['icons'].hasOwnProperty(key)) {\n\t\t\t\t\thtml = html.replace(new RegExp(util.escapeRegex(key), 'g'), EmojiArea.createIcon(group, key));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.$editor.html(html);\n\n\t\t$textarea.hide().after(this.$editor);\n\n\t\tthis.setup();\n\n\t\tthis.$button.on('mousedown', function() {\n\t\t\tif (self.hasFocus) {\n\t\t\t\tself.selection = util.saveSelection();\n\t\t\t}\n\t\t});\n\t};\n\n\tEmojiArea_WYSIWYG.prototype.onChange = function() {\n\t\tthis.$textarea.val(this.val()).trigger('change');\n\t};\n\n\tEmojiArea_WYSIWYG.prototype.insert = function(group, emoji) {\n\t\tvar content;\n\t\tvar $img = $(EmojiArea.createIcon(group, emoji));\n\t\tif ($img[0].attachEvent) {\n\t\t\t$img[0].attachEvent('onresizestart', function(e) { e.returnValue = false; }, false);\n\t\t}\n\n\t\tthis.$editor.trigger('focus');\n\t\tif (this.selection) {\n\t\t\tutil.restoreSelection(this.selection);\n\t\t}\n\t\ttry { util.replaceSelection($img[0]); } catch (e) {}\n\t\tthis.onChange();\n\t};\n\n\tEmojiArea_WYSIWYG.prototype.val = function() {\n\t\tvar lines = [];\n\t\tvar line  = [];\n\n\t\tvar flush = function() {\n\t\t\tlines.push(line.join(''));\n\t\t\tline = [];\n\t\t};\n\n\t\tvar sanitizeNode = function(node) {\n\t\t\tif (node.nodeType === TEXT_NODE) {\n\t\t\t\tline.push(node.nodeValue);\n\t\t\t} else if (node.nodeType === ELEMENT_NODE) {\n\t\t\t\tvar tagName = node.tagName.toLowerCase();\n\t\t\t\tvar isBlock = TAGS_BLOCK.indexOf(tagName) !== -1;\n\n\t\t\t\tif (isBlock && line.length) flush();\n\n\t\t\t\tif (tagName === 'img') {\n\t\t\t\t\tvar alt = node.getAttribute('alt') || '';\n\t\t\t\t\tif (alt) line.push(alt);\n\t\t\t\t\treturn;\n\t\t\t\t} else if (tagName === 'br') {\n\t\t\t\t\tflush();\n\t\t\t\t}\n\n\t\t\t\tvar children = node.childNodes;\n\t\t\t\tfor (var i = 0; i < children.length; i++) {\n\t\t\t\t\tsanitizeNode(children[i]);\n\t\t\t\t}\n\n\t\t\t\tif (isBlock && line.length) flush();\n\t\t\t}\n\t\t};\n\n\t\tvar children = this.$editor[0].childNodes;\n\t\tfor (var i = 0; i < children.length; i++) {\n\t\t\tsanitizeNode(children[i]);\n\t\t}\n\n\t\tif (line.length) flush();\n\n\t\treturn lines.join('\\n');\n\t};\n\n\tutil.extend(EmojiArea_WYSIWYG.prototype, EmojiArea.prototype);\n\n\t// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\n\n\t/**\n\t * Emoji Dropdown Menu\n\t *\n\t * @constructor\n\t * @param {object} emojiarea\n\t */\n\tvar EmojiMenu = function() {\n\t\tvar self = this;\n\t\tvar $body = $(document.body);\n\t\tvar $window = $(window);\n\n\t\tthis.visible = false;\n\t\tthis.emojiarea = null;\n\t\tthis.$menu = $('<div>');\n\t\tthis.$menu.addClass('emoji-menu');\n\t\tthis.$menu.hide();\n\t\tthis.$items = $('<div>').appendTo(this.$menu);\n\n\t\t$body.append(this.$menu);\n\n\t\t$body.on('keydown', function(e) {\n\t\t\tif (e.keyCode === KEY_ESC || e.keyCode === KEY_TAB) {\n\t\t\t\tself.hide();\n\t\t\t}\n\t\t});\n\n\t\t$body.on('mouseup', function() {\n\t\t\tself.hide();\n\t\t});\n\n\t\t$window.on('resize', function() {\n\t\t\tif (self.visible) self.reposition();\n\t\t});\n\n\t\tthis.$menu.on('mouseup', 'a', function(e) {\n\t\t\te.stopPropagation();\n\t\t\treturn false;\n\t\t});\n\n\t\tthis.$menu.on('click', 'a', function(e) {\n\t\t\tvar emoji = $('.label', $(this)).text();\n\t\t\tvar group = $('.label', $(this)).parent().parent().attr('group');\n\t\t\tif(group && emoji !== ''){\n\t\t\t\twindow.setTimeout(function() {\n\t\t\t\t\tself.onItemSelected.apply(self, [group, emoji]);\n\t\t\t\t}, 0);\n\t\t\t\te.stopPropagation();\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\n\t\tthis.load();\n\t};\n\n\tEmojiMenu.prototype.onItemSelected = function(group, emoji) {\n\t\tthis.emojiarea.insert(group, emoji);\n\t\tthis.hide();\n\t};\n\n\tEmojiMenu.prototype.load = function() {\n\t\tvar html = [];\n\t\tvar groups = [];\n\t\tvar options = $.emojiarea.icons;\n\t\tvar path = $.emojiarea.path;\n\t\tif (path.length && path.charAt(path.length - 1) !== '/') {\n\t\t\tpath += '/';\n\t\t}\n\t\tgroups.push('<ul class=\"group-selector\">');\n\t\tfor (var group in options) {\n\t\tgroups.push('<a href=\"#group_' + group + '\" class=\"tab_switch\"><li>' + options[group]['name'] + '</li></a>');\n\t\thtml.push('<div class=\"select_group\" group=\"' + group + '\" id=\"group_' + group + '\">');\n\t\t\tfor (var key in options[group]['icons']) {\n\t\t\t\tif (options[group]['icons'].hasOwnProperty(key)) {\n\t\t\t\t\tvar filename = options[key];\n\t\t\t\t\thtml.push('<a href=\"javascript:void(0)\" title=\"' + util.htmlEntities(key) + '\">' + EmojiArea.createIcon(group, key) + '<span class=\"label\">' + util.htmlEntities(key) + '</span></a>');\n\t\t\t\t}\n\t\t\t}\n\t\thtml.push('</div>');\n\t\t}\n\t\tgroups.push('</ul>');\n\t\tthis.$items.html(html.join(''));\n\t\tthis.$menu.prepend(groups.join(''));\n\t\tthis.$menu.find('.tab_switch').each(function(i) {\n\t\t\tif (i != 0) {\n\t\t\t\tvar select = $(this).attr('href');\n\t\t\t\t$(select).hide();\n\t\t\t} else {\n\t\t\t\t$(this).addClass('active');\n\t\t\t}\n\t\t\t$(this).click(function() {\n\t\t\t\t$(this).addClass('active');\n\t\t\t\t$(this).siblings().removeClass('active');\n\t\t\t\t$('.select_group').hide();\n\t\t\t\tvar select = $(this).attr('href');\n\t\t\t\t$(select).show();\n\t\t\t});\n\t\t});\n\t};\n\n\tEmojiMenu.prototype.reposition = function() {\n\t\tvar $button = this.emojiarea.$button;\n\t\tvar offset = $button.offset();\n\t\toffset.top += $button.outerHeight();\n\t\toffset.left += Math.round($button.outerWidth() / 2);\n\n\t\tthis.$menu.css({\n\t\t\ttop: offset.top,\n\t\t\tleft: offset.left\n\t\t});\n\t};\n\n\tEmojiMenu.prototype.hide = function(callback) {\n\t\tif (this.emojiarea) {\n\t\t\tthis.emojiarea.menu = null;\n\t\t\tthis.emojiarea.$button.removeClass('on');\n\t\t\tthis.emojiarea = null;\n\t\t}\n\t\tthis.visible = false;\n\t\tthis.$menu.hide();\n\t};\n\n\tEmojiMenu.prototype.show = function(emojiarea) {\n\t\tif (this.emojiarea && this.emojiarea === emojiarea) return;\n\t\tthis.emojiarea = emojiarea;\n\t\tthis.emojiarea.menu = this;\n\n\t\tthis.reposition();\n\t\tthis.$menu.show();\n\t\tthis.visible = true;\n\t};\n\n\tEmojiMenu.show = (function() {\n\t\tvar menu = null;\n\t\treturn function(emojiarea) {\n\t\t\tmenu = menu || new EmojiMenu();\n\t\t\tmenu.show(emojiarea);\n\t\t};\n\t})();\n\n})(jQuery, window, document);"
  },
  {
    "path": "public/member.js",
    "content": "// TODO: Push ImageFileExts to the client from the server in some sort of gen.js?\nvar imageExts = [\"png\",\"jpg\",\"jpe\",\"jpeg\",\"jif\",\"jfi\",\"jfif\",\"svg\",\"bmp\",\"gif\",\"tiff\",\"tif\",\"webp\"];\n\n(() => {\n\tfunction copyToClipboard(str) {\n\t\tconst el=document.createElement('textarea');\n\t\tel.value=str;\n\t\tel.setAttribute('readonly','');\n\t\tel.style.position='absolute';\n\t\tel.style.left='-9999px';\n\t\tdocument.body.appendChild(el);\n\t\tel.select();\n\t\tdocument.execCommand('copy');\n\t\tdocument.body.removeChild(el);\n\t}\n\n\tfunction uploadFileHandler(fileList, maxFiles=5, step1 = () => {}, step2 = () => {}) {\n\t\tlet files = [];\n\t\tfor(var i=0; i<fileList.length && i<5; i++) files[i] = fileList[i];\n\t\n\t\tlet totalSize = 0;\n\t\tfor(let i=0; i<files.length; i++) {\n\t\t\tlog(\"file \"+i,files[i]);\n\t\t\ttotalSize += files[i][\"size\"];\n\t\t}\n\t\tif(totalSize > me.Site.MaxReqSize) throw(\"You can't upload this much at once, max: \"+me.Site.MaxReqSize);\n\t\n\t\tfor(let i=0; i<files.length; i++) {\n\t\t\tlet fname = files[i][\"name\"];\n\t\t\tlet f = e => {\n\t\t\t\tstep1(e,fname)\n\t\t\t\t\t\n\t\t\t\tlet reader = new FileReader();\n\t\t\t\treader.onload = e2 => {\n\t\t\t\t\tcrypto.subtle.digest('SHA-256',e2.target.result)\n\t\t\t\t\t\t.then(hash => {\n\t\t\t\t\t\t\tconst hashArray = Array.from(new Uint8Array(hash))\n\t\t\t\t\t\t\treturn hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('')\n\t\t\t\t\t\t}).then(hash => step2(e,hash,fname));\n\t\t\t\t}\n\t\t\t\treader.readAsArrayBuffer(files[i]);\n\t\t\t};\n\t\t\t\t\n\t\t\tlet ext = getExt(fname);\n\t\t\t// TODO: Push ImageFileExts to the client from the server in some sort of gen.js?\n\t\t\tlet isImage = imageExts.includes(ext);\n\t\t\tif(isImage) {\n\t\t\t\tlet reader = new FileReader();\n\t\t\t\treader.onload = f;\n\t\t\t\treader.readAsDataURL(files[i]);\n\t\t\t} else f(null);\n\t\t}\n\t}\n\n\t// Attachment Manager\n\tfunction uploadAttachHandler2() {\n\t\tlet post = this.closest(\".post_item\");\n\t\tlet fileDock = this.closest(\".attach_edit_bay\");\n\t\ttry {\n\t\t\tuploadFileHandler(this.files, 5, () => {},\n\t\t\t(e,hash,fname) => {\n\t\t\t\tlog(\"hash\",hash);\n\t\t\t\tlet formData = new FormData();\n\t\t\t\tformData.append(\"s\",me.User.S);\n\t\t\t\tfor(let i=0; i<this.files.length; i++) formData.append(\"upload_files\",this.files[i]);\n\t\t\t\tbindAttachManager();\n\n\t\t\t\tlet req = new XMLHttpRequest();\n\t\t\t\treq.addEventListener(\"load\", () => {\n\t\t\t\t\tlet data = JSON.parse(req.responseText);\n\t\t\t\t\t//log(\"rdata\",data);\n\t\t\t\t\tlet fileItem = document.createElement(\"div\");\n\t\t\t\t\tlet ext = getExt(fname);\n\t\t\t\t\t// TODO: Push ImageFileExts to the client from the server in some sort of gen.js?\n\t\t\t\t\tlet isImage = imageExts.includes(ext);\n\t\t\t\t\tlet c = \"\";\n\t\t\t\t\tif(isImage) c = \" attach_image_holder\"\n\t\t\t\t\tfileItem.className = \"attach_item attach_item_item\"+c;\n\t\t\t\t\tfileItem.innerHTML = Tmpl_topic_c_attach_item({\n\t\t\t\t\t\tID: data.elems[hash+\".\"+ext],\n\t\t\t\t\t\tImgSrc: isImage ? e.target.result : \"\",\n\t\t\t\t\t\tPath: hash+\".\"+ext,\n\t\t\t\t\t\tFullPath: \"//\" + window.location.host + \"/attachs/\" + hash + \".\" + ext,\n\t\t\t\t\t});\n\t\t\t\t\tfileDock.insertBefore(fileItem,fileDock.querySelector(\".attach_item_buttons\"));\n\t\t\t\t\t\n\t\t\t\t\tpost.classList.add(\"has_attachs\");\n\t\t\t\t\tbindAttachItems();\n\t\t\t\t});\n\t\t\t\treq.open(\"POST\",\"//\"+window.location.host+\"/\"+fileDock.getAttribute(\"type\")+\"/attach/add/submit/\"+fileDock.getAttribute(\"id\"));\n\t\t\t\treq.send(formData);\n\t\t\t});\n\t\t} catch(e) {\n\t\t\t// TODO: Use a notice instead\n\t\t\tlog(\"e\",e);\n\t\t\talert(e);\n\t\t}\n\t}\n\n\t// Quick Topic / Quick Reply\n\tfunction uploadAttachHandler() {\n\t\ttry {\n\t\t\tuploadFileHandler(this.files,5,(e,fname) => {\n\t\t\t\t// TODO: Use client templates here\n\t\t\t\tlet fileDock = document.getElementById(\"upload_file_dock\");\n\t\t\t\tlet fileItem = document.createElement(\"label\");\n\t\t\t\tlog(\"fileItem\",fileItem);\n\n\t\t\t\tlet ext = getExt(fname);\n\t\t\t\t// TODO: Push ImageFileExts to the client from the server in some sort of gen.js?\n\t\t\t\tlet isImage = imageExts.includes(ext);\n\t\t\t\tfileItem.innerText = \".\"+ext;\n\t\t\t\tfileItem.className = \"formbutton uploadItem\";\n\t\t\t\t// TODO: Check if this is actually an image\n\t\t\t\tif(isImage) fileItem.style.backgroundImage = \"url(\"+e.target.result+\")\";\n\n\t\t\t\tfileDock.appendChild(fileItem);\n\t\t\t},(e,hash,fname) => {\n\t\t\t\tlog(\"hash\",hash);\n\t\t\t\tlet ext = getExt(fname)\n\t\t\t\tlet con = document.getElementById(\"input_content\")\n\t\t\t\tlog(\"con.value\",con.value);\n\t\t\t\t\n\t\t\t\tlet attachItem;\n\t\t\t\tif(con.value==\"\") attachItem = \"//\"+window.location.host+\"/attachs/\"+hash+\".\"+ext;\n\t\t\t\telse attachItem = \"\\r\\n//\"+window.location.host+\"/attachs/\"+hash+\".\"+ext;\n\t\t\t\tcon.value = con.value + attachItem;\n\t\t\t\tlog(\"con.value\",con.value);\n\t\t\t\t\n\t\t\t\t// For custom / third party text editors\n\t\t\t\tattachItemCallback(attachItem);\n\t\t\t});\n\t\t} catch(e) {\n\t\t\t// TODO: Use a notice instead\n\t\t\tlog(\"e\",e);\n\t\t\talert(e);\n\t\t}\n\t}\n\n\tfunction bindAttachManager() {\n\t\tlet uploadFiles = document.getElementsByClassName(\"upload_files_post\");\n\t\tif(uploadFiles==null) return;\n\t\tfor(let i=0; i<uploadFiles.length; i++) {\n\t\t\tlet uploader = uploadFiles[i];\n\t\t\tuploader.value = \"\";\n\t\t\tuploader.removeEventListener(\"change\", uploadAttachHandler2, false);\n\t\t\tuploader.addEventListener(\"change\", uploadAttachHandler2, false);\n\t\t}\n\t}\n\t\n\t//addInitHook(\"before_init_bind_page\", () => {\n\t//log(\"in member.js before_init_bind_page\")\n\taddInitHook(\"end_bind_topic\", () => {\n\tlog(\"in member.js end_bind_topic\")\n\n\tlet changeListener = (files,h) => {\n\t\tif(files!=null) {\n\t\t\tfiles.removeEventListener(\"change\", h, false);\n\t\t\tfiles.addEventListener(\"change\", h, false);\n\t\t}\n\t};\n\tlet uploadFiles = document.getElementById(\"upload_files\");\n\tchangeListener(uploadFiles,uploadAttachHandler);\n\tlet uploadFilesOp = document.getElementById(\"upload_files_op\");\n\tchangeListener(uploadFilesOp,uploadAttachHandler2);\n\tbindAttachManager();\n\t\t\n\tfunction bindAttachItems() {\n\t\t$(\".attach_item_select\").unbind(\"click\");\n\t\t$(\".attach_item_copy\").unbind(\"click\");\n\t\t$(\".attach_item_select\").click(function(){\n\t\t\tlet hold = $(this).closest(\".attach_item\");\n\t\t\tif(hold.hasClass(\"attach_item_selected\")) hold.removeClass(\"attach_item_selected\");\n\t\t\telse hold.addClass(\"attach_item_selected\");\n\t\t});\n\t\t$(\".attach_item_copy\").click(function(){\n\t\t\tlet hold = $(this).closest(\".attach_item\");\n\t\t\tlet pathNode = hold.find(\".attach_item_path\");\n\t\t\tcopyToClipboard(pathNode.attr(\"fullPath\"));\n\t\t});\n\t}\n\tbindAttachItems();\n\t\n\t$(\".attach_item_delete\").unbind(\"click\");\n\t$(\".attach_item_delete\").click(function(){\n\t\tlet formData = new URLSearchParams();\n\t\tformData.append(\"s\",me.User.S);\n\t\n\t\tlet post = this.closest(\".post_item\");\n\t\tlet aidList = \"\";\n\t\tlet elems = post.getElementsByClassName(\"attach_item_selected\");\n\t\tif(elems==null) return;\n\t\t\t\n\t\tfor(let i = 0; i < elems.length; i++) {\n\t\t\tlet pathNode = elems[i].querySelector(\".attach_item_path\");\n\t\t\tlog(\"pathNode\",pathNode);\n\t\t\taidList += pathNode.getAttribute(\"aid\")+\",\";\n\t\t\telems[i].remove();\n\t\t}\n\t\tif(aidList.length > 0) aidList = aidList.slice(0, -1);\n\t\tlog(\"aidList\",aidList)\n\t\tformData.append(\"aids\",aidList);\n\t\n\t\tlet ec = 0;\n\t\tlet e = post.getElementsByClassName(\"attach_item_item\");\n\t\tif(e!=null) ec = e.length;\n\t\tif(ec==0) post.classList.remove(\"has_attachs\");\n\t\t\t\n\t\tlet req = new XMLHttpRequest();\n\t\tlet fileDock = this.closest(\".attach_edit_bay\");\n\t\treq.open(\"POST\",\"//\"+window.location.host+\"/\"+fileDock.getAttribute(\"type\")+\"/attach/remove/submit/\"+fileDock.getAttribute(\"id\"),true);\n\t\treq.send(formData);\n\t\n\t\tbindAttachItems();\n\t\tbindAttachManager();\n\t});\n\t\n\tfunction addPollInput() {\n\t\tlog(\"clicked on pollinputinput\");\n\t\tlet dataPollInput = $(this).parent().attr(\"data-pollinput\");\n\t\tlog(\"dataPollInput\",dataPollInput);\n\t\tif(dataPollInput==undefined) return;\n\t\tif(dataPollInput!=(pollInputIndex-1)) return;\n\t\t$(\".poll_content_row .formitem\").append(Tmpl_topic_c_poll_input({\n\t\t\tIndex: pollInputIndex,\n\t\t\tPlace: phraseBox[\"topic\"][\"topic.reply_add_poll_option\"].replace(\"%d\",pollInputIndex),\n\t\t}));\n\t\tpollInputIndex++;\n\t\tlog(\"new pollInputIndex\",pollInputIndex);\n\t\t$(\".pollinputinput\").off(\"click\");\n\t\t$(\".pollinputinput\").click(addPollInput);\n\t}\n\t\n\tlet pollInputIndex = 1;\n\t$(\"#add_poll_button\").unbind(\"click\");\n\t$(\"#add_poll_button\").click(ev => {\n\t\tev.preventDefault();\n\t\t$(\".poll_content_row\").removeClass(\"auto_hide\");\n\t\t$(\"#has_poll_input\").val(\"1\");\n\t\t$(\".pollinputinput\").click(addPollInput);\n\t});\n\t});\n\t//});\n\tfunction modCancel() {\n\t\tlog(\"enter modCancel\");\n\t\tif(!$(\".mod_floater\").hasClass(\"auto_hide\")) $(\".mod_floater\").addClass(\"auto_hide\")\n\t\t$(\".moderate_link\").unbind(\"click\");\n\t\t$(\".moderate_link\").removeClass(\"moderate_open\");\n\t\t$(\".pre_opt\").addClass(\"auto_hide\");\n\t\t$(\".mod_floater_submit\").unbind(\"click\");\n\t\t$(\"#topicsItemList,#forumItemList\").removeClass(\"topics_moderate\");\n\t\t$(\".topic_selected\").removeClass(\"topic_selected\");\n\t\t// ! Be careful not to trample bindings elsewhere\n\t\t$(\".topic_row\").unbind(\"click\");\n\t\t$(\"#mod_topic_mover\").addClass(\"auto_hide\");\n\t}\n\tfunction modCancelBind() {\n\t\tlog(\"enter modCancelBind\")\n\t\t$(\".moderate_link\").unbind(\"click\");\n\t\t$(\".moderate_open\").click(ev => {\n\t\t\tmodCancel();\n\t\t\t$(\".moderate_open\").unbind(\"click\");\n\t\t\tmodLinkBind();\n\t\t});\n\t}\n\tfunction modLinkBind() {\n\t\tlog(\"enter modLinkBind\");\n\t\t$(\".moderate_link\").click(ev => {\n\t\t\tlog(\"enter .moderate_link\");\n\t\t\tev.preventDefault();\n\t\t\t$(\".pre_opt\").removeClass(\"auto_hide\");\n\t\t\t$(\".moderate_link\").addClass(\"moderate_open\");\n\t\t\tselectedTopics=[];\n\t\t\tmodCancelBind();\n\t\t\t$(\"#topicsItemList,#forumItemList\").addClass(\"topics_moderate\");\n\t\t\t$(\".topic_row\").each(function(){\n\t\t\t\t$(this).click(function(){\n\t\t\t\t\tif(!this.classList.contains(\"can_mod\")) return;\n\t\t\t\t\tlet tid = parseInt($(this).attr(\"data-tid\"),10);\n\t\t\t\t\tlet sel = this.classList.contains(\"topic_selected\");\n\t\t\t\t\tif(sel) {\n\t\t\t\t\t\tfor(var i=0; i<selectedTopics.length; i++){\n\t\t\t\t\t\t\tif(selectedTopics[i]===tid) selectedTopics.splice(i, 1);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else selectedTopics.push(tid);\n\t\t\t\t\tif(selectedTopics.length==1) {\n\t\t\t\t\t\tvar msg = phraseBox[\"topic_list\"][\"topic_list.what_to_do_single\"];\n\t\t\t\t\t} else {\n\t\t\t\t\t\tvar msg = \"What do you want to do with these \"+selectedTopics.length+\" topics?\";\n\t\t\t\t\t}\n\t\t\t\t\t$(\".mod_floater_head span\").html(msg);\n\t\t\t\t\tif(!sel) {\n\t\t\t\t\t\t$(this).addClass(\"topic_selected\");\n\t\t\t\t\t\t$(\".mod_floater\").removeClass(\"auto_hide\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\t$(this).removeClass(\"topic_selected\");\n\t\t\t\t\t}\n\t\t\t\t\tif(selectedTopics.length==0 && !$(\".mod_floater\").hasClass(\"auto_hide\")) $(\".mod_floater\").addClass(\"auto_hide\");\n\t\t\t\t});\n\t\t\t});\n\t\t\t\n\t\t\tlet bulkActionSender = (action,selectedTopics,fragBit) => {\n\t\t\t\t$.ajax({\n\t\t\t\t\turl: \"/topic/\"+action+\"/submit/\"+fragBit+\"?s=\"+me.User.S,\n\t\t\t\t\ttype: \"POST\",\n\t\t\t\t\tdata: JSON.stringify(selectedTopics),\n\t\t\t\t\tcontentType: \"application/json\",\n\t\t\t\t\terror: ajaxError,\n\t\t\t\t\tsuccess: () => window.location.reload()\n\t\t\t\t});\n\t\t\t};\n\t\t\t// TODO: Should we unbind this here to avoid binding multiple listeners to this accidentally?\n\t\t\t$(\".mod_floater_submit\").click(function(ev){\n\t\t\t\tev.preventDefault();\n\t\t\t\tlet selectNode = this.form.querySelector(\".mod_floater_options\");\n\t\t\t\tlet optionNode = selectNode.options[selectNode.selectedIndex];\n\t\t\t\tlet action = optionNode.getAttribute(\"value\");\n\t\t\t\t\n\t\t\t\t// Handle these specially\n\t\t\t\tswitch(action) {\n\t\t\t\t\tcase \"move\":\n\t\t\t\t\t\tlog(\"move action\");\n\t\t\t\t\t\tlet modTopicMover = $(\"#mod_topic_mover\");\n\t\t\t\t\t\t$(\"#mod_topic_mover\").removeClass(\"auto_hide\");\n\t\t\t\t\t\t$(\"#mod_topic_mover .pane_row\").unbind(\"click\");\n\t\t\t\t\t\t$(\"#mod_topic_mover .pane_row\").click(function(){\n\t\t\t\t\t\t\tmodTopicMover.find(\".pane_row\").removeClass(\"pane_selected\");\n\t\t\t\t\t\t\tlet fid = this.getAttribute(\"data-fid\");\n\t\t\t\t\t\t\tif(fid==null) return;\n\t\t\t\t\t\t\tthis.classList.add(\"pane_selected\");\n\t\t\t\t\t\t\tlog(\"fid\",fid);\n\t\t\t\t\t\t\tforumToMoveTo = fid;\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t$(\"#mover_submit\").unbind(\"click\");\n\t\t\t\t\t\t\t$(\"#mover_submit\").click(ev => {\n\t\t\t\t\t\t\t\tev.preventDefault();\n\t\t\t\t\t\t\t\tbulkActionSender(\"move\",selectedTopics,forumToMoveTo);\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tbulkActionSender(action,selectedTopics,\"\");\n\t\t\t});\n\t\t});\n\t}\n\t//addInitHook(\"after_init_bind_page\", () => {\n\t//addInitHook(\"before_init_bind_page\", () => {\n\t//log(\"in member.js before_init_bind_page 2\")\n\taddInitHook(\"end_bind_page\", () => {\n\t\tlog(\"in member.js end_bind_page\")\n\t\tmodCancel();\n\t\tmodLinkBind();\n\t});\n\taddInitHook(\"after_init_bind_page\", () => addHook(\"end_unbind_page\", () => modCancel()))\n\t//});\n})()"
  },
  {
    "path": "public/panel_forum_edit.js",
    "content": "(() => {\n\taddInitHook(\"end_init\", () => {\n\t\tformVars = {'perm_preset': ['can_moderate','can_post','read_only','no_access','default','custom']};\n\t});\n})();"
  },
  {
    "path": "public/panel_forums.js",
    "content": "(() => {\naddInitHook(\"end_init\", () => {\n\nformVars = {\n\t'forum_active': ['Hide','Show'],\n\t'forum_preset': ['all','announce','members','staff','admins','archive','custom']\n};\nvar forums = {};\nlet items = document.getElementsByClassName(\"panel_forum_item\");\nfor(let i=0; item=items[i]; i++) forums[i] = item.getAttribute(\"data-fid\");\nlog(\"forums\",forums);\n\nSortable.create(document.getElementById(\"panel_forums\"), {\n\tsort: true,\n\tonEnd: (evt) => {\n\t\tlog(\"pre forums\",forums)\n\t\tlog(\"evt\",evt)\n\t\tlet oldFid = forums[evt.newIndex];\n\t\tforums[evt.oldIndex] = oldFid;\n\t\tlet newFid = evt.item.getAttribute(\"data-fid\");\n\t\tlog(\"newFid\",newFid);\n\t\tforums[evt.newIndex] = newFid;\n\t\tlog(\"post forums\",forums);\n\t}\n});\n\ndocument.getElementById(\"panel_forums_order_button\").addEventListener(\"click\", () => {\n\tlet req = new XMLHttpRequest();\n\tif(!req) {\n\t\tlog(\"Failed to create request\");\n\t\treturn false;\n\t}\n\treq.onreadystatechange = () => {\n\t\ttry {\n\t\t\tif(req.readyState!==XMLHttpRequest.DONE) return;\n\t\t\t// TODO: Signal the error with a notice\n\t\t\tif(req.status!==200) return;\n\t\t\t\n\t\t\tlet resp = JSON.parse(req.responseText);\n\t\t\tlog(\"resp\",resp);\n\t\t\t// TODO: Should we move other notices into TmplPhrases like this one?\n\t\t\tpushNotice(phraseBox[\"panel\"][\"panel.forums_order_updated\"]);\n\t\t\tif(resp.success==1) return;\n\t\t} catch(e) {\n\t\t\tconsole.error(\"e\",e)\n\t\t}\n\t\tconsole.trace();\n\t}\n\t// ? - Is encodeURIComponent the right function for this?\n\treq.open(\"POST\",\"/panel/forums/order/edit/submit/?s=\" + encodeURIComponent(me.User.S));\n\treq.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');\n\tlet items = \"\";\n\tfor(let i=0;item=forums[i];i++) items += item+\",\";\n\tif(items.length > 0) items = items.slice(0,-1);\n\treq.send(\"js=1&amp;items={\"+items+\"}\");\n});\n\n});\n})()"
  },
  {
    "path": "public/panel_menu_items.js",
    "content": "(() => {\n\taddInitHook(\"end_init\", () => {\n\n// TODO: Move this into a JS file to reduce the number of possible problems\nvar menuItems = {};\nlet items = document.getElementsByClassName(\"panel_menu_item\");\nfor(let i=0; item=items[i]; i++) menuItems[i] = item.getAttribute(\"data-miid\");\n\nSortable.create(document.getElementById(\"panel_menu_item_holder\"), {\n\tsort: true,\n\tonEnd: evt => {\n\t\tlog(\"pre menuItems\",menuItems)\n\t\tlog(\"evt\",evt)\n\t\tlet oldMiid = menuItems[evt.newIndex];\n\t\tmenuItems[evt.oldIndex] = oldMiid;\n\t\tlet newMiid = evt.item.getAttribute(\"data-miid\");\n\t\tlog(\"newMiid\",newMiid);\n\t\tmenuItems[evt.newIndex] = newMiid;\n\t\tlog(\"post menuItems\",menuItems);\n\t}\n});\n\ndocument.getElementById(\"panel_menu_items_order_button\").addEventListener(\"click\", () => {\n\tlet req = new XMLHttpRequest();\n\tif(!req) {\n\t\tlog(\"Failed to create request\");\n\t\treturn false;\n\t}\n\treq.onreadystatechange = () => {\n\t\ttry {\n\t\t\tif(req.readyState!==XMLHttpRequest.DONE) return;\n\t\t\t// TODO: Signal the error with a notice\n\t\t\tif(req.status===200) {\n\t\t\t\tlet resp = JSON.parse(req.responseText);\n\t\t\t\tlog(\"resp\",resp);\n\t\t\t\t// TODO: Should we move other notices into TmplPhrases like this one?\n\t\t\t\tpushNotice(phraseBox[\"panel\"][\"panel.themes_menus_items_order_updated\"]);\n\t\t\t\tif(resp.success==1) return;\n\t\t\t}\n\t\t} catch(e) {\n\t\t\tconsole.error(\"e\",e)\n\t\t}\n\t\tconsole.trace();\n\t}\n\t// ? - Is encodeURIComponent the right function for this?\n\tlet spl = document.location.pathname.split(\"/\");\n\treq.open(\"POST\",\"/panel/themes/menus/item/order/edit/submit/\"+parseInt(spl[spl.length-1],10)+\"?s=\"+encodeURIComponent(me.User.S));\n\treq.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');\n\tlet items = \"\";\n\tfor(let i=0; item=menuItems[i];i++) items += item+\",\";\n\tif(items.length > 0) items = items.slice(0,-1);\n\treq.send(\"js=1&amp;items={\"+items+\"}\");\n});\n\n});\n})()"
  },
  {
    "path": "public/profile_member.js",
    "content": "function handle_profile_hashbit() {\n\tvar hash_class = \"\";\n\tswitch(window.location.hash.substr(1)) {\n\t\tcase \"ban_user\":\n\t\t\thash_class = \"ban_user_hash\";\n\t\t\tbreak;\n\t\tcase \"delete_posts\":\n\t\t\thash_class = \"delete_posts_hash\";\n\t\t\tbreak;\n\t\tdefault:\n\t\t\tlog(\"Unknown hashbit\");\n\t\t\treturn;\n\t}\n\t$(\".hash_hide\").hide();\n\t$(\".\" + hash_class).show();\n}\n\n(() => {\naddInitHook(\"end_init\", () => {\n\tif(window.location.hash) handle_profile_hashbit();\n\twindow.addEventListener(\"hashchange\", handle_profile_hashbit, false);\n});\n})();"
  },
  {
    "path": "public/register.js",
    "content": "(() => {\n\taddInitHook(\"end_init\", () => {\n\tfetch(\"/api/watches/\")\n\t.then(resp => {\n\t\tif(resp.status!==200) {\n\t\t\tlog(\"err\");\n\t\t\tlog(\"resp\",resp);\n\t\t\treturn;\n\t\t}\n\t\tresp.text().then(d => eval(d));\n\t})\n\t.catch(e => log(\"e\",e));\n\t});\n})()"
  },
  {
    "path": "public/templates/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "public/trumbowyg/ui/trumbowyg.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n#trumbowyg-icons {\n  overflow: hidden;\n  visibility: hidden;\n  height: 0;\n  width: 0; }\n  #trumbowyg-icons svg {\n    height: 0;\n    width: 0; }\n\n.trumbowyg-box *,\n.trumbowyg-box *::before,\n.trumbowyg-box *::after {\n  box-sizing: border-box; }\n\n.trumbowyg-box svg {\n  width: 17px;\n  height: 100%;\n  fill: #222; }\n\n.trumbowyg-box,\n.trumbowyg-editor {\n  display: block;\n  position: relative;\n  border: 1px solid #DDD;\n  width: 100%;\n  min-height: 300px;\n  margin: 17px auto; }\n\n.trumbowyg-box .trumbowyg-editor {\n  margin: 0 auto; }\n\n.trumbowyg-box.trumbowyg-fullscreen {\n  background: #FEFEFE;\n  border: none !important; }\n\n.trumbowyg-editor,\n.trumbowyg-textarea {\n  position: relative;\n  box-sizing: border-box;\n  padding: 20px;\n  min-height: 300px;\n  width: 100%;\n  border-style: none;\n  resize: none;\n  outline: none;\n  overflow: auto; }\n  .trumbowyg-editor.trumbowyg-autogrow-on-enter,\n  .trumbowyg-textarea.trumbowyg-autogrow-on-enter {\n    transition: height 300ms ease-out; }\n\n.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n  color: transparent !important;\n  text-shadow: 0 0 7px #333; }\n  @media screen and (min-width: 0 \\0) {\n    .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n      color: rgba(200, 200, 200, 0.6) !important; } }\n  @supports (-ms-accelerator: true) {\n    .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n      color: rgba(200, 200, 200, 0.6) !important; } }\n\n.trumbowyg-box-blur .trumbowyg-editor img,\n.trumbowyg-box-blur .trumbowyg-editor hr {\n  opacity: 0.2; }\n\n.trumbowyg-textarea {\n  position: relative;\n  display: block;\n  overflow: auto;\n  border: none;\n  white-space: normal;\n  font-size: 14px;\n  font-family: \"Inconsolata\", \"Consolas\", \"Courier\", \"Courier New\", sans-serif;\n  line-height: 18px; }\n\n.trumbowyg-box.trumbowyg-editor-visible .trumbowyg-textarea {\n  height: 1px !important;\n  width: 25%;\n  min-height: 0 !important;\n  padding: 0 !important;\n  background: none;\n  opacity: 0 !important; }\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-textarea {\n  display: block; }\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-editor {\n  display: none; }\n\n.trumbowyg-box.trumbowyg-disabled .trumbowyg-textarea {\n  opacity: 0.8;\n  background: none; }\n\n.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n  content: attr(placeholder);\n  color: #999;\n  pointer-events: none; }\n\n.trumbowyg-button-pane {\n  width: 100%;\n  min-height: 36px;\n  background: #ecf0f1;\n  border-bottom: 1px solid #d7e0e2;\n  margin: 0;\n  padding: 0 5px;\n  position: relative;\n  list-style-type: none;\n  line-height: 10px;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden;\n  z-index: 11; }\n  .trumbowyg-button-pane::after {\n    content: \" \";\n    display: block;\n    position: absolute;\n    top: 36px;\n    left: 0;\n    right: 0;\n    width: 100%;\n    height: 1px;\n    background: #d7e0e2; }\n  .trumbowyg-button-pane .trumbowyg-button-group {\n    display: inline-block; }\n    .trumbowyg-button-pane .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n      color: transparent; }\n    .trumbowyg-button-pane .trumbowyg-button-group:not(:empty) + .trumbowyg-button-group::before {\n      content: \" \";\n      display: inline-block;\n      width: 1px;\n      background: #d7e0e2;\n      margin: 0 5px;\n      height: 35px;\n      vertical-align: top; }\n  .trumbowyg-button-pane button {\n    display: inline-block;\n    position: relative;\n    width: 35px;\n    height: 35px;\n    padding: 1px 6px !important;\n    margin-bottom: 1px;\n    overflow: hidden;\n    border: none;\n    cursor: pointer;\n    background: none;\n    vertical-align: middle;\n    transition: background-color 150ms, opacity 150ms; }\n    .trumbowyg-button-pane button.trumbowyg-textual-button {\n      width: auto;\n      line-height: 35px;\n      -webkit-user-select: none;\n         -moz-user-select: none;\n          -ms-user-select: none;\n              user-select: none; }\n  .trumbowyg-button-pane.trumbowyg-disable button:not(.trumbowyg-not-disable):not(.trumbowyg-active),\n  .trumbowyg-disabled .trumbowyg-button-pane button:not(.trumbowyg-not-disable):not(.trumbowyg-viewHTML-button) {\n    opacity: 0.2;\n    cursor: default; }\n  .trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before,\n  .trumbowyg-disabled .trumbowyg-button-pane .trumbowyg-button-group::before {\n    background: #e3e9eb; }\n  .trumbowyg-button-pane button:not(.trumbowyg-disable):hover,\n  .trumbowyg-button-pane button:not(.trumbowyg-disable):focus,\n  .trumbowyg-button-pane button.trumbowyg-active {\n    background-color: #FFF;\n    outline: none; }\n  .trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n    display: block;\n    content: \" \";\n    position: absolute;\n    top: 25px;\n    right: 3px;\n    height: 0;\n    width: 0;\n    border: 3px solid transparent;\n    border-top-color: #555; }\n  .trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button {\n    padding-left: 10px !important;\n    padding-right: 18px !important; }\n    .trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button::after {\n      top: 17px;\n      right: 7px; }\n  .trumbowyg-button-pane .trumbowyg-right {\n    float: right; }\n    .trumbowyg-button-pane .trumbowyg-right::before {\n      display: none !important; }\n\n.trumbowyg-dropdown {\n  width: 200px;\n  border: 1px solid #ecf0f1;\n  padding: 5px 0;\n  border-top: none;\n  background: #FFF;\n  margin-left: -1px;\n  box-shadow: rgba(0, 0, 0, 0.1) 0 2px 3px;\n  z-index: 11; }\n  .trumbowyg-dropdown button {\n    display: block;\n    width: 100%;\n    height: 35px;\n    line-height: 35px;\n    text-decoration: none;\n    background: #FFF;\n    padding: 0 10px;\n    color: #333 !important;\n    border: none;\n    cursor: pointer;\n    text-align: left;\n    font-size: 15px;\n    transition: all 150ms; }\n    .trumbowyg-dropdown button:hover, .trumbowyg-dropdown button:focus {\n      background: #ecf0f1; }\n    .trumbowyg-dropdown button svg {\n      float: left;\n      margin-right: 14px; }\n\n/* Modal box */\n.trumbowyg-modal {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n          transform: translateX(-50%);\n  max-width: 520px;\n  width: 100%;\n  height: 350px;\n  z-index: 11;\n  overflow: hidden;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden; }\n\n.trumbowyg-modal-box {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n          transform: translateX(-50%);\n  max-width: 500px;\n  width: calc(100% - 20px);\n  padding-bottom: 45px;\n  z-index: 1;\n  background-color: #FFF;\n  text-align: center;\n  font-size: 14px;\n  box-shadow: rgba(0, 0, 0, 0.2) 0 2px 3px;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden; }\n  .trumbowyg-modal-box .trumbowyg-modal-title {\n    font-size: 24px;\n    font-weight: bold;\n    margin: 0 0 20px;\n    padding: 15px 0 13px;\n    display: block;\n    border-bottom: 1px solid #EEE;\n    color: #333;\n    background: #fbfcfc; }\n  .trumbowyg-modal-box .trumbowyg-progress {\n    width: 100%;\n    height: 3px;\n    position: absolute;\n    top: 58px; }\n    .trumbowyg-modal-box .trumbowyg-progress .trumbowyg-progress-bar {\n      background: #2BC06A;\n      width: 0;\n      height: 100%;\n      transition: width 150ms linear; }\n  .trumbowyg-modal-box label {\n    display: block;\n    position: relative;\n    margin: 15px 12px;\n    height: 29px;\n    line-height: 29px;\n    overflow: hidden; }\n    .trumbowyg-modal-box label .trumbowyg-input-infos {\n      display: block;\n      text-align: left;\n      height: 25px;\n      line-height: 25px;\n      transition: all 150ms; }\n      .trumbowyg-modal-box label .trumbowyg-input-infos span {\n        display: block;\n        color: #69878f;\n        background-color: #fbfcfc;\n        border: 1px solid #DEDEDE;\n        padding: 0 7px;\n        width: 150px; }\n      .trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n        color: #e74c3c; }\n    .trumbowyg-modal-box label.trumbowyg-input-error input,\n    .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n      border: 1px solid #e74c3c; }\n    .trumbowyg-modal-box label.trumbowyg-input-error .trumbowyg-input-infos {\n      margin-top: -27px; }\n    .trumbowyg-modal-box label input {\n      position: absolute;\n      top: 0;\n      right: 0;\n      height: 27px;\n      line-height: 27px;\n      border: 1px solid #DEDEDE;\n      background: #fff;\n      font-size: 14px;\n      max-width: 330px;\n      width: 70%;\n      padding: 0 7px;\n      transition: all 150ms; }\n      .trumbowyg-modal-box label input:hover, .trumbowyg-modal-box label input:focus {\n        outline: none;\n        border: 1px solid #95a5a6; }\n      .trumbowyg-modal-box label input:focus {\n        background: #fbfcfc; }\n  .trumbowyg-modal-box .error {\n    margin-top: 25px;\n    display: block;\n    color: red; }\n  .trumbowyg-modal-box .trumbowyg-modal-button {\n    position: absolute;\n    bottom: 10px;\n    right: 0;\n    text-decoration: none;\n    color: #FFF;\n    display: block;\n    width: 100px;\n    height: 35px;\n    line-height: 33px;\n    margin: 0 10px;\n    background-color: #333;\n    border: none;\n    cursor: pointer;\n    font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif;\n    font-size: 16px;\n    transition: all 150ms; }\n    .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n      right: 110px;\n      background: #2bc06a; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n        background: #40d47e;\n        outline: none; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n        background: #25a25a; }\n    .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n      color: #555;\n      background: #e6e6e6; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n        background: #fbfbfb;\n        outline: none; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n        background: #d5d5d5; }\n\n.trumbowyg-overlay {\n  position: absolute;\n  background-color: rgba(255, 255, 255, 0.5);\n  height: 100%;\n  width: 100%;\n  left: 0;\n  display: none;\n  top: 0;\n  z-index: 10; }\n\n/**\n * Fullscreen\n */\nbody.trumbowyg-body-fullscreen {\n  overflow: hidden; }\n\n.trumbowyg-fullscreen {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  z-index: 99999; }\n  .trumbowyg-fullscreen.trumbowyg-box,\n  .trumbowyg-fullscreen .trumbowyg-editor {\n    border: none; }\n  .trumbowyg-fullscreen .trumbowyg-editor,\n  .trumbowyg-fullscreen .trumbowyg-textarea {\n    height: calc(100% - 37px) !important;\n    overflow: auto; }\n  .trumbowyg-fullscreen .trumbowyg-overlay {\n    height: 100% !important; }\n  .trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n    color: #222;\n    fill: transparent; }\n\n.trumbowyg-editor {\n  /*\n     * lset for resetCss option\n     */ }\n  .trumbowyg-editor object,\n  .trumbowyg-editor embed,\n  .trumbowyg-editor video,\n  .trumbowyg-editor img {\n    max-width: 100%; }\n  .trumbowyg-editor video,\n  .trumbowyg-editor img {\n    height: auto; }\n  .trumbowyg-editor img {\n    cursor: move; }\n  .trumbowyg-editor.trumbowyg-reset-css {\n    background: #FEFEFE !important;\n    font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n    font-size: 14px !important;\n    line-height: 1.45em !important;\n    white-space: normal !important;\n    color: #333; }\n    .trumbowyg-editor.trumbowyg-reset-css a {\n      color: #15c !important;\n      text-decoration: underline !important; }\n    .trumbowyg-editor.trumbowyg-reset-css div,\n    .trumbowyg-editor.trumbowyg-reset-css p,\n    .trumbowyg-editor.trumbowyg-reset-css ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol,\n    .trumbowyg-editor.trumbowyg-reset-css blockquote {\n      box-shadow: none !important;\n      background: none !important;\n      margin: 0 !important;\n      margin-bottom: 15px !important;\n      line-height: 1.4em !important;\n      font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n      font-size: 14px !important;\n      border: none; }\n    .trumbowyg-editor.trumbowyg-reset-css iframe,\n    .trumbowyg-editor.trumbowyg-reset-css object,\n    .trumbowyg-editor.trumbowyg-reset-css hr {\n      margin-bottom: 15px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css blockquote {\n      margin-left: 32px !important;\n      font-style: italic !important;\n      color: #555; }\n    .trumbowyg-editor.trumbowyg-reset-css ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol {\n      padding-left: 20px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css ul ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol ol,\n    .trumbowyg-editor.trumbowyg-reset-css ul ol,\n    .trumbowyg-editor.trumbowyg-reset-css ol ul {\n      border: none;\n      margin: 2px !important;\n      padding: 0 !important;\n      padding-left: 24px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css hr {\n      display: block;\n      height: 1px;\n      border: none;\n      border-top: 1px solid #CCC; }\n    .trumbowyg-editor.trumbowyg-reset-css h1,\n    .trumbowyg-editor.trumbowyg-reset-css h2,\n    .trumbowyg-editor.trumbowyg-reset-css h3,\n    .trumbowyg-editor.trumbowyg-reset-css h4 {\n      color: #111;\n      background: none;\n      margin: 0 !important;\n      padding: 0 !important;\n      font-weight: bold; }\n    .trumbowyg-editor.trumbowyg-reset-css h1 {\n      font-size: 32px !important;\n      line-height: 38px !important;\n      margin-bottom: 20px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h2 {\n      font-size: 26px !important;\n      line-height: 34px !important;\n      margin-bottom: 15px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h3 {\n      font-size: 22px !important;\n      line-height: 28px !important;\n      margin-bottom: 7px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h4 {\n      font-size: 16px !important;\n      line-height: 22px !important;\n      margin-bottom: 7px !important; }\n\n/*\n * Dark theme\n */\n.trumbowyg-dark .trumbowyg-textarea {\n  background: #111;\n  color: #ddd; }\n\n.trumbowyg-dark .trumbowyg-box {\n  border: 1px solid #343434; }\n  .trumbowyg-dark .trumbowyg-box.trumbowyg-fullscreen {\n    background: #111; }\n  .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n    text-shadow: 0 0 7px #ccc; }\n    @media screen and (min-width: 0 \\0 ) {\n      .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n        color: rgba(20, 20, 20, 0.6) !important; } }\n    @supports (-ms-accelerator: true) {\n      .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n        color: rgba(20, 20, 20, 0.6) !important; } }\n  .trumbowyg-dark .trumbowyg-box svg {\n    fill: #ecf0f1;\n    color: #ecf0f1; }\n\n.trumbowyg-dark .trumbowyg-button-pane {\n  background-color: #222;\n  border-bottom-color: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane::after {\n    background: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-button-group:not(:empty)::before {\n    background-color: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-button-group:not(:empty) .trumbowyg-fullscreen-button svg {\n    color: transparent; }\n  .trumbowyg-dark .trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before {\n    background-color: #2a2a2a; }\n  .trumbowyg-dark .trumbowyg-button-pane button:not(.trumbowyg-disable):hover,\n  .trumbowyg-dark .trumbowyg-button-pane button:not(.trumbowyg-disable):focus,\n  .trumbowyg-dark .trumbowyg-button-pane button.trumbowyg-active {\n    background-color: #333; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n    border-top-color: #fff; }\n\n.trumbowyg-dark .trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: #ecf0f1;\n  fill: transparent; }\n\n.trumbowyg-dark .trumbowyg-dropdown {\n  border-color: #222;\n  background: #333;\n  box-shadow: rgba(0, 0, 0, 0.3) 0 2px 3px; }\n  .trumbowyg-dark .trumbowyg-dropdown button {\n    background: #333;\n    color: #fff !important; }\n    .trumbowyg-dark .trumbowyg-dropdown button:hover, .trumbowyg-dark .trumbowyg-dropdown button:focus {\n      background: #222; }\n\n.trumbowyg-dark .trumbowyg-modal-box {\n  background-color: #222; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-title {\n    border-bottom: 1px solid #555;\n    color: #fff;\n    background: #3c3c3c; }\n  .trumbowyg-dark .trumbowyg-modal-box label {\n    display: block;\n    position: relative;\n    margin: 15px 12px;\n    height: 27px;\n    line-height: 27px;\n    overflow: hidden; }\n    .trumbowyg-dark .trumbowyg-modal-box label .trumbowyg-input-infos span {\n      color: #eee;\n      background-color: #2f2f2f;\n      border-color: #222; }\n    .trumbowyg-dark .trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n      color: #e74c3c; }\n    .trumbowyg-dark .trumbowyg-modal-box label.trumbowyg-input-error input,\n    .trumbowyg-dark .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n      border-color: #e74c3c; }\n    .trumbowyg-dark .trumbowyg-modal-box label input {\n      border-color: #222;\n      color: #eee;\n      background: #333; }\n      .trumbowyg-dark .trumbowyg-modal-box label input:hover, .trumbowyg-dark .trumbowyg-modal-box label input:focus {\n        border-color: #626262; }\n      .trumbowyg-dark .trumbowyg-modal-box label input:focus {\n        background-color: #2f2f2f; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n    background: #1b7943; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n      background: #25a25a; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n      background: #176437; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n    background: #333;\n    color: #ccc; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n      background: #444; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n      background: #111; }\n\n.trumbowyg-dark .trumbowyg-overlay {\n  background-color: rgba(15, 15, 15, 0.6); }\n"
  },
  {
    "path": "public/trumbowyg/ui/trumbowyg.custom.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor. Modified by Azareal.\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg & https://github.com/Azareal/Gosora\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n * @author Azareal\n */\n\n#trumbowyg-icons {\n  overflow: hidden;\n  visibility: hidden;\n  height: 0;\n  width: 0;\n}\n#trumbowyg-icons svg {\n  height: 0;\n  width: 0;\n}\n\n.trumbowyg-box *, .trumbowyg-box *::before, .trumbowyg-box *::after {\n  box-sizing: border-box;\n}\n\n.trumbowyg-box svg {\n  width: 17px;\n  height: 100%;\n  fill: #222222;\n  color: #222222;\n  opacity: 0.5;\n}\n.trumbowyg-box svg:hover {\n  width: 17px;\n  height: 100%;\n  fill: #222222;\n  color: #222222;\n  opacity: 0.75;\n}\n\n.trumbowyg-box, .trumbowyg-editor {\n  display: block;\n  position: relative;\n  width: 100%;\n  min-height: 150px;\n  margin: 0;\n}\n\n.trumbowyg-box.trumbowyg-fullscreen {\n  background: #FEFEFE;\n  border: none !important;\n}\n\n.trumbowyg-editor, .trumbowyg-textarea {\n  position: relative;\n  box-sizing: border-box;\n  padding: 20px;\n  min-height: 150px;\n  width: 100%;\n  border-style: none;\n  resize: none;\n  outline: none;\n  border: 1px solid #DDD;\n  overflow: hidden;\n  word-wrap: break-word;\n  overflow-wrap: break-word;\n}\n.trumbowyg-editor.trumbowyg-autogrow-on-enter, .trumbowyg-textarea.trumbowyg-autogrow-on-enter {\n  transition: height 150ms ease-out;\n}\n\n.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n  color: transparent !important;\n  text-shadow: 0 0 7px #333; }\n\n@media screen and (min-width: 0 \\0) {\n  .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n    color: rgba(200, 200, 200, 0.6) !important;\n  }\n}\n\n.trumbowyg-box-blur .trumbowyg-editor img, .trumbowyg-box-blur .trumbowyg-editor hr {\n  opacity: 0.2;\n}\n\n.trumbowyg-textarea {\n  position: relative;\n  display: block;\n  overflow: auto;\n  border: none;\n  white-space: normal;\n  font-size: 14px;\n  font-family: \"Inconsolata\", \"Consolas\", \"Courier\", \"Courier New\", sans-serif;\n  line-height: 18px;\n}\n\n.trumbowyg-box.trumbowyg-editor-visible .trumbowyg-textarea {\n  height: 1px !important;\n  width: 25%;\n  min-height: 0 !important;\n  padding: 0 !important;\n  background: none;\n  opacity: 0 !important;\n}\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-textarea {\n  display: block;\n}\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-editor {\n  display: none;\n}\n\n.trumbowyg-box.trumbowyg-disabled .trumbowyg-textarea {\n  opacity: 0.8;\n  background: none;\n}\n\n.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n  content: attr(placeholder);\n  color: #999;\n  pointer-events: none;\n}\n\n.trumbowyg-button-pane {\n  min-height: 36px;\n  margin: 0;\n  padding: 0px 5px;\n  position: relative;\n  list-style-type: none;\n  line-height: 10px;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n  z-index: 11;\n  display: flex;\n}\n.trumbowyg-button-pane::after {\n  content: \" \";\n  display: block;\n  position: absolute;\n  top: 36px;\n  left: 0;\n  right: 0;\n  width: 100%;\n  height: 1px;\n  background: #d7e0e2;\n}\n.trumbowyg-button-pane .trumbowyg-button-group {\n  display: inline-block;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:first-child {\n    margin-left: 10px;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:last-child {\n    margin-right: auto;\n}\n.trumbowyg-button-pane .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: transparent;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:after {\n  content: \"\";\n  display: inline-block;\n  width: 1px;\n  margin: 0 5px;\n  height: 20px;\n  vertical-align: top;\n  border-right: 1px solid #d7e0e2;\n  margin-top: 8px;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:first-child:before {\n    content: \"\";\n    display: inline-block;\n    width: 1px;\n    margin: 0 5px;\n    margin-right: 5px;\n    margin-bottom: 0px;\n    margin-left: 5px;\n    height: 20px;\n    vertical-align: top;\n    border-right: 1px solid #d7e0e2;\n    margin-top: 8px;\n}\n.trumbowyg-button-pane button {\n  display: inline-block;\n  position: relative;\n  width: 35px;\n  height: 35px;\n  padding: 1px 6px !important;\n  margin-bottom: 1px;\n  overflow: hidden;\n  border: none;\n  cursor: pointer;\n  background: none;\n  vertical-align: middle;\n  transition: background-color 150ms, opacity 150ms;\n}\n.trumbowyg-button-pane button.trumbowyg-textual-button {\n  width: auto;\n  line-height: 35px;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n.trumbowyg-button-pane.trumbowyg-disable button:not(.trumbowyg-not-disable):not(.trumbowyg-active), .trumbowyg-disabled .trumbowyg-button-pane button:not(.trumbowyg-not-disable):not(.trumbowyg-viewHTML-button) {\n  opacity: 0.2;\n  cursor: default;\n}\n.trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before, .trumbowyg-disabled .trumbowyg-button-pane .trumbowyg-button-group::before {\n  background: #e3e9eb;\n}\n.trumbowyg-button-pane button:not(.trumbowyg-disable):hover, .trumbowyg-button-pane button:not(.trumbowyg-disable):focus, .trumbowyg-button-pane button.trumbowyg-active {\n  background-color: #FFFFFF;\n  outline: none;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n  display: block;\n  content: \" \";\n  position: absolute;\n  top: 25px;\n  right: 3px;\n  height: 0;\n  width: 0;\n  border: 3px solid transparent;\n  border-top-color: #555555;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button {\n  padding-left: 10px !important;\n  padding-right: 18px !important;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button::after {\n  top: 17px;\n  right: 7px;\n}\n.trumbowyg-button-pane .trumbowyg-right {\n  float: right;\n}\n.trumbowyg-button-pane .trumbowyg-right::before {\n  display: none !important;\n}\n\n.trumbowyg-dropdown {\n  width: 200px;\n  border: 1px solid #ecf0f1;\n  padding: 5px 0;\n  border-top: none;\n  background: #FFF;\n  margin-left: -1px;\n  box-shadow: rgba(0, 0, 0, 0.1) 0 2px 3px;\n  z-index: 11;\n}\n.trumbowyg-dropdown button {\n  display: block;\n  width: 100%;\n  height: 35px;\n  line-height: 35px;\n  text-decoration: none;\n  background: #FFF;\n  padding: 0 10px;\n  color: #333 !important;\n  border: none;\n  cursor: pointer;\n  text-align: left;\n  font-size: 15px;\n  transition: all 150ms;\n}\n.trumbowyg-dropdown button:hover, .trumbowyg-dropdown button:focus {\n  background: #ecf0f1;\n}\n.trumbowyg-dropdown button svg {\n  float: left;\n  margin-right: 14px;\n}\n\n/* Modal box */\n.trumbowyg-modal {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n  transform: translateX(-50%);\n  max-width: 520px;\n  width: 100%;\n  height: 350px;\n  z-index: 11;\n  overflow: hidden;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n}\n\n.trumbowyg-modal-box {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n  transform: translateX(-50%);\n  max-width: 500px;\n  width: calc(100% - 20px);\n  padding-bottom: 45px;\n  z-index: 1;\n  background-color: #FFF;\n  text-align: center;\n  font-size: 14px;\n  box-shadow: rgba(0, 0, 0, 0.2) 0 2px 3px;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n}\n.trumbowyg-modal-box .trumbowyg-modal-title {\n  font-size: 24px;\n  font-weight: bold;\n  margin: 0 0 20px;\n  padding: 15px 0 13px;\n  display: block;\n  border-bottom: 1px solid #EEE;\n  color: #333;\n  background: #fbfcfc;\n}\n.trumbowyg-modal-box .trumbowyg-progress {\n  width: 100%;\n  height: 3px;\n  position: absolute;\n  top: 58px;\n}\n.trumbowyg-modal-box .trumbowyg-progress .trumbowyg-progress-bar {\n  background: #2BC06A;\n  width: 0;\n  height: 100%;\n  transition: width 150ms linear;\n}\n.trumbowyg-modal-box label {\n  display: block;\n  position: relative;\n  margin: 15px 12px;\n  height: 29px;\n  line-height: 29px;\n  overflow: hidden;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos {\n  display: block;\n  text-align: left;\n  height: 25px;\n  line-height: 25px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos span {\n  display: block;\n  color: #69878f;\n  background-color: #fbfcfc;\n  border: 1px solid #DEDEDE;\n  padding: 0 7px;\n  width: 150px;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n  color: #e74c3c;\n}\n.trumbowyg-modal-box label.trumbowyg-input-error input, .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n  border: 1px solid #e74c3c;\n}\n.trumbowyg-modal-box label.trumbowyg-input-error .trumbowyg-input-infos {\n  margin-top: -27px;\n}\n.trumbowyg-modal-box label input {\n  position: absolute;\n  top: 0;\n  right: 0;\n  height: 27px;\n  line-height: 27px;\n  border: 1px solid #DEDEDE;\n  background: #fff;\n  font-size: 14px;\n  max-width: 330px;\n  width: 70%;\n  padding: 0 7px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box label input:hover, .trumbowyg-modal-box label input:focus {\n  outline: none;\n  border: 1px solid #95a5a6;\n}\n.trumbowyg-modal-box label input:focus {\n  background: #fbfcfc;\n}\n.trumbowyg-modal-box .error {\n  margin-top: 25px;\n  display: block;\n  color: red;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button {\n  position: absolute;\n  bottom: 10px;\n  right: 0;\n  text-decoration: none;\n  color: #FFF;\n  display: block;\n  width: 100px;\n  height: 35px;\n  line-height: 33px;\n  margin: 0 10px;\n  background-color: #333;\n  border: none;\n  cursor: pointer;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif;\n  font-size: 16px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n  right: 110px;\n  background: #2bc06a;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n  background: #40d47e;\n  outline: none;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n  background: #25a25a;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n  color: #555;\n  background: #e6e6e6;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n  background: #fbfbfb;\n  outline: none;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n  background: #d5d5d5;\n}\n\n.trumbowyg-overlay {\n  position: absolute;\n  background-color: rgba(255, 255, 255, 0.5);\n  height: 100%;\n  width: 100%;\n  left: 0;\n  display: none;\n  top: 0;\n  z-index: 10; }\n\n/**\n * Fullscreen\n */\nbody.trumbowyg-body-fullscreen {\n  overflow: hidden; }\n\n.trumbowyg-fullscreen {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  z-index: 99999;\n}\n.trumbowyg-fullscreen.trumbowyg-box, .trumbowyg-fullscreen .trumbowyg-editor {\n  border: none;\n}\n.trumbowyg-fullscreen .trumbowyg-editor, .trumbowyg-fullscreen .trumbowyg-textarea {\n  height: calc(100% - 37px) !important;\n  overflow: auto;\n}\n.trumbowyg-fullscreen .trumbowyg-overlay {\n  height: 100% !important;\n}\n.trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: #222;\n  fill: transparent;\n}\n\n.trumbowyg-editor object, .trumbowyg-editor embed, .trumbowyg-editor video, .trumbowyg-editor img {\n  max-width: 100%;\n}\n.trumbowyg-editor video, .trumbowyg-editor img {\n  height: auto;\n}\n.trumbowyg-editor img {\n  cursor: move;\n}\n.trumbowyg-editor.trumbowyg-reset-css {\n  background: #FEFEFE !important;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n  font-size: 14px !important;\n  line-height: 1.45em !important;\n  white-space: normal !important;\n  color: #333;\n}\n.trumbowyg-editor.trumbowyg-reset-css a {\n  color: #15c !important;\n  text-decoration: underline !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css div, .trumbowyg-editor.trumbowyg-reset-css p, .trumbowyg-editor.trumbowyg-reset-css ul, .trumbowyg-editor.trumbowyg-reset-css ol, .trumbowyg-editor.trumbowyg-reset-css blockquote {\n  box-shadow: none !important;\n  background: none !important;\n  margin: 0 !important;\n  margin-bottom: 15px !important;\n  line-height: 1.4em !important;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n  font-size: 14px !important;\n  border: none;\n}\n.trumbowyg-editor.trumbowyg-reset-css iframe, .trumbowyg-editor.trumbowyg-reset-css object, .trumbowyg-editor.trumbowyg-reset-css hr {\n  margin-bottom: 15px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css blockquote {\n  margin-left: 32px !important;\n  font-style: italic !important;\n  color: #555;\n}\n.trumbowyg-editor.trumbowyg-reset-css ul, .trumbowyg-editor.trumbowyg-reset-css ol {\n  padding-left: 20px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css ul ul, .trumbowyg-editor.trumbowyg-reset-css ol ol, .trumbowyg-editor.trumbowyg-reset-css ul ol, .trumbowyg-editor.trumbowyg-reset-css ol ul {\n  border: none;\n  margin: 2px !important;\n  padding: 0 !important;\n  padding-left: 24px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css hr {\n  display: block;\n  height: 1px;\n  border: none;\n  border-top: 1px solid #CCC;\n}\n.trumbowyg-editor.trumbowyg-reset-css h1, .trumbowyg-editor.trumbowyg-reset-css h2, .trumbowyg-editor.trumbowyg-reset-css h3, .trumbowyg-editor.trumbowyg-reset-css h4 {\n  color: #111;\n  background: none;\n  margin: 0 !important;\n  padding: 0 !important;\n  font-weight: bold;\n}\n.trumbowyg-editor.trumbowyg-reset-css h1 {\n  font-size: 32px !important;\n  line-height: 38px !important;\n  margin-bottom: 20px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h2 {\n  font-size: 26px !important;\n  line-height: 34px !important;\n  margin-bottom: 15px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h3 {\n  font-size: 22px !important;\n  line-height: 28px !important;\n  margin-bottom: 7px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h4 {\n  font-size: 16px !important;\n  line-height: 22px !important;\n  margin-bottom: 7px !important;\n}"
  },
  {
    "path": "public/widgets.js",
    "content": "\"use strict\";\n$(document).ready(() => {\n\tlet clickHandle = function(ev){\n\t\tlog(\"in clickHandle\")\n\t\tev.preventDefault();\n\t\tlet ep = $(this).closest(\".editable_parent\");\n\t\tep.find(\".hide_on_block_edit\").addClass(\"edit_opened\");\n\t\tep.find(\".show_on_block_edit\").addClass(\"edit_opened\");\n\t\tep.addClass(\"in_edit\");\n\n\t\tep.find(\".widget_save\").click(() => {\n\t\t\tep.find(\".hide_on_block_edit\").removeClass(\"edit_opened\");\n\t\t\tep.find(\".show_on_block_edit\").removeClass(\"edit_opened\");\n\t\t\tep.removeClass(\"in_edit\");\n\t\t});\n\n\t\tep.find(\".widget_delete\").click(function(ev) {\n\t\t\tev.preventDefault();\n\t\t\tep.remove();\n\t\t\tlet formData = new URLSearchParams();\n\t\t\tformData.append(\"s\",me.User.S);\n\t\t\tlet req = new XMLHttpRequest();\n\t\t\tlet target = this.closest(\"a\").getAttribute(\"href\");\n\t\t\treq.open(\"POST\",target,true);\n\t\t\treq.send(formData);\n\t\t});\n\t};\n\n\t$(\".widget_item a\").click(clickHandle);\n\n\tlet changeHandle = function(ev){\n\t\tlet wtype = this.options[this.selectedIndex].value;\n\t\tlet typeBlock = this.closest(\".widget_edit\").querySelector(\".wtypes\");\n\t\ttypeBlock.className = \"wtypes wtype_\"+wtype;\n\t};\n\t$(\".wtype_sel\").change(changeHandle);\n\n\t$(\".widget_new a\").click(function(ev){\n\t\tlog(\"clicked widget_new a\")\n\t\tlet widgetList = this.closest(\".panel_widgets\");\n\t\tlet widgetNew = this.closest(\".widget_new\");\n\t\tlet widgetTmpl = document.getElementById(\"widgetTmpl\").querySelector(\".widget_item\");\n\t\tlet n = widgetTmpl.cloneNode(true);\n\t\tn.querySelector(\".wside\").value = this.getAttribute(\"data-dock\");\n\t\twidgetList.insertBefore(n,widgetNew);\n\t\t$(\".widget_item a\").unbind(\"click\");\n\t\t$(\".widget_item a\").click(clickHandle);\n\t\t$(\".wtype_sel\").unbind(\"change\");\n\t\t$(\".wtype_sel\").change(changeHandle);\n\t});\n\n\t$(\".widget_save\").click(function(ev){\n\t\tlog(\"in .widget_save\")\n\t\tev.preventDefault();\n\t\tev.stopPropagation();\n\t\tlet pform = this.closest(\"form\");\n\t\tlet dat = new URLSearchParams();\n\t\tfor (const pair of new FormData(pform)) dat.append(pair[0], pair[1]);\n\t\tdat.append(\"s\",me.User.S);\n\t\tvar req = new XMLHttpRequest();\n\t\treq.open(\"POST\",pform.getAttribute(\"action\"));\n\t\treq.send(dat);\n\t});\n});"
  },
  {
    "path": "pubnot/chartist/chartist-plugin-legend.css",
    "content": ".ct-legend {\n    position: relative;\n           z-index: 10;\n           list-style: none;\n           text-align: center;\n       }\n       .ct-legend li {\n           position: relative;\n           padding-left: 23px;\n           margin-right: 10px;\n           margin-bottom: 3px;\n           cursor: pointer;\n           display: inline-block;\n       }\n       .ct-legend li:before {\n           width: 12px;\n           height: 12px;\n           position: absolute;\n           left: 0;\n           content: '';\n           border: 3px solid transparent;\n           border-radius: 2px;\n       }\n       .ct-legend li.inactive:before {\n           background: transparent;\n       }\n       .ct-legend.ct-legend-inside {\n           position: absolute;\n           top: 0;\n           right: 0;\n       }\n       .ct-legend.ct-legend-inside li{\n           display: block;\n           margin: 0;\n       }\n       .ct-legend .ct-series-0:before {\n           background-color: #d70206;\n           border-color: #d70206;\n       }\n       .ct-legend .ct-series-1:before {\n           background-color: #f05b4f;\n           border-color: #f05b4f;\n       }\n       .ct-legend .ct-series-2:before {\n           background-color: #f4c63d;\n           border-color: #f4c63d;\n       }\n       .ct-legend .ct-series-3:before {\n           background-color: #d17905;\n           border-color: #d17905;\n       }\n       .ct-legend .ct-series-4:before {\n           background-color: #453d3f;\n           border-color: #453d3f;\n       }\n       .ct-legend .ct-series-5:before {\n           background-color: #59922b;\n           border-color: #59922b;\n       }\n       .ct-legend .ct-series-6:before {\n           background-color: #0544d3;\n           border-color: #0544d3;\n       }\n\n       .ct-chart-line-multipleseries .ct-legend .ct-series-0:before {\n          background-color: #d70206;\n          border-color: #d70206;\n       }\n       .ct-chart-line-multipleseries .ct-legend .ct-series-1:before {\n          background-color: #f4c63d;\n          border-color: #f4c63d;\n       }\n       .ct-chart-line-multipleseries .ct-legend li.inactive:before {\n          background: transparent;\n        }"
  },
  {
    "path": "pubnot/chartist/chartist.css",
    "content": ".ct-label {\n  fill: rgba(0, 0, 0, 0.4);\n  color: rgba(0, 0, 0, 0.4);\n  font-size: 0.75rem;\n  line-height: 1; }\n\n.ct-chart-line .ct-label,\n.ct-chart-bar .ct-label {\n  display: block;\n  display: -webkit-box;\n  display: -moz-box;\n  display: -ms-flexbox;\n  display: -webkit-flex;\n  display: flex; }\n\n.ct-chart-pie .ct-label,\n.ct-chart-donut .ct-label {\n  dominant-baseline: central; }\n\n.ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-vertical.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-label.ct-vertical.ct-end {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: end; }\n\n.ct-grid {\n  stroke: rgba(0, 0, 0, 0.2);\n  stroke-width: 1px;\n  stroke-dasharray: 2px; }\n\n.ct-grid-background {\n  fill: none; }\n\n.ct-point {\n  stroke-width: 10px;\n  stroke-linecap: round; }\n\n.ct-line {\n  fill: none;\n  stroke-width: 4px; }\n\n.ct-area {\n  stroke: none;\n  fill-opacity: 0.1; }\n\n.ct-bar {\n  fill: none;\n  stroke-width: 10px; }\n\n.ct-slice-donut {\n  fill: none;\n  stroke-width: 60px; }\n\n.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {\n  stroke: #d70206; }\n\n.ct-series-a .ct-slice-pie, .ct-series-a .ct-slice-donut-solid, .ct-series-a .ct-area {\n  fill: #d70206; }\n\n.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-b .ct-slice-pie, .ct-series-b .ct-slice-donut-solid, .ct-series-b .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {\n  stroke: #f4c63d; }\n\n.ct-series-c .ct-slice-pie, .ct-series-c .ct-slice-donut-solid, .ct-series-c .ct-area {\n  fill: #f4c63d; }\n\n.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {\n  stroke: #d17905; }\n\n.ct-series-d .ct-slice-pie, .ct-series-d .ct-slice-donut-solid, .ct-series-d .ct-area {\n  fill: #d17905; }\n\n.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {\n  stroke: #453d3f; }\n\n.ct-series-e .ct-slice-pie, .ct-series-e .ct-slice-donut-solid, .ct-series-e .ct-area {\n  fill: #453d3f; }\n\n.ct-series-f .ct-point, .ct-series-f .ct-line, .ct-series-f .ct-bar, .ct-series-f .ct-slice-donut {\n  stroke: #59922b; }\n\n.ct-series-f .ct-slice-pie, .ct-series-f .ct-slice-donut-solid, .ct-series-f .ct-area {\n  fill: #59922b; }\n\n.ct-series-g .ct-point, .ct-series-g .ct-line, .ct-series-g .ct-bar, .ct-series-g .ct-slice-donut {\n  stroke: #0544d3; }\n\n.ct-series-g .ct-slice-pie, .ct-series-g .ct-slice-donut-solid, .ct-series-g .ct-area {\n  fill: #0544d3; }\n\n.ct-series-h .ct-point, .ct-series-h .ct-line, .ct-series-h .ct-bar, .ct-series-h .ct-slice-donut {\n  stroke: #6b0392; }\n\n.ct-series-h .ct-slice-pie, .ct-series-h .ct-slice-donut-solid, .ct-series-h .ct-area {\n  fill: #6b0392; }\n\n.ct-series-i .ct-point, .ct-series-i .ct-line, .ct-series-i .ct-bar, .ct-series-i .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-i .ct-slice-pie, .ct-series-i .ct-slice-donut-solid, .ct-series-i .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-j .ct-point, .ct-series-j .ct-line, .ct-series-j .ct-bar, .ct-series-j .ct-slice-donut {\n  stroke: #dda458; }\n\n.ct-series-j .ct-slice-pie, .ct-series-j .ct-slice-donut-solid, .ct-series-j .ct-area {\n  fill: #dda458; }\n\n.ct-series-k .ct-point, .ct-series-k .ct-line, .ct-series-k .ct-bar, .ct-series-k .ct-slice-donut {\n  stroke: #eacf7d; }\n\n.ct-series-k .ct-slice-pie, .ct-series-k .ct-slice-donut-solid, .ct-series-k .ct-area {\n  fill: #eacf7d; }\n\n.ct-series-l .ct-point, .ct-series-l .ct-line, .ct-series-l .ct-bar, .ct-series-l .ct-slice-donut {\n  stroke: #86797d; }\n\n.ct-series-l .ct-slice-pie, .ct-series-l .ct-slice-donut-solid, .ct-series-l .ct-area {\n  fill: #86797d; }\n\n.ct-series-m .ct-point, .ct-series-m .ct-line, .ct-series-m .ct-bar, .ct-series-m .ct-slice-donut {\n  stroke: #b2c326; }\n\n.ct-series-m .ct-slice-pie, .ct-series-m .ct-slice-donut-solid, .ct-series-m .ct-area {\n  fill: #b2c326; }\n\n.ct-series-n .ct-point, .ct-series-n .ct-line, .ct-series-n .ct-bar, .ct-series-n .ct-slice-donut {\n  stroke: #6188e2; }\n\n.ct-series-n .ct-slice-pie, .ct-series-n .ct-slice-donut-solid, .ct-series-n .ct-area {\n  fill: #6188e2; }\n\n.ct-series-o .ct-point, .ct-series-o .ct-line, .ct-series-o .ct-bar, .ct-series-o .ct-slice-donut {\n  stroke: #a748ca; }\n\n.ct-series-o .ct-slice-pie, .ct-series-o .ct-slice-donut-solid, .ct-series-o .ct-area {\n  fill: #a748ca; }\n\n.ct-square {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-square:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 100%; }\n  .ct-square:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-square > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 93.75%; }\n  .ct-minor-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 88.8888888889%; }\n  .ct-major-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 83.3333333333%; }\n  .ct-minor-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 80%; }\n  .ct-major-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fourth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fourth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 75%; }\n  .ct-perfect-fourth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fourth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fifth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fifth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 66.6666666667%; }\n  .ct-perfect-fifth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fifth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 62.5%; }\n  .ct-minor-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-golden-section {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-golden-section:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 61.804697157%; }\n  .ct-golden-section:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-golden-section > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 60%; }\n  .ct-major-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 56.25%; }\n  .ct-minor-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 53.3333333333%; }\n  .ct-major-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 50%; }\n  .ct-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-tenth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-tenth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 40%; }\n  .ct-major-tenth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-tenth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-eleventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-eleventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 37.5%; }\n  .ct-major-eleventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-eleventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-twelfth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-twelfth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 33.3333333333%; }\n  .ct-major-twelfth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-twelfth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-double-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-double-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 25%; }\n  .ct-double-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-double-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n/*# sourceMappingURL=chartist.css.map */"
  },
  {
    "path": "pubnot/chartist/chartist.js",
    "content": "(function (root, factory) {\n  if (typeof define === 'function' && define.amd) {\n    // AMD. Register as an anonymous module unless amdModuleId is set\n    define('Chartist', [], function () {\n      return (root['Chartist'] = factory());\n    });\n  } else if (typeof module === 'object' && module.exports) {\n    // Node. Does not work with strict CommonJS, but\n    // only CommonJS-like environments that support module.exports,\n    // like Node.\n    module.exports = factory();\n  } else {\n    root['Chartist'] = factory();\n  }\n}(this, function () {\n\n/* Chartist.js 0.11.0\n * Copyright © 2017 Gion Kunz\n * Free to use under either the WTFPL license or the MIT license.\n * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-WTFPL\n * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-MIT\n */\n/**\n * The core module of Chartist that is mainly providing static functions and higher level functions for chart modules.\n *\n * @module Chartist.Core\n */\nvar Chartist = {\n  version: '0.11.0'\n};\n\n(function (window, document, Chartist) {\n  'use strict';\n\n  /**\n   * This object contains all namespaces used within Chartist.\n   *\n   * @memberof Chartist.Core\n   * @type {{svg: string, xmlns: string, xhtml: string, xlink: string, ct: string}}\n   */\n  Chartist.namespaces = {\n    svg: 'http://www.w3.org/2000/svg',\n    xmlns: 'http://www.w3.org/2000/xmlns/',\n    xhtml: 'http://www.w3.org/1999/xhtml',\n    xlink: 'http://www.w3.org/1999/xlink',\n    ct: 'http://gionkunz.github.com/chartist-js/ct'\n  };\n\n  /**\n   * Helps to simplify functional style code\n   *\n   * @memberof Chartist.Core\n   * @param {*} n This exact value will be returned by the noop function\n   * @return {*} The same value that was provided to the n parameter\n   */\n  Chartist.noop = function (n) {\n    return n;\n  };\n\n  /**\n   * Generates a-z from a number 0 to 26\n   *\n   * @memberof Chartist.Core\n   * @param {Number} n A number from 0 to 26 that will result in a letter a-z\n   * @return {String} A character from a-z based on the input number n\n   */\n  Chartist.alphaNumerate = function (n) {\n    // Limit to a-z\n    return String.fromCharCode(97 + n % 26);\n  };\n\n  /**\n   * Simple recursive object extend\n   *\n   * @memberof Chartist.Core\n   * @param {Object} target Target object where the source will be merged into\n   * @param {Object...} sources This object (objects) will be merged into target and then target is returned\n   * @return {Object} An object that has the same reference as target but is extended and merged with the properties of source\n   */\n  Chartist.extend = function (target) {\n    var i, source, sourceProp;\n    target = target || {};\n\n    for (i = 1; i < arguments.length; i++) {\n      source = arguments[i];\n      for (var prop in source) {\n        sourceProp = source[prop];\n        if (typeof sourceProp === 'object' && sourceProp !== null && !(sourceProp instanceof Array)) {\n          target[prop] = Chartist.extend(target[prop], sourceProp);\n        } else {\n          target[prop] = sourceProp;\n        }\n      }\n    }\n\n    return target;\n  };\n\n  /**\n   * Replaces all occurrences of subStr in str with newSubStr and returns a new string.\n   *\n   * @memberof Chartist.Core\n   * @param {String} str\n   * @param {String} subStr\n   * @param {String} newSubStr\n   * @return {String}\n   */\n  Chartist.replaceAll = function(str, subStr, newSubStr) {\n    return str.replace(new RegExp(subStr, 'g'), newSubStr);\n  };\n\n  /**\n   * Converts a number to a string with a unit. If a string is passed then this will be returned unmodified.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value\n   * @param {String} unit\n   * @return {String} Returns the passed number value with unit.\n   */\n  Chartist.ensureUnit = function(value, unit) {\n    if(typeof value === 'number') {\n      value = value + unit;\n    }\n\n    return value;\n  };\n\n  /**\n   * Converts a number or string to a quantity object.\n   *\n   * @memberof Chartist.Core\n   * @param {String|Number} input\n   * @return {Object} Returns an object containing the value as number and the unit as string.\n   */\n  Chartist.quantity = function(input) {\n    if (typeof input === 'string') {\n      var match = (/^(\\d+)\\s*(.*)$/g).exec(input);\n      return {\n        value : +match[1],\n        unit: match[2] || undefined\n      };\n    }\n    return { value: input };\n  };\n\n  /**\n   * This is a wrapper around document.querySelector that will return the query if it's already of type Node\n   *\n   * @memberof Chartist.Core\n   * @param {String|Node} query The query to use for selecting a Node or a DOM node that will be returned directly\n   * @return {Node}\n   */\n  Chartist.querySelector = function(query) {\n    return query instanceof Node ? query : document.querySelector(query);\n  };\n\n  /**\n   * Functional style helper to produce array with given length initialized with undefined values\n   *\n   * @memberof Chartist.Core\n   * @param length\n   * @return {Array}\n   */\n  Chartist.times = function(length) {\n    return Array.apply(null, new Array(length));\n  };\n\n  /**\n   * Sum helper to be used in reduce functions\n   *\n   * @memberof Chartist.Core\n   * @param previous\n   * @param current\n   * @return {*}\n   */\n  Chartist.sum = function(previous, current) {\n    return previous + (current ? current : 0);\n  };\n\n  /**\n   * Multiply helper to be used in `Array.map` for multiplying each value of an array with a factor.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} factor\n   * @returns {Function} Function that can be used in `Array.map` to multiply each value in an array\n   */\n  Chartist.mapMultiply = function(factor) {\n    return function(num) {\n      return num * factor;\n    };\n  };\n\n  /**\n   * Add helper to be used in `Array.map` for adding a addend to each value of an array.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} addend\n   * @returns {Function} Function that can be used in `Array.map` to add a addend to each value in an array\n   */\n  Chartist.mapAdd = function(addend) {\n    return function(num) {\n      return num + addend;\n    };\n  };\n\n  /**\n   * Map for multi dimensional arrays where their nested arrays will be mapped in serial. The output array will have the length of the largest nested array. The callback function is called with variable arguments where each argument is the nested array value (or undefined if there are no more values).\n   *\n   * @memberof Chartist.Core\n   * @param arr\n   * @param cb\n   * @return {Array}\n   */\n  Chartist.serialMap = function(arr, cb) {\n    var result = [],\n        length = Math.max.apply(null, arr.map(function(e) {\n          return e.length;\n        }));\n\n    Chartist.times(length).forEach(function(e, index) {\n      var args = arr.map(function(e) {\n        return e[index];\n      });\n\n      result[index] = cb.apply(null, args);\n    });\n\n    return result;\n  };\n\n  /**\n   * This helper function can be used to round values with certain precision level after decimal. This is used to prevent rounding errors near float point precision limit.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value The value that should be rounded with precision\n   * @param {Number} [digits] The number of digits after decimal used to do the rounding\n   * @returns {number} Rounded value\n   */\n  Chartist.roundWithPrecision = function(value, digits) {\n    var precision = Math.pow(10, digits || Chartist.precision);\n    return Math.round(value * precision) / precision;\n  };\n\n  /**\n   * Precision level used internally in Chartist for rounding. If you require more decimal places you can increase this number.\n   *\n   * @memberof Chartist.Core\n   * @type {number}\n   */\n  Chartist.precision = 8;\n\n  /**\n   * A map with characters to escape for strings to be safely used as attribute values.\n   *\n   * @memberof Chartist.Core\n   * @type {Object}\n   */\n  Chartist.escapingMap = {\n    '&': '&amp;',\n    '<': '&lt;',\n    '>': '&gt;',\n    '\"': '&quot;',\n    '\\'': '&#039;'\n  };\n\n  /**\n   * This function serializes arbitrary data to a string. In case of data that can't be easily converted to a string, this function will create a wrapper object and serialize the data using JSON.stringify. The outcoming string will always be escaped using Chartist.escapingMap.\n   * If called with null or undefined the function will return immediately with null or undefined.\n   *\n   * @memberof Chartist.Core\n   * @param {Number|String|Object} data\n   * @return {String}\n   */\n  Chartist.serialize = function(data) {\n    if(data === null || data === undefined) {\n      return data;\n    } else if(typeof data === 'number') {\n      data = ''+data;\n    } else if(typeof data === 'object') {\n      data = JSON.stringify({data: data});\n    }\n\n    return Object.keys(Chartist.escapingMap).reduce(function(result, key) {\n      return Chartist.replaceAll(result, key, Chartist.escapingMap[key]);\n    }, data);\n  };\n\n  /**\n   * This function de-serializes a string previously serialized with Chartist.serialize. The string will always be unescaped using Chartist.escapingMap before it's returned. Based on the input value the return type can be Number, String or Object. JSON.parse is used with try / catch to see if the unescaped string can be parsed into an Object and this Object will be returned on success.\n   *\n   * @memberof Chartist.Core\n   * @param {String} data\n   * @return {String|Number|Object}\n   */\n  Chartist.deserialize = function(data) {\n    if(typeof data !== 'string') {\n      return data;\n    }\n\n    data = Object.keys(Chartist.escapingMap).reduce(function(result, key) {\n      return Chartist.replaceAll(result, Chartist.escapingMap[key], key);\n    }, data);\n\n    try {\n      data = JSON.parse(data);\n      data = data.data !== undefined ? data.data : data;\n    } catch(e) {}\n\n    return data;\n  };\n\n  /**\n   * Create or reinitialize the SVG element for the chart\n   *\n   * @memberof Chartist.Core\n   * @param {Node} container The containing DOM Node object that will be used to plant the SVG element\n   * @param {String} width Set the width of the SVG element. Default is 100%\n   * @param {String} height Set the height of the SVG element. Default is 100%\n   * @param {String} className Specify a class to be added to the SVG element\n   * @return {Object} The created/reinitialized SVG element\n   */\n  Chartist.createSvg = function (container, width, height, className) {\n    var svg;\n\n    width = width || '100%';\n    height = height || '100%';\n\n    // Check if there is a previous SVG element in the container that contains the Chartist XML namespace and remove it\n    // Since the DOM API does not support namespaces we need to manually search the returned list http://www.w3.org/TR/selectors-api/\n    Array.prototype.slice.call(container.querySelectorAll('svg')).filter(function filterChartistSvgObjects(svg) {\n      return svg.getAttributeNS(Chartist.namespaces.xmlns, 'ct');\n    }).forEach(function removePreviousElement(svg) {\n      container.removeChild(svg);\n    });\n\n    // Create svg object with width and height or use 100% as default\n    svg = new Chartist.Svg('svg').attr({\n      width: width,\n      height: height\n    }).addClass(className);\n\n    svg._node.style.width = width;\n    svg._node.style.height = height;\n\n    // Add the DOM node to our container\n    container.appendChild(svg._node);\n\n    return svg;\n  };\n\n  /**\n   * Ensures that the data object passed as second argument to the charts is present and correctly initialized.\n   *\n   * @param  {Object} data The data object that is passed as second argument to the charts\n   * @return {Object} The normalized data object\n   */\n  Chartist.normalizeData = function(data, reverse, multi) {\n    var labelCount;\n    var output = {\n      raw: data,\n      normalized: {}\n    };\n\n    // Check if we should generate some labels based on existing series data\n    output.normalized.series = Chartist.getDataArray({\n      series: data.series || []\n    }, reverse, multi);\n\n    // If all elements of the normalized data array are arrays we're dealing with\n    // multi series data and we need to find the largest series if they are un-even\n    if (output.normalized.series.every(function(value) {\n        return value instanceof Array;\n      })) {\n      // Getting the series with the the most elements\n      labelCount = Math.max.apply(null, output.normalized.series.map(function(series) {\n        return series.length;\n      }));\n    } else {\n      // We're dealing with Pie data so we just take the normalized array length\n      labelCount = output.normalized.series.length;\n    }\n\n    output.normalized.labels = (data.labels || []).slice();\n    // Padding the labels to labelCount with empty strings\n    Array.prototype.push.apply(\n      output.normalized.labels,\n      Chartist.times(Math.max(0, labelCount - output.normalized.labels.length)).map(function() {\n        return '';\n      })\n    );\n\n    if(reverse) {\n      Chartist.reverseData(output.normalized);\n    }\n\n    return output;\n  };\n\n  /**\n   * This function safely checks if an objects has an owned property.\n   *\n   * @param {Object} object The object where to check for a property\n   * @param {string} property The property name\n   * @returns {boolean} Returns true if the object owns the specified property\n   */\n  Chartist.safeHasProperty = function(object, property) {\n    return object !== null &&\n      typeof object === 'object' &&\n      object.hasOwnProperty(property);\n  };\n\n  /**\n   * Checks if a value is considered a hole in the data series.\n   *\n   * @param {*} value\n   * @returns {boolean} True if the value is considered a data hole\n   */\n  Chartist.isDataHoleValue = function(value) {\n    return value === null ||\n      value === undefined ||\n      (typeof value === 'number' && isNaN(value));\n  };\n\n  /**\n   * Reverses the series, labels and series data arrays.\n   *\n   * @memberof Chartist.Core\n   * @param data\n   */\n  Chartist.reverseData = function(data) {\n    data.labels.reverse();\n    data.series.reverse();\n    for (var i = 0; i < data.series.length; i++) {\n      if(typeof(data.series[i]) === 'object' && data.series[i].data !== undefined) {\n        data.series[i].data.reverse();\n      } else if(data.series[i] instanceof Array) {\n        data.series[i].reverse();\n      }\n    }\n  };\n\n  /**\n   * Convert data series into plain array\n   *\n   * @memberof Chartist.Core\n   * @param {Object} data The series object that contains the data to be visualized in the chart\n   * @param {Boolean} [reverse] If true the whole data is reversed by the getDataArray call. This will modify the data object passed as first parameter. The labels as well as the series order is reversed. The whole series data arrays are reversed too.\n   * @param {Boolean} [multi] Create a multi dimensional array from a series data array where a value object with `x` and `y` values will be created.\n   * @return {Array} A plain array that contains the data to be visualized in the chart\n   */\n  Chartist.getDataArray = function(data, reverse, multi) {\n    // Recursively walks through nested arrays and convert string values to numbers and objects with value properties\n    // to values. Check the tests in data core -> data normalization for a detailed specification of expected values\n    function recursiveConvert(value) {\n      if(Chartist.safeHasProperty(value, 'value')) {\n        // We are dealing with value object notation so we need to recurse on value property\n        return recursiveConvert(value.value);\n      } else if(Chartist.safeHasProperty(value, 'data')) {\n        // We are dealing with series object notation so we need to recurse on data property\n        return recursiveConvert(value.data);\n      } else if(value instanceof Array) {\n        // Data is of type array so we need to recurse on the series\n        return value.map(recursiveConvert);\n      } else if(Chartist.isDataHoleValue(value)) {\n        // We're dealing with a hole in the data and therefore need to return undefined\n        // We're also returning undefined for multi value output\n        return undefined;\n      } else {\n        // We need to prepare multi value output (x and y data)\n        if(multi) {\n          var multiValue = {};\n\n          // Single series value arrays are assumed to specify the Y-Axis value\n          // For example: [1, 2] => [{x: undefined, y: 1}, {x: undefined, y: 2}]\n          // If multi is a string then it's assumed that it specified which dimension should be filled as default\n          if(typeof multi === 'string') {\n            multiValue[multi] = Chartist.getNumberOrUndefined(value);\n          } else {\n            multiValue.y = Chartist.getNumberOrUndefined(value);\n          }\n\n          multiValue.x = value.hasOwnProperty('x') ? Chartist.getNumberOrUndefined(value.x) : multiValue.x;\n          multiValue.y = value.hasOwnProperty('y') ? Chartist.getNumberOrUndefined(value.y) : multiValue.y;\n\n          return multiValue;\n\n        } else {\n          // We can return simple data\n          return Chartist.getNumberOrUndefined(value);\n        }\n      }\n    }\n\n    return data.series.map(recursiveConvert);\n  };\n\n  /**\n   * Converts a number into a padding object.\n   *\n   * @memberof Chartist.Core\n   * @param {Object|Number} padding\n   * @param {Number} [fallback] This value is used to fill missing values if a incomplete padding object was passed\n   * @returns {Object} Returns a padding object containing top, right, bottom, left properties filled with the padding number passed in as argument. If the argument is something else than a number (presumably already a correct padding object) then this argument is directly returned.\n   */\n  Chartist.normalizePadding = function(padding, fallback) {\n    fallback = fallback || 0;\n\n    return typeof padding === 'number' ? {\n      top: padding,\n      right: padding,\n      bottom: padding,\n      left: padding\n    } : {\n      top: typeof padding.top === 'number' ? padding.top : fallback,\n      right: typeof padding.right === 'number' ? padding.right : fallback,\n      bottom: typeof padding.bottom === 'number' ? padding.bottom : fallback,\n      left: typeof padding.left === 'number' ? padding.left : fallback\n    };\n  };\n\n  Chartist.getMetaData = function(series, index) {\n    var value = series.data ? series.data[index] : series[index];\n    return value ? value.meta : undefined;\n  };\n\n  /**\n   * Calculate the order of magnitude for the chart scale\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value The value Range of the chart\n   * @return {Number} The order of magnitude\n   */\n  Chartist.orderOfMagnitude = function (value) {\n    return Math.floor(Math.log(Math.abs(value)) / Math.LN10);\n  };\n\n  /**\n   * Project a data length into screen coordinates (pixels)\n   *\n   * @memberof Chartist.Core\n   * @param {Object} axisLength The svg element for the chart\n   * @param {Number} length Single data value from a series array\n   * @param {Object} bounds All the values to set the bounds of the chart\n   * @return {Number} The projected data length in pixels\n   */\n  Chartist.projectLength = function (axisLength, length, bounds) {\n    return length / bounds.range * axisLength;\n  };\n\n  /**\n   * Get the height of the area in the chart for the data series\n   *\n   * @memberof Chartist.Core\n   * @param {Object} svg The svg element for the chart\n   * @param {Object} options The Object that contains all the optional values for the chart\n   * @return {Number} The height of the area in the chart for the data series\n   */\n  Chartist.getAvailableHeight = function (svg, options) {\n    return Math.max((Chartist.quantity(options.height).value || svg.height()) - (options.chartPadding.top +  options.chartPadding.bottom) - options.axisX.offset, 0);\n  };\n\n  /**\n   * Get highest and lowest value of data array. This Array contains the data that will be visualized in the chart.\n   *\n   * @memberof Chartist.Core\n   * @param {Array} data The array that contains the data to be visualized in the chart\n   * @param {Object} options The Object that contains the chart options\n   * @param {String} dimension Axis dimension 'x' or 'y' used to access the correct value and high / low configuration\n   * @return {Object} An object that contains the highest and lowest value that will be visualized on the chart.\n   */\n  Chartist.getHighLow = function (data, options, dimension) {\n    // TODO: Remove workaround for deprecated global high / low config. Axis high / low configuration is preferred\n    options = Chartist.extend({}, options, dimension ? options['axis' + dimension.toUpperCase()] : {});\n\n    var highLow = {\n        high: options.high === undefined ? -Number.MAX_VALUE : +options.high,\n        low: options.low === undefined ? Number.MAX_VALUE : +options.low\n      };\n    var findHigh = options.high === undefined;\n    var findLow = options.low === undefined;\n\n    // Function to recursively walk through arrays and find highest and lowest number\n    function recursiveHighLow(data) {\n      if(data === undefined) {\n        return undefined;\n      } else if(data instanceof Array) {\n        for (var i = 0; i < data.length; i++) {\n          recursiveHighLow(data[i]);\n        }\n      } else {\n        var value = dimension ? +data[dimension] : +data;\n\n        if (findHigh && value > highLow.high) {\n          highLow.high = value;\n        }\n\n        if (findLow && value < highLow.low) {\n          highLow.low = value;\n        }\n      }\n    }\n\n    // Start to find highest and lowest number recursively\n    if(findHigh || findLow) {\n      recursiveHighLow(data);\n    }\n\n    // Overrides of high / low based on reference value, it will make sure that the invisible reference value is\n    // used to generate the chart. This is useful when the chart always needs to contain the position of the\n    // invisible reference value in the view i.e. for bipolar scales.\n    if (options.referenceValue || options.referenceValue === 0) {\n      highLow.high = Math.max(options.referenceValue, highLow.high);\n      highLow.low = Math.min(options.referenceValue, highLow.low);\n    }\n\n    // If high and low are the same because of misconfiguration or flat data (only the same value) we need\n    // to set the high or low to 0 depending on the polarity\n    if (highLow.high <= highLow.low) {\n      // If both values are 0 we set high to 1\n      if (highLow.low === 0) {\n        highLow.high = 1;\n      } else if (highLow.low < 0) {\n        // If we have the same negative value for the bounds we set bounds.high to 0\n        highLow.high = 0;\n      } else if (highLow.high > 0) {\n        // If we have the same positive value for the bounds we set bounds.low to 0\n        highLow.low = 0;\n      } else {\n        // If data array was empty, values are Number.MAX_VALUE and -Number.MAX_VALUE. Set bounds to prevent errors\n        highLow.high = 1;\n        highLow.low = 0;\n      }\n    }\n\n    return highLow;\n  };\n\n  /**\n   * Checks if a value can be safely coerced to a number. This includes all values except null which result in finite numbers when coerced. This excludes NaN, since it's not finite.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {Boolean}\n   */\n  Chartist.isNumeric = function(value) {\n    return value === null ? false : isFinite(value);\n  };\n\n  /**\n   * Returns true on all falsey values except the numeric value 0.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {boolean}\n   */\n  Chartist.isFalseyButZero = function(value) {\n    return !value && value !== 0;\n  };\n\n  /**\n   * Returns a number if the passed parameter is a valid number or the function will return undefined. On all other values than a valid number, this function will return undefined.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {*}\n   */\n  Chartist.getNumberOrUndefined = function(value) {\n    return Chartist.isNumeric(value) ? +value : undefined;\n  };\n\n  /**\n   * Checks if provided value object is multi value (contains x or y properties)\n   *\n   * @memberof Chartist.Core\n   * @param value\n   */\n  Chartist.isMultiValue = function(value) {\n    return typeof value === 'object' && ('x' in value || 'y' in value);\n  };\n\n  /**\n   * Gets a value from a dimension `value.x` or `value.y` while returning value directly if it's a valid numeric value. If the value is not numeric and it's falsey this function will return `defaultValue`.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @param dimension\n   * @param defaultValue\n   * @returns {*}\n   */\n  Chartist.getMultiValue = function(value, dimension) {\n    if(Chartist.isMultiValue(value)) {\n      return Chartist.getNumberOrUndefined(value[dimension || 'y']);\n    } else {\n      return Chartist.getNumberOrUndefined(value);\n    }\n  };\n\n  /**\n   * Pollard Rho Algorithm to find smallest factor of an integer value. There are more efficient algorithms for factorization, but this one is quite efficient and not so complex.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} num An integer number where the smallest factor should be searched for\n   * @returns {Number} The smallest integer factor of the parameter num.\n   */\n  Chartist.rho = function(num) {\n    if(num === 1) {\n      return num;\n    }\n\n    function gcd(p, q) {\n      if (p % q === 0) {\n        return q;\n      } else {\n        return gcd(q, p % q);\n      }\n    }\n\n    function f(x) {\n      return x * x + 1;\n    }\n\n    var x1 = 2, x2 = 2, divisor;\n    if (num % 2 === 0) {\n      return 2;\n    }\n\n    do {\n      x1 = f(x1) % num;\n      x2 = f(f(x2)) % num;\n      divisor = gcd(Math.abs(x1 - x2), num);\n    } while (divisor === 1);\n\n    return divisor;\n  };\n\n  /**\n   * Calculate and retrieve all the bounds for the chart and return them in one array\n   *\n   * @memberof Chartist.Core\n   * @param {Number} axisLength The length of the Axis used for\n   * @param {Object} highLow An object containing a high and low property indicating the value range of the chart.\n   * @param {Number} scaleMinSpace The minimum projected length a step should result in\n   * @param {Boolean} onlyInteger\n   * @return {Object} All the values to set the bounds of the chart\n   */\n  Chartist.getBounds = function (axisLength, highLow, scaleMinSpace, onlyInteger) {\n    var i,\n      optimizationCounter = 0,\n      newMin,\n      newMax,\n      bounds = {\n        high: highLow.high,\n        low: highLow.low\n      };\n\n    bounds.valueRange = bounds.high - bounds.low;\n    bounds.oom = Chartist.orderOfMagnitude(bounds.valueRange);\n    bounds.step = Math.pow(10, bounds.oom);\n    bounds.min = Math.floor(bounds.low / bounds.step) * bounds.step;\n    bounds.max = Math.ceil(bounds.high / bounds.step) * bounds.step;\n    bounds.range = bounds.max - bounds.min;\n    bounds.numberOfSteps = Math.round(bounds.range / bounds.step);\n\n    // Optimize scale step by checking if subdivision is possible based on horizontalGridMinSpace\n    // If we are already below the scaleMinSpace value we will scale up\n    var length = Chartist.projectLength(axisLength, bounds.step, bounds);\n    var scaleUp = length < scaleMinSpace;\n    var smallestFactor = onlyInteger ? Chartist.rho(bounds.range) : 0;\n\n    // First check if we should only use integer steps and if step 1 is still larger than scaleMinSpace so we can use 1\n    if(onlyInteger && Chartist.projectLength(axisLength, 1, bounds) >= scaleMinSpace) {\n      bounds.step = 1;\n    } else if(onlyInteger && smallestFactor < bounds.step && Chartist.projectLength(axisLength, smallestFactor, bounds) >= scaleMinSpace) {\n      // If step 1 was too small, we can try the smallest factor of range\n      // If the smallest factor is smaller than the current bounds.step and the projected length of smallest factor\n      // is larger than the scaleMinSpace we should go for it.\n      bounds.step = smallestFactor;\n    } else {\n      // Trying to divide or multiply by 2 and find the best step value\n      while (true) {\n        if (scaleUp && Chartist.projectLength(axisLength, bounds.step, bounds) <= scaleMinSpace) {\n          bounds.step *= 2;\n        } else if (!scaleUp && Chartist.projectLength(axisLength, bounds.step / 2, bounds) >= scaleMinSpace) {\n          bounds.step /= 2;\n          if(onlyInteger && bounds.step % 1 !== 0) {\n            bounds.step *= 2;\n            break;\n          }\n        } else {\n          break;\n        }\n\n        if(optimizationCounter++ > 1000) {\n          throw new Error('Exceeded maximum number of iterations while optimizing scale step!');\n        }\n      }\n    }\n\n    var EPSILON = 2.221E-16;\n    bounds.step = Math.max(bounds.step, EPSILON);\n    function safeIncrement(value, increment) {\n      // If increment is too small use *= (1+EPSILON) as a simple nextafter\n      if (value === (value += increment)) {\n      \tvalue *= (1 + (increment > 0 ? EPSILON : -EPSILON));\n      }\n      return value;\n    }\n\n    // Narrow min and max based on new step\n    newMin = bounds.min;\n    newMax = bounds.max;\n    while (newMin + bounds.step <= bounds.low) {\n    \tnewMin = safeIncrement(newMin, bounds.step);\n    }\n    while (newMax - bounds.step >= bounds.high) {\n    \tnewMax = safeIncrement(newMax, -bounds.step);\n    }\n    bounds.min = newMin;\n    bounds.max = newMax;\n    bounds.range = bounds.max - bounds.min;\n\n    var values = [];\n    for (i = bounds.min; i <= bounds.max; i = safeIncrement(i, bounds.step)) {\n      var value = Chartist.roundWithPrecision(i);\n      if (value !== values[values.length - 1]) {\n        values.push(value);\n      }\n    }\n    bounds.values = values;\n    return bounds;\n  };\n\n  /**\n   * Calculate cartesian coordinates of polar coordinates\n   *\n   * @memberof Chartist.Core\n   * @param {Number} centerX X-axis coordinates of center point of circle segment\n   * @param {Number} centerY X-axis coordinates of center point of circle segment\n   * @param {Number} radius Radius of circle segment\n   * @param {Number} angleInDegrees Angle of circle segment in degrees\n   * @return {{x:Number, y:Number}} Coordinates of point on circumference\n   */\n  Chartist.polarToCartesian = function (centerX, centerY, radius, angleInDegrees) {\n    var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;\n\n    return {\n      x: centerX + (radius * Math.cos(angleInRadians)),\n      y: centerY + (radius * Math.sin(angleInRadians))\n    };\n  };\n\n  /**\n   * Initialize chart drawing rectangle (area where chart is drawn) x1,y1 = bottom left / x2,y2 = top right\n   *\n   * @memberof Chartist.Core\n   * @param {Object} svg The svg element for the chart\n   * @param {Object} options The Object that contains all the optional values for the chart\n   * @param {Number} [fallbackPadding] The fallback padding if partial padding objects are used\n   * @return {Object} The chart rectangles coordinates inside the svg element plus the rectangles measurements\n   */\n  Chartist.createChartRect = function (svg, options, fallbackPadding) {\n    var hasAxis = !!(options.axisX || options.axisY);\n    var yAxisOffset = hasAxis ? options.axisY.offset : 0;\n    var xAxisOffset = hasAxis ? options.axisX.offset : 0;\n    // If width or height results in invalid value (including 0) we fallback to the unitless settings or even 0\n    var width = svg.width() || Chartist.quantity(options.width).value || 0;\n    var height = svg.height() || Chartist.quantity(options.height).value || 0;\n    var normalizedPadding = Chartist.normalizePadding(options.chartPadding, fallbackPadding);\n\n    // If settings were to small to cope with offset (legacy) and padding, we'll adjust\n    width = Math.max(width, yAxisOffset + normalizedPadding.left + normalizedPadding.right);\n    height = Math.max(height, xAxisOffset + normalizedPadding.top + normalizedPadding.bottom);\n\n    var chartRect = {\n      padding: normalizedPadding,\n      width: function () {\n        return this.x2 - this.x1;\n      },\n      height: function () {\n        return this.y1 - this.y2;\n      }\n    };\n\n    if(hasAxis) {\n      if (options.axisX.position === 'start') {\n        chartRect.y2 = normalizedPadding.top + xAxisOffset;\n        chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);\n      } else {\n        chartRect.y2 = normalizedPadding.top;\n        chartRect.y1 = Math.max(height - normalizedPadding.bottom - xAxisOffset, chartRect.y2 + 1);\n      }\n\n      if (options.axisY.position === 'start') {\n        chartRect.x1 = normalizedPadding.left + yAxisOffset;\n        chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);\n      } else {\n        chartRect.x1 = normalizedPadding.left;\n        chartRect.x2 = Math.max(width - normalizedPadding.right - yAxisOffset, chartRect.x1 + 1);\n      }\n    } else {\n      chartRect.x1 = normalizedPadding.left;\n      chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);\n      chartRect.y2 = normalizedPadding.top;\n      chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);\n    }\n\n    return chartRect;\n  };\n\n  /**\n   * Creates a grid line based on a projected value.\n   *\n   * @memberof Chartist.Core\n   * @param position\n   * @param index\n   * @param axis\n   * @param offset\n   * @param length\n   * @param group\n   * @param classes\n   * @param eventEmitter\n   */\n  Chartist.createGrid = function(position, index, axis, offset, length, group, classes, eventEmitter) {\n    var positionalData = {};\n    positionalData[axis.units.pos + '1'] = position;\n    positionalData[axis.units.pos + '2'] = position;\n    positionalData[axis.counterUnits.pos + '1'] = offset;\n    positionalData[axis.counterUnits.pos + '2'] = offset + length;\n\n    var gridElement = group.elem('line', positionalData, classes.join(' '));\n\n    // Event for grid draw\n    eventEmitter.emit('draw',\n      Chartist.extend({\n        type: 'grid',\n        axis: axis,\n        index: index,\n        group: group,\n        element: gridElement\n      }, positionalData)\n    );\n  };\n\n  /**\n   * Creates a grid background rect and emits the draw event.\n   *\n   * @memberof Chartist.Core\n   * @param gridGroup\n   * @param chartRect\n   * @param className\n   * @param eventEmitter\n   */\n  Chartist.createGridBackground = function (gridGroup, chartRect, className, eventEmitter) {\n    var gridBackground = gridGroup.elem('rect', {\n        x: chartRect.x1,\n        y: chartRect.y2,\n        width: chartRect.width(),\n        height: chartRect.height(),\n      }, className, true);\n\n      // Event for grid background draw\n      eventEmitter.emit('draw', {\n        type: 'gridBackground',\n        group: gridGroup,\n        element: gridBackground\n      });\n  };\n\n  /**\n   * Creates a label based on a projected value and an axis.\n   *\n   * @memberof Chartist.Core\n   * @param position\n   * @param length\n   * @param index\n   * @param labels\n   * @param axis\n   * @param axisOffset\n   * @param labelOffset\n   * @param group\n   * @param classes\n   * @param useForeignObject\n   * @param eventEmitter\n   */\n  Chartist.createLabel = function(position, length, index, labels, axis, axisOffset, labelOffset, group, classes, useForeignObject, eventEmitter) {\n    var labelElement;\n    var positionalData = {};\n\n    positionalData[axis.units.pos] = position + labelOffset[axis.units.pos];\n    positionalData[axis.counterUnits.pos] = labelOffset[axis.counterUnits.pos];\n    positionalData[axis.units.len] = length;\n    positionalData[axis.counterUnits.len] = Math.max(0, axisOffset - 10);\n\n    if(useForeignObject) {\n      // We need to set width and height explicitly to px as span will not expand with width and height being\n      // 100% in all browsers\n      var content = document.createElement('span');\n      content.className = classes.join(' ');\n      content.setAttribute('xmlns', Chartist.namespaces.xhtml);\n      content.innerText = labels[index];\n      content.style[axis.units.len] = Math.round(positionalData[axis.units.len]) + 'px';\n      content.style[axis.counterUnits.len] = Math.round(positionalData[axis.counterUnits.len]) + 'px';\n\n      labelElement = group.foreignObject(content, Chartist.extend({\n        style: 'overflow: visible;'\n      }, positionalData));\n    } else {\n      labelElement = group.elem('text', positionalData, classes.join(' ')).text(labels[index]);\n    }\n\n    eventEmitter.emit('draw', Chartist.extend({\n      type: 'label',\n      axis: axis,\n      index: index,\n      group: group,\n      element: labelElement,\n      text: labels[index]\n    }, positionalData));\n  };\n\n  /**\n   * Helper to read series specific options from options object. It automatically falls back to the global option if\n   * there is no option in the series options.\n   *\n   * @param {Object} series Series object\n   * @param {Object} options Chartist options object\n   * @param {string} key The options key that should be used to obtain the options\n   * @returns {*}\n   */\n  Chartist.getSeriesOption = function(series, options, key) {\n    if(series.name && options.series && options.series[series.name]) {\n      var seriesOptions = options.series[series.name];\n      return seriesOptions.hasOwnProperty(key) ? seriesOptions[key] : options[key];\n    } else {\n      return options[key];\n    }\n  };\n\n  /**\n   * Provides options handling functionality with callback for options changes triggered by responsive options and media query matches\n   *\n   * @memberof Chartist.Core\n   * @param {Object} options Options set by user\n   * @param {Array} responsiveOptions Optional functions to add responsive behavior to chart\n   * @param {Object} eventEmitter The event emitter that will be used to emit the options changed events\n   * @return {Object} The consolidated options object from the defaults, base and matching responsive options\n   */\n  Chartist.optionsProvider = function (options, responsiveOptions, eventEmitter) {\n    var baseOptions = Chartist.extend({}, options),\n      currentOptions,\n      mediaQueryListeners = [],\n      i;\n\n    function updateCurrentOptions(mediaEvent) {\n      var previousOptions = currentOptions;\n      currentOptions = Chartist.extend({}, baseOptions);\n\n      if (responsiveOptions) {\n        for (i = 0; i < responsiveOptions.length; i++) {\n          var mql = window.matchMedia(responsiveOptions[i][0]);\n          if (mql.matches) {\n            currentOptions = Chartist.extend(currentOptions, responsiveOptions[i][1]);\n          }\n        }\n      }\n\n      if(eventEmitter && mediaEvent) {\n        eventEmitter.emit('optionsChanged', {\n          previousOptions: previousOptions,\n          currentOptions: currentOptions\n        });\n      }\n    }\n\n    function removeMediaQueryListeners() {\n      mediaQueryListeners.forEach(function(mql) {\n        mql.removeListener(updateCurrentOptions);\n      });\n    }\n\n    if (!window.matchMedia) {\n      throw 'window.matchMedia not found! Make sure you\\'re using a polyfill.';\n    } else if (responsiveOptions) {\n\n      for (i = 0; i < responsiveOptions.length; i++) {\n        var mql = window.matchMedia(responsiveOptions[i][0]);\n        mql.addListener(updateCurrentOptions);\n        mediaQueryListeners.push(mql);\n      }\n    }\n    // Execute initially without an event argument so we get the correct options\n    updateCurrentOptions();\n\n    return {\n      removeMediaQueryListeners: removeMediaQueryListeners,\n      getCurrentOptions: function getCurrentOptions() {\n        return Chartist.extend({}, currentOptions);\n      }\n    };\n  };\n\n\n  /**\n   * Splits a list of coordinates and associated values into segments. Each returned segment contains a pathCoordinates\n   * valueData property describing the segment.\n   *\n   * With the default options, segments consist of contiguous sets of points that do not have an undefined value. Any\n   * points with undefined values are discarded.\n   *\n   * **Options**\n   * The following options are used to determine how segments are formed\n   * ```javascript\n   * var options = {\n   *   // If fillHoles is true, undefined values are simply discarded without creating a new segment. Assuming other options are default, this returns single segment.\n   *   fillHoles: false,\n   *   // If increasingX is true, the coordinates in all segments have strictly increasing x-values.\n   *   increasingX: false\n   * };\n   * ```\n   *\n   * @memberof Chartist.Core\n   * @param {Array} pathCoordinates List of point coordinates to be split in the form [x1, y1, x2, y2 ... xn, yn]\n   * @param {Array} values List of associated point values in the form [v1, v2 .. vn]\n   * @param {Object} options Options set by user\n   * @return {Array} List of segments, each containing a pathCoordinates and valueData property.\n   */\n  Chartist.splitIntoSegments = function(pathCoordinates, valueData, options) {\n    var defaultOptions = {\n      increasingX: false,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var segments = [];\n    var hole = true;\n\n    for(var i = 0; i < pathCoordinates.length; i += 2) {\n      // If this value is a \"hole\" we set the hole flag\n      if(Chartist.getMultiValue(valueData[i / 2].value) === undefined) {\n      // if(valueData[i / 2].value === undefined) {\n        if(!options.fillHoles) {\n          hole = true;\n        }\n      } else {\n        if(options.increasingX && i >= 2 && pathCoordinates[i] <= pathCoordinates[i-2]) {\n          // X is not increasing, so we need to make sure we start a new segment\n          hole = true;\n        }\n\n\n        // If it's a valid value we need to check if we're coming out of a hole and create a new empty segment\n        if(hole) {\n          segments.push({\n            pathCoordinates: [],\n            valueData: []\n          });\n          // As we have a valid value now, we are not in a \"hole\" anymore\n          hole = false;\n        }\n\n        // Add to the segment pathCoordinates and valueData\n        segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);\n        segments[segments.length - 1].valueData.push(valueData[i / 2]);\n      }\n    }\n\n    return segments;\n  };\n}(window, document, Chartist));\n;/**\n * Chartist path interpolation functions.\n *\n * @module Chartist.Interpolation\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  Chartist.Interpolation = {};\n\n  /**\n   * This interpolation function does not smooth the path and the result is only containing lines and no curves.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.none({\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   *\n   * @memberof Chartist.Interpolation\n   * @return {Function}\n   */\n  Chartist.Interpolation.none = function(options) {\n    var defaultOptions = {\n      fillHoles: false\n    };\n    options = Chartist.extend({}, defaultOptions, options);\n    return function none(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n      var hole = true;\n\n      for(var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var currData = valueData[i / 2];\n\n        if(Chartist.getMultiValue(currData.value) !== undefined) {\n\n          if(hole) {\n            path.move(currX, currY, false, currData);\n          } else {\n            path.line(currX, currY, false, currData);\n          }\n\n          hole = false;\n        } else if(!options.fillHoles) {\n          hole = true;\n        }\n      }\n\n      return path;\n    };\n  };\n\n  /**\n   * Simple smoothing creates horizontal handles that are positioned with a fraction of the length between two data points. You can use the divisor option to specify the amount of smoothing.\n   *\n   * Simple smoothing can be used instead of `Chartist.Smoothing.cardinal` if you'd like to get rid of the artifacts it produces sometimes. Simple smoothing produces less flowing lines but is accurate by hitting the points and it also doesn't swing below or above the given data point.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The simple interpolation function accepts one configuration parameter `divisor`, between 1 and ∞, which controls the smoothing characteristics.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.simple({\n   *     divisor: 2,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the simple interpolation factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.simple = function(options) {\n    var defaultOptions = {\n      divisor: 2,\n      fillHoles: false\n    };\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var d = 1 / Math.max(1, options.divisor);\n\n    return function simple(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n      var prevX, prevY, prevData;\n\n      for(var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var length = (currX - prevX) * d;\n        var currData = valueData[i / 2];\n\n        if(currData.value !== undefined) {\n\n          if(prevData === undefined) {\n            path.move(currX, currY, false, currData);\n          } else {\n            path.curve(\n              prevX + length,\n              prevY,\n              currX - length,\n              currY,\n              currX,\n              currY,\n              false,\n              currData\n            );\n          }\n\n          prevX = currX;\n          prevY = currY;\n          prevData = currData;\n        } else if(!options.fillHoles) {\n          prevX = currX = prevData = undefined;\n        }\n      }\n\n      return path;\n    };\n  };\n\n  /**\n   * Cardinal / Catmull-Rome spline interpolation is the default smoothing function in Chartist. It produces nice results where the splines will always meet the points. It produces some artifacts though when data values are increased or decreased rapidly. The line may not follow a very accurate path and if the line should be accurate this smoothing function does not produce the best results.\n   *\n   * Cardinal splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The cardinal interpolation function accepts one configuration parameter `tension`, between 0 and 1, which controls the smoothing intensity.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.cardinal({\n   *     tension: 1,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the cardinal factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.cardinal = function(options) {\n    var defaultOptions = {\n      tension: 1,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var t = Math.min(1, Math.max(0, options.tension)),\n      c = 1 - t;\n\n    return function cardinal(pathCoordinates, valueData) {\n      // First we try to split the coordinates into segments\n      // This is necessary to treat \"holes\" in line charts\n      var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {\n        fillHoles: options.fillHoles\n      });\n\n      if(!segments.length) {\n        // If there were no segments return 'Chartist.Interpolation.none'\n        return Chartist.Interpolation.none()([]);\n      } else if(segments.length > 1) {\n        // If the split resulted in more that one segment we need to interpolate each segment individually and join them\n        // afterwards together into a single path.\n          var paths = [];\n        // For each segment we will recurse the cardinal function\n        segments.forEach(function(segment) {\n          paths.push(cardinal(segment.pathCoordinates, segment.valueData));\n        });\n        // Join the segment path data into a single path and return\n        return Chartist.Svg.Path.join(paths);\n      } else {\n        // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first\n        // segment\n        pathCoordinates = segments[0].pathCoordinates;\n        valueData = segments[0].valueData;\n\n        // If less than two points we need to fallback to no smoothing\n        if(pathCoordinates.length <= 4) {\n          return Chartist.Interpolation.none()(pathCoordinates, valueData);\n        }\n\n        var path = new Chartist.Svg.Path().move(pathCoordinates[0], pathCoordinates[1], false, valueData[0]),\n          z;\n\n        for (var i = 0, iLen = pathCoordinates.length; iLen - 2 * !z > i; i += 2) {\n          var p = [\n            {x: +pathCoordinates[i - 2], y: +pathCoordinates[i - 1]},\n            {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]},\n            {x: +pathCoordinates[i + 2], y: +pathCoordinates[i + 3]},\n            {x: +pathCoordinates[i + 4], y: +pathCoordinates[i + 5]}\n          ];\n          if (z) {\n            if (!i) {\n              p[0] = {x: +pathCoordinates[iLen - 2], y: +pathCoordinates[iLen - 1]};\n            } else if (iLen - 4 === i) {\n              p[3] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};\n            } else if (iLen - 2 === i) {\n              p[2] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};\n              p[3] = {x: +pathCoordinates[2], y: +pathCoordinates[3]};\n            }\n          } else {\n            if (iLen - 4 === i) {\n              p[3] = p[2];\n            } else if (!i) {\n              p[0] = {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]};\n            }\n          }\n\n          path.curve(\n            (t * (-p[0].x + 6 * p[1].x + p[2].x) / 6) + (c * p[2].x),\n            (t * (-p[0].y + 6 * p[1].y + p[2].y) / 6) + (c * p[2].y),\n            (t * (p[1].x + 6 * p[2].x - p[3].x) / 6) + (c * p[2].x),\n            (t * (p[1].y + 6 * p[2].y - p[3].y) / 6) + (c * p[2].y),\n            p[2].x,\n            p[2].y,\n            false,\n            valueData[(i + 2) / 2]\n          );\n        }\n\n        return path;\n      }\n    };\n  };\n\n  /**\n   * Monotone Cubic spline interpolation produces a smooth curve which preserves monotonicity. Unlike cardinal splines, the curve will not extend beyond the range of y-values of the original data points.\n   *\n   * Monotone Cubic splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.\n   *\n   * The x-values of subsequent points must be increasing to fit a Monotone Cubic spline. If this condition is not met for a pair of adjacent points, then there will be a break in the curve between those data points.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.monotoneCubic({\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the monotoneCubic factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.monotoneCubic = function(options) {\n    var defaultOptions = {\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    return function monotoneCubic(pathCoordinates, valueData) {\n      // First we try to split the coordinates into segments\n      // This is necessary to treat \"holes\" in line charts\n      var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {\n        fillHoles: options.fillHoles,\n        increasingX: true\n      });\n\n      if(!segments.length) {\n        // If there were no segments return 'Chartist.Interpolation.none'\n        return Chartist.Interpolation.none()([]);\n      } else if(segments.length > 1) {\n        // If the split resulted in more that one segment we need to interpolate each segment individually and join them\n        // afterwards together into a single path.\n          var paths = [];\n        // For each segment we will recurse the monotoneCubic fn function\n        segments.forEach(function(segment) {\n          paths.push(monotoneCubic(segment.pathCoordinates, segment.valueData));\n        });\n        // Join the segment path data into a single path and return\n        return Chartist.Svg.Path.join(paths);\n      } else {\n        // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first\n        // segment\n        pathCoordinates = segments[0].pathCoordinates;\n        valueData = segments[0].valueData;\n\n        // If less than three points we need to fallback to no smoothing\n        if(pathCoordinates.length <= 4) {\n          return Chartist.Interpolation.none()(pathCoordinates, valueData);\n        }\n\n        var xs = [],\n          ys = [],\n          i,\n          n = pathCoordinates.length / 2,\n          ms = [],\n          ds = [], dys = [], dxs = [],\n          path;\n\n        // Populate x and y coordinates into separate arrays, for readability\n\n        for(i = 0; i < n; i++) {\n          xs[i] = pathCoordinates[i * 2];\n          ys[i] = pathCoordinates[i * 2 + 1];\n        }\n\n        // Calculate deltas and derivative\n\n        for(i = 0; i < n - 1; i++) {\n          dys[i] = ys[i + 1] - ys[i];\n          dxs[i] = xs[i + 1] - xs[i];\n          ds[i] = dys[i] / dxs[i];\n        }\n\n        // Determine desired slope (m) at each point using Fritsch-Carlson method\n        // See: http://math.stackexchange.com/questions/45218/implementation-of-monotone-cubic-interpolation\n\n        ms[0] = ds[0];\n        ms[n - 1] = ds[n - 2];\n\n        for(i = 1; i < n - 1; i++) {\n          if(ds[i] === 0 || ds[i - 1] === 0 || (ds[i - 1] > 0) !== (ds[i] > 0)) {\n            ms[i] = 0;\n          } else {\n            ms[i] = 3 * (dxs[i - 1] + dxs[i]) / (\n              (2 * dxs[i] + dxs[i - 1]) / ds[i - 1] +\n              (dxs[i] + 2 * dxs[i - 1]) / ds[i]);\n\n            if(!isFinite(ms[i])) {\n              ms[i] = 0;\n            }\n          }\n        }\n\n        // Now build a path from the slopes\n\n        path = new Chartist.Svg.Path().move(xs[0], ys[0], false, valueData[0]);\n\n        for(i = 0; i < n - 1; i++) {\n          path.curve(\n            // First control point\n            xs[i] + dxs[i] / 3,\n            ys[i] + ms[i] * dxs[i] / 3,\n            // Second control point\n            xs[i + 1] - dxs[i] / 3,\n            ys[i + 1] - ms[i + 1] * dxs[i] / 3,\n            // End point\n            xs[i + 1],\n            ys[i + 1],\n\n            false,\n            valueData[i + 1]\n          );\n        }\n\n        return path;\n      }\n    };\n  };\n\n  /**\n   * Step interpolation will cause the line chart to move in steps rather than diagonal or smoothed lines. This interpolation will create additional points that will also be drawn when the `showPoint` option is enabled.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The step interpolation function accepts one configuration parameter `postpone`, that can be `true` or `false`. The default value is `true` and will cause the step to occur where the value actually changes. If a different behaviour is needed where the step is shifted to the left and happens before the actual value, this option can be set to `false`.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.step({\n   *     postpone: true,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param options\n   * @returns {Function}\n   */\n  Chartist.Interpolation.step = function(options) {\n    var defaultOptions = {\n      postpone: true,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    return function step(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n\n      var prevX, prevY, prevData;\n\n      for (var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var currData = valueData[i / 2];\n\n        // If the current point is also not a hole we can draw the step lines\n        if(currData.value !== undefined) {\n          if(prevData === undefined) {\n            path.move(currX, currY, false, currData);\n          } else {\n            if(options.postpone) {\n              // If postponed we should draw the step line with the value of the previous value\n              path.line(currX, prevY, false, prevData);\n            } else {\n              // If not postponed we should draw the step line with the value of the current value\n              path.line(prevX, currY, false, currData);\n            }\n            // Line to the actual point (this should only be a Y-Axis movement\n            path.line(currX, currY, false, currData);\n          }\n\n          prevX = currX;\n          prevY = currY;\n          prevData = currData;\n        } else if(!options.fillHoles) {\n          prevX = prevY = prevData = undefined;\n        }\n      }\n\n      return path;\n    };\n  };\n\n}(window, document, Chartist));\n;/**\n * A very basic event module that helps to generate and catch events.\n *\n * @module Chartist.Event\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  Chartist.EventEmitter = function () {\n    var handlers = [];\n\n    /**\n     * Add an event handler for a specific event\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name\n     * @param {Function} handler A event handler function\n     */\n    function addEventHandler(event, handler) {\n      handlers[event] = handlers[event] || [];\n      handlers[event].push(handler);\n    }\n\n    /**\n     * Remove an event handler of a specific event name or remove all event handlers for a specific event.\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name where a specific or all handlers should be removed\n     * @param {Function} [handler] An optional event handler function. If specified only this specific handler will be removed and otherwise all handlers are removed.\n     */\n    function removeEventHandler(event, handler) {\n      // Only do something if there are event handlers with this name existing\n      if(handlers[event]) {\n        // If handler is set we will look for a specific handler and only remove this\n        if(handler) {\n          handlers[event].splice(handlers[event].indexOf(handler), 1);\n          if(handlers[event].length === 0) {\n            delete handlers[event];\n          }\n        } else {\n          // If no handler is specified we remove all handlers for this event\n          delete handlers[event];\n        }\n      }\n    }\n\n    /**\n     * Use this function to emit an event. All handlers that are listening for this event will be triggered with the data parameter.\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name that should be triggered\n     * @param {*} data Arbitrary data that will be passed to the event handler callback functions\n     */\n    function emit(event, data) {\n      // Only do something if there are event handlers with this name existing\n      if(handlers[event]) {\n        handlers[event].forEach(function(handler) {\n          handler(data);\n        });\n      }\n\n      // Emit event to star event handlers\n      if(handlers['*']) {\n        handlers['*'].forEach(function(starHandler) {\n          starHandler(event, data);\n        });\n      }\n    }\n\n    return {\n      addEventHandler: addEventHandler,\n      removeEventHandler: removeEventHandler,\n      emit: emit\n    };\n  };\n\n}(window, document, Chartist));\n;/**\n * This module provides some basic prototype inheritance utilities.\n *\n * @module Chartist.Class\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  function listToArray(list) {\n    var arr = [];\n    if (list.length) {\n      for (var i = 0; i < list.length; i++) {\n        arr.push(list[i]);\n      }\n    }\n    return arr;\n  }\n\n  /**\n   * Method to extend from current prototype.\n   *\n   * @memberof Chartist.Class\n   * @param {Object} properties The object that serves as definition for the prototype that gets created for the new class. This object should always contain a constructor property that is the desired constructor for the newly created class.\n   * @param {Object} [superProtoOverride] By default extens will use the current class prototype or Chartist.class. With this parameter you can specify any super prototype that will be used.\n   * @return {Function} Constructor function of the new class\n   *\n   * @example\n   * var Fruit = Class.extend({\n     * color: undefined,\n     *   sugar: undefined,\n     *\n     *   constructor: function(color, sugar) {\n     *     this.color = color;\n     *     this.sugar = sugar;\n     *   },\n     *\n     *   eat: function() {\n     *     this.sugar = 0;\n     *     return this;\n     *   }\n     * });\n   *\n   * var Banana = Fruit.extend({\n     *   length: undefined,\n     *\n     *   constructor: function(length, sugar) {\n     *     Banana.super.constructor.call(this, 'Yellow', sugar);\n     *     this.length = length;\n     *   }\n     * });\n   *\n   * var banana = new Banana(20, 40);\n   * console.log('banana instanceof Fruit', banana instanceof Fruit);\n   * console.log('Fruit is prototype of banana', Fruit.prototype.isPrototypeOf(banana));\n   * console.log('bananas prototype is Fruit', Object.getPrototypeOf(banana) === Fruit.prototype);\n   * console.log(banana.sugar);\n   * console.log(banana.eat().sugar);\n   * console.log(banana.color);\n   */\n  function extend(properties, superProtoOverride) {\n    var superProto = superProtoOverride || this.prototype || Chartist.Class;\n    var proto = Object.create(superProto);\n\n    Chartist.Class.cloneDefinitions(proto, properties);\n\n    var constr = function() {\n      var fn = proto.constructor || function () {},\n        instance;\n\n      // If this is linked to the Chartist namespace the constructor was not called with new\n      // To provide a fallback we will instantiate here and return the instance\n      instance = this === Chartist ? Object.create(proto) : this;\n      fn.apply(instance, Array.prototype.slice.call(arguments, 0));\n\n      // If this constructor was not called with new we need to return the instance\n      // This will not harm when the constructor has been called with new as the returned value is ignored\n      return instance;\n    };\n\n    constr.prototype = proto;\n    constr.super = superProto;\n    constr.extend = this.extend;\n\n    return constr;\n  }\n\n  // Variable argument list clones args > 0 into args[0] and retruns modified args[0]\n  function cloneDefinitions() {\n    var args = listToArray(arguments);\n    var target = args[0];\n\n    args.splice(1, args.length - 1).forEach(function (source) {\n      Object.getOwnPropertyNames(source).forEach(function (propName) {\n        // If this property already exist in target we delete it first\n        delete target[propName];\n        // Define the property with the descriptor from source\n        Object.defineProperty(target, propName,\n          Object.getOwnPropertyDescriptor(source, propName));\n      });\n    });\n\n    return target;\n  }\n\n  Chartist.Class = {\n    extend: extend,\n    cloneDefinitions: cloneDefinitions\n  };\n\n}(window, document, Chartist));\n;/**\n * Base for all chart types. The methods in Chartist.Base are inherited to all chart types.\n *\n * @module Chartist.Base\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  // TODO: Currently we need to re-draw the chart on window resize. This is usually very bad and will affect performance.\n  // This is done because we can't work with relative coordinates when drawing the chart because SVG Path does not\n  // work with relative positions yet. We need to check if we can do a viewBox hack to switch to percentage.\n  // See http://mozilla.6506.n7.nabble.com/Specyfing-paths-with-percentages-unit-td247474.html\n  // Update: can be done using the above method tested here: http://codepen.io/gionkunz/pen/KDvLj\n  // The problem is with the label offsets that can't be converted into percentage and affecting the chart container\n  /**\n   * Updates the chart which currently does a full reconstruction of the SVG DOM\n   *\n   * @param {Object} [data] Optional data you'd like to set for the chart before it will update. If not specified the update method will use the data that is already configured with the chart.\n   * @param {Object} [options] Optional options you'd like to add to the previous options for the chart before it will update. If not specified the update method will use the options that have been already configured with the chart.\n   * @param {Boolean} [override] If set to true, the passed options will be used to extend the options that have been configured already. Otherwise the chart default options will be used as the base\n   * @memberof Chartist.Base\n   */\n  function update(data, options, override) {\n    if(data) {\n      this.data = data || {};\n      this.data.labels = this.data.labels || [];\n      this.data.series = this.data.series || [];\n      // Event for data transformation that allows to manipulate the data before it gets rendered in the charts\n      this.eventEmitter.emit('data', {\n        type: 'update',\n        data: this.data\n      });\n    }\n\n    if(options) {\n      this.options = Chartist.extend({}, override ? this.options : this.defaultOptions, options);\n\n      // If chartist was not initialized yet, we just set the options and leave the rest to the initialization\n      // Otherwise we re-create the optionsProvider at this point\n      if(!this.initializeTimeoutId) {\n        this.optionsProvider.removeMediaQueryListeners();\n        this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);\n      }\n    }\n\n    // Only re-created the chart if it has been initialized yet\n    if(!this.initializeTimeoutId) {\n      this.createChart(this.optionsProvider.getCurrentOptions());\n    }\n\n    // Return a reference to the chart object to chain up calls\n    return this;\n  }\n\n  /**\n   * This method can be called on the API object of each chart and will un-register all event listeners that were added to other components. This currently includes a window.resize listener as well as media query listeners if any responsive options have been provided. Use this function if you need to destroy and recreate Chartist charts dynamically.\n   *\n   * @memberof Chartist.Base\n   */\n  function detach() {\n    // Only detach if initialization already occurred on this chart. If this chart still hasn't initialized (therefore\n    // the initializationTimeoutId is still a valid timeout reference, we will clear the timeout\n    if(!this.initializeTimeoutId) {\n      window.removeEventListener('resize', this.resizeListener);\n      this.optionsProvider.removeMediaQueryListeners();\n    } else {\n      window.clearTimeout(this.initializeTimeoutId);\n    }\n\n    return this;\n  }\n\n  /**\n   * Use this function to register event handlers. The handler callbacks are synchronous and will run in the main thread rather than the event loop.\n   *\n   * @memberof Chartist.Base\n   * @param {String} event Name of the event. Check the examples for supported events.\n   * @param {Function} handler The handler function that will be called when an event with the given name was emitted. This function will receive a data argument which contains event data. See the example for more details.\n   */\n  function on(event, handler) {\n    this.eventEmitter.addEventHandler(event, handler);\n    return this;\n  }\n\n  /**\n   * Use this function to un-register event handlers. If the handler function parameter is omitted all handlers for the given event will be un-registered.\n   *\n   * @memberof Chartist.Base\n   * @param {String} event Name of the event for which a handler should be removed\n   * @param {Function} [handler] The handler function that that was previously used to register a new event handler. This handler will be removed from the event handler list. If this parameter is omitted then all event handlers for the given event are removed from the list.\n   */\n  function off(event, handler) {\n    this.eventEmitter.removeEventHandler(event, handler);\n    return this;\n  }\n\n  function initialize() {\n    // Add window resize listener that re-creates the chart\n    window.addEventListener('resize', this.resizeListener);\n\n    // Obtain current options based on matching media queries (if responsive options are given)\n    // This will also register a listener that is re-creating the chart based on media changes\n    this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);\n    // Register options change listener that will trigger a chart update\n    this.eventEmitter.addEventHandler('optionsChanged', function() {\n      this.update();\n    }.bind(this));\n\n    // Before the first chart creation we need to register us with all plugins that are configured\n    // Initialize all relevant plugins with our chart object and the plugin options specified in the config\n    if(this.options.plugins) {\n      this.options.plugins.forEach(function(plugin) {\n        if(plugin instanceof Array) {\n          plugin[0](this, plugin[1]);\n        } else {\n          plugin(this);\n        }\n      }.bind(this));\n    }\n\n    // Event for data transformation that allows to manipulate the data before it gets rendered in the charts\n    this.eventEmitter.emit('data', {\n      type: 'initial',\n      data: this.data\n    });\n\n    // Create the first chart\n    this.createChart(this.optionsProvider.getCurrentOptions());\n\n    // As chart is initialized from the event loop now we can reset our timeout reference\n    // This is important if the chart gets initialized on the same element twice\n    this.initializeTimeoutId = undefined;\n  }\n\n  /**\n   * Constructor of chart base class.\n   *\n   * @param query\n   * @param data\n   * @param defaultOptions\n   * @param options\n   * @param responsiveOptions\n   * @constructor\n   */\n  function Base(query, data, defaultOptions, options, responsiveOptions) {\n    this.container = Chartist.querySelector(query);\n    this.data = data || {};\n    this.data.labels = this.data.labels || [];\n    this.data.series = this.data.series || [];\n    this.defaultOptions = defaultOptions;\n    this.options = options;\n    this.responsiveOptions = responsiveOptions;\n    this.eventEmitter = Chartist.EventEmitter();\n    this.supportsForeignObject = Chartist.Svg.isSupported('Extensibility');\n    this.supportsAnimations = Chartist.Svg.isSupported('AnimationEventsAttribute');\n    this.resizeListener = function resizeListener(){\n      this.update();\n    }.bind(this);\n\n    if(this.container) {\n      // If chartist was already initialized in this container we are detaching all event listeners first\n      if(this.container.__chartist__) {\n        this.container.__chartist__.detach();\n      }\n\n      this.container.__chartist__ = this;\n    }\n\n    // Using event loop for first draw to make it possible to register event listeners in the same call stack where\n    // the chart was created.\n    this.initializeTimeoutId = setTimeout(initialize.bind(this), 0);\n  }\n\n  // Creating the chart base class\n  Chartist.Base = Chartist.Class.extend({\n    constructor: Base,\n    optionsProvider: undefined,\n    container: undefined,\n    svg: undefined,\n    eventEmitter: undefined,\n    createChart: function() {\n      throw new Error('Base chart type can\\'t be instantiated!');\n    },\n    update: update,\n    detach: detach,\n    on: on,\n    off: off,\n    version: Chartist.version,\n    supportsForeignObject: false\n  });\n\n}(window, document, Chartist));\n;/**\n * Chartist SVG module for simple SVG DOM abstraction\n *\n * @module Chartist.Svg\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Chartist.Svg creates a new SVG object wrapper with a starting element. You can use the wrapper to fluently create sub-elements and modify them.\n   *\n   * @memberof Chartist.Svg\n   * @constructor\n   * @param {String|Element} name The name of the SVG element to create or an SVG dom element which should be wrapped into Chartist.Svg\n   * @param {Object} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.\n   * @param {String} className This class or class list will be added to the SVG element\n   * @param {Object} parent The parent SVG wrapper object where this newly created wrapper and it's element will be attached to as child\n   * @param {Boolean} insertFirst If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element\n   */\n  function Svg(name, attributes, className, parent, insertFirst) {\n    // If Svg is getting called with an SVG element we just return the wrapper\n    if(name instanceof Element) {\n      this._node = name;\n    } else {\n      this._node = document.createElementNS(Chartist.namespaces.svg, name);\n\n      // If this is an SVG element created then custom namespace\n      if(name === 'svg') {\n        this.attr({\n          'xmlns:ct': Chartist.namespaces.ct\n        });\n      }\n    }\n\n    if(attributes) {\n      this.attr(attributes);\n    }\n\n    if(className) {\n      this.addClass(className);\n    }\n\n    if(parent) {\n      if (insertFirst && parent._node.firstChild) {\n        parent._node.insertBefore(this._node, parent._node.firstChild);\n      } else {\n        parent._node.appendChild(this._node);\n      }\n    }\n  }\n\n  /**\n   * Set attributes on the current SVG element of the wrapper you're currently working on.\n   *\n   * @memberof Chartist.Svg\n   * @param {Object|String} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added. If this parameter is a String then the function is used as a getter and will return the attribute value.\n   * @param {String} [ns] If specified, the attribute will be obtained using getAttributeNs. In order to write namepsaced attributes you can use the namespace:attribute notation within the attributes object.\n   * @return {Object|String} The current wrapper object will be returned so it can be used for chaining or the attribute value if used as getter function.\n   */\n  function attr(attributes, ns) {\n    if(typeof attributes === 'string') {\n      if(ns) {\n        return this._node.getAttributeNS(ns, attributes);\n      } else {\n        return this._node.getAttribute(attributes);\n      }\n    }\n\n    Object.keys(attributes).forEach(function(key) {\n      // If the attribute value is undefined we can skip this one\n      if(attributes[key] === undefined) {\n        return;\n      }\n\n      if (key.indexOf(':') !== -1) {\n        var namespacedAttribute = key.split(':');\n        this._node.setAttributeNS(Chartist.namespaces[namespacedAttribute[0]], key, attributes[key]);\n      } else {\n        this._node.setAttribute(key, attributes[key]);\n      }\n    }.bind(this));\n\n    return this;\n  }\n\n  /**\n   * Create a new SVG element whose wrapper object will be selected for further operations. This way you can also create nested groups easily.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} name The name of the SVG element that should be created as child element of the currently selected element wrapper\n   * @param {Object} [attributes] An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.\n   * @param {String} [className] This class or class list will be added to the SVG element\n   * @param {Boolean} [insertFirst] If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element\n   * @return {Chartist.Svg} Returns a Chartist.Svg wrapper object that can be used to modify the containing SVG data\n   */\n  function elem(name, attributes, className, insertFirst) {\n    return new Chartist.Svg(name, attributes, className, this, insertFirst);\n  }\n\n  /**\n   * Returns the parent Chartist.SVG wrapper object\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} Returns a Chartist.Svg wrapper around the parent node of the current node. If the parent node is not existing or it's not an SVG node then this function will return null.\n   */\n  function parent() {\n    return this._node.parentNode instanceof SVGElement ? new Chartist.Svg(this._node.parentNode) : null;\n  }\n\n  /**\n   * This method returns a Chartist.Svg wrapper around the root SVG element of the current tree.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The root SVG element wrapped in a Chartist.Svg element\n   */\n  function root() {\n    var node = this._node;\n    while(node.nodeName !== 'svg') {\n      node = node.parentNode;\n    }\n    return new Chartist.Svg(node);\n  }\n\n  /**\n   * Find the first child SVG element of the current element that matches a CSS selector. The returned object is a Chartist.Svg wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} selector A CSS selector that is used to query for child SVG elements\n   * @return {Chartist.Svg} The SVG wrapper for the element found or null if no element was found\n   */\n  function querySelector(selector) {\n    var foundNode = this._node.querySelector(selector);\n    return foundNode ? new Chartist.Svg(foundNode) : null;\n  }\n\n  /**\n   * Find the all child SVG elements of the current element that match a CSS selector. The returned object is a Chartist.Svg.List wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} selector A CSS selector that is used to query for child SVG elements\n   * @return {Chartist.Svg.List} The SVG wrapper list for the element found or null if no element was found\n   */\n  function querySelectorAll(selector) {\n    var foundNodes = this._node.querySelectorAll(selector);\n    return foundNodes.length ? new Chartist.Svg.List(foundNodes) : null;\n  }\n\n  /**\n   * Returns the underlying SVG node for the current element.\n   *\n   * @memberof Chartist.Svg\n   * @returns {Node}\n   */\n  function getNode() {\n    return this._node;\n  }\n\n  /**\n   * This method creates a foreignObject (see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) that allows to embed HTML content into a SVG graphic. With the help of foreignObjects you can enable the usage of regular HTML elements inside of SVG where they are subject for SVG positioning and transformation but the Browser will use the HTML rendering capabilities for the containing DOM.\n   *\n   * @memberof Chartist.Svg\n   * @param {Node|String} content The DOM Node, or HTML string that will be converted to a DOM Node, that is then placed into and wrapped by the foreignObject\n   * @param {String} [attributes] An object with properties that will be added as attributes to the foreignObject element that is created. Attributes with undefined values will not be added.\n   * @param {String} [className] This class or class list will be added to the SVG element\n   * @param {Boolean} [insertFirst] Specifies if the foreignObject should be inserted as first child\n   * @return {Chartist.Svg} New wrapper object that wraps the foreignObject element\n   */\n  function foreignObject(content, attributes, className, insertFirst) {\n    // If content is string then we convert it to DOM\n    // TODO: Handle case where content is not a string nor a DOM Node\n    if(typeof content === 'string') {\n      var container = document.createElement('div');\n      container.innerHTML = content;\n      content = container.firstChild;\n    }\n\n    // Adding namespace to content element\n    content.setAttribute('xmlns', Chartist.namespaces.xmlns);\n\n    // Creating the foreignObject without required extension attribute (as described here\n    // http://www.w3.org/TR/SVG/extend.html#ForeignObjectElement)\n    var fnObj = this.elem('foreignObject', attributes, className, insertFirst);\n\n    // Add content to foreignObjectElement\n    fnObj._node.appendChild(content);\n\n    return fnObj;\n  }\n\n  /**\n   * This method adds a new text element to the current Chartist.Svg wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} t The text that should be added to the text element that is created\n   * @return {Chartist.Svg} The same wrapper object that was used to add the newly created element\n   */\n  function text(t) {\n    this._node.appendChild(document.createTextNode(t));\n    return this;\n  }\n\n  /**\n   * This method will clear all child nodes of the current wrapper object.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The same wrapper object that got emptied\n   */\n  function empty() {\n    while (this._node.firstChild) {\n      this._node.removeChild(this._node.firstChild);\n    }\n\n    return this;\n  }\n\n  /**\n   * This method will cause the current wrapper to remove itself from its parent wrapper. Use this method if you'd like to get rid of an element in a given DOM structure.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The parent wrapper object of the element that got removed\n   */\n  function remove() {\n    this._node.parentNode.removeChild(this._node);\n    return this.parent();\n  }\n\n  /**\n   * This method will replace the element with a new element that can be created outside of the current DOM.\n   *\n   * @memberof Chartist.Svg\n   * @param {Chartist.Svg} newElement The new Chartist.Svg object that will be used to replace the current wrapper object\n   * @return {Chartist.Svg} The wrapper of the new element\n   */\n  function replace(newElement) {\n    this._node.parentNode.replaceChild(newElement._node, this._node);\n    return newElement;\n  }\n\n  /**\n   * This method will append an element to the current element as a child.\n   *\n   * @memberof Chartist.Svg\n   * @param {Chartist.Svg} element The Chartist.Svg element that should be added as a child\n   * @param {Boolean} [insertFirst] Specifies if the element should be inserted as first child\n   * @return {Chartist.Svg} The wrapper of the appended object\n   */\n  function append(element, insertFirst) {\n    if(insertFirst && this._node.firstChild) {\n      this._node.insertBefore(element._node, this._node.firstChild);\n    } else {\n      this._node.appendChild(element._node);\n    }\n\n    return this;\n  }\n\n  /**\n   * Returns an array of class names that are attached to the current wrapper element. This method can not be chained further.\n   *\n   * @memberof Chartist.Svg\n   * @return {Array} A list of classes or an empty array if there are no classes on the current element\n   */\n  function classes() {\n    return this._node.getAttribute('class') ? this._node.getAttribute('class').trim().split(/\\s+/) : [];\n  }\n\n  /**\n   * Adds one or a space separated list of classes to the current element and ensures the classes are only existing once.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} names A white space separated list of class names\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function addClass(names) {\n    this._node.setAttribute('class',\n      this.classes(this._node)\n        .concat(names.trim().split(/\\s+/))\n        .filter(function(elem, pos, self) {\n          return self.indexOf(elem) === pos;\n        }).join(' ')\n    );\n\n    return this;\n  }\n\n  /**\n   * Removes one or a space separated list of classes from the current element.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} names A white space separated list of class names\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function removeClass(names) {\n    var removedClasses = names.trim().split(/\\s+/);\n\n    this._node.setAttribute('class', this.classes(this._node).filter(function(name) {\n      return removedClasses.indexOf(name) === -1;\n    }).join(' '));\n\n    return this;\n  }\n\n  /**\n   * Removes all classes from the current element.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function removeAllClasses() {\n    this._node.setAttribute('class', '');\n\n    return this;\n  }\n\n  /**\n   * Get element height using `getBoundingClientRect`\n   *\n   * @memberof Chartist.Svg\n   * @return {Number} The elements height in pixels\n   */\n  function height() {\n    return this._node.getBoundingClientRect().height;\n  }\n\n  /**\n   * Get element width using `getBoundingClientRect`\n   *\n   * @memberof Chartist.Core\n   * @return {Number} The elements width in pixels\n   */\n  function width() {\n    return this._node.getBoundingClientRect().width;\n  }\n\n  /**\n   * The animate function lets you animate the current element with SMIL animations. You can add animations for multiple attributes at the same time by using an animation definition object. This object should contain SMIL animation attributes. Please refer to http://www.w3.org/TR/SVG/animate.html for a detailed specification about the available animation attributes. Additionally an easing property can be passed in the animation definition object. This can be a string with a name of an easing function in `Chartist.Svg.Easing` or an array with four numbers specifying a cubic Bézier curve.\n   * **An animations object could look like this:**\n   * ```javascript\n   * element.animate({\n   *   opacity: {\n   *     dur: 1000,\n   *     from: 0,\n   *     to: 1\n   *   },\n   *   x1: {\n   *     dur: '1000ms',\n   *     from: 100,\n   *     to: 200,\n   *     easing: 'easeOutQuart'\n   *   },\n   *   y1: {\n   *     dur: '2s',\n   *     from: 0,\n   *     to: 100\n   *   }\n   * });\n   * ```\n   * **Automatic unit conversion**\n   * For the `dur` and the `begin` animate attribute you can also omit a unit by passing a number. The number will automatically be converted to milli seconds.\n   * **Guided mode**\n   * The default behavior of SMIL animations with offset using the `begin` attribute is that the attribute will keep it's original value until the animation starts. Mostly this behavior is not desired as you'd like to have your element attributes already initialized with the animation `from` value even before the animation starts. Also if you don't specify `fill=\"freeze\"` on an animate element or if you delete the animation after it's done (which is done in guided mode) the attribute will switch back to the initial value. This behavior is also not desired when performing simple one-time animations. For one-time animations you'd want to trigger animations immediately instead of relative to the document begin time. That's why in guided mode Chartist.Svg will also use the `begin` property to schedule a timeout and manually start the animation after the timeout. If you're using multiple SMIL definition objects for an attribute (in an array), guided mode will be disabled for this attribute, even if you explicitly enabled it.\n   * If guided mode is enabled the following behavior is added:\n   * - Before the animation starts (even when delayed with `begin`) the animated attribute will be set already to the `from` value of the animation\n   * - `begin` is explicitly set to `indefinite` so it can be started manually without relying on document begin time (creation)\n   * - The animate element will be forced to use `fill=\"freeze\"`\n   * - The animation will be triggered with `beginElement()` in a timeout where `begin` of the definition object is interpreted in milli seconds. If no `begin` was specified the timeout is triggered immediately.\n   * - After the animation the element attribute value will be set to the `to` value of the animation\n   * - The animate element is deleted from the DOM\n   *\n   * @memberof Chartist.Svg\n   * @param {Object} animations An animations object where the property keys are the attributes you'd like to animate. The properties should be objects again that contain the SMIL animation attributes (usually begin, dur, from, and to). The property begin and dur is auto converted (see Automatic unit conversion). You can also schedule multiple animations for the same attribute by passing an Array of SMIL definition objects. Attributes that contain an array of SMIL definition objects will not be executed in guided mode.\n   * @param {Boolean} guided Specify if guided mode should be activated for this animation (see Guided mode). If not otherwise specified, guided mode will be activated.\n   * @param {Object} eventEmitter If specified, this event emitter will be notified when an animation starts or ends.\n   * @return {Chartist.Svg} The current element where the animation was added\n   */\n  function animate(animations, guided, eventEmitter) {\n    if(guided === undefined) {\n      guided = true;\n    }\n\n    Object.keys(animations).forEach(function createAnimateForAttributes(attribute) {\n\n      function createAnimate(animationDefinition, guided) {\n        var attributeProperties = {},\n          animate,\n          timeout,\n          easing;\n\n        // Check if an easing is specified in the definition object and delete it from the object as it will not\n        // be part of the animate element attributes.\n        if(animationDefinition.easing) {\n          // If already an easing Bézier curve array we take it or we lookup a easing array in the Easing object\n          easing = animationDefinition.easing instanceof Array ?\n            animationDefinition.easing :\n            Chartist.Svg.Easing[animationDefinition.easing];\n          delete animationDefinition.easing;\n        }\n\n        // If numeric dur or begin was provided we assume milli seconds\n        animationDefinition.begin = Chartist.ensureUnit(animationDefinition.begin, 'ms');\n        animationDefinition.dur = Chartist.ensureUnit(animationDefinition.dur, 'ms');\n\n        if(easing) {\n          animationDefinition.calcMode = 'spline';\n          animationDefinition.keySplines = easing.join(' ');\n          animationDefinition.keyTimes = '0;1';\n        }\n\n        // Adding \"fill: freeze\" if we are in guided mode and set initial attribute values\n        if(guided) {\n          animationDefinition.fill = 'freeze';\n          // Animated property on our element should already be set to the animation from value in guided mode\n          attributeProperties[attribute] = animationDefinition.from;\n          this.attr(attributeProperties);\n\n          // In guided mode we also set begin to indefinite so we can trigger the start manually and put the begin\n          // which needs to be in ms aside\n          timeout = Chartist.quantity(animationDefinition.begin || 0).value;\n          animationDefinition.begin = 'indefinite';\n        }\n\n        animate = this.elem('animate', Chartist.extend({\n          attributeName: attribute\n        }, animationDefinition));\n\n        if(guided) {\n          // If guided we take the value that was put aside in timeout and trigger the animation manually with a timeout\n          setTimeout(function() {\n            // If beginElement fails we set the animated attribute to the end position and remove the animate element\n            // This happens if the SMIL ElementTimeControl interface is not supported or any other problems occured in\n            // the browser. (Currently FF 34 does not support animate elements in foreignObjects)\n            try {\n              animate._node.beginElement();\n            } catch(err) {\n              // Set animated attribute to current animated value\n              attributeProperties[attribute] = animationDefinition.to;\n              this.attr(attributeProperties);\n              // Remove the animate element as it's no longer required\n              animate.remove();\n            }\n          }.bind(this), timeout);\n        }\n\n        if(eventEmitter) {\n          animate._node.addEventListener('beginEvent', function handleBeginEvent() {\n            eventEmitter.emit('animationBegin', {\n              element: this,\n              animate: animate._node,\n              params: animationDefinition\n            });\n          }.bind(this));\n        }\n\n        animate._node.addEventListener('endEvent', function handleEndEvent() {\n          if(eventEmitter) {\n            eventEmitter.emit('animationEnd', {\n              element: this,\n              animate: animate._node,\n              params: animationDefinition\n            });\n          }\n\n          if(guided) {\n            // Set animated attribute to current animated value\n            attributeProperties[attribute] = animationDefinition.to;\n            this.attr(attributeProperties);\n            // Remove the animate element as it's no longer required\n            animate.remove();\n          }\n        }.bind(this));\n      }\n\n      // If current attribute is an array of definition objects we create an animate for each and disable guided mode\n      if(animations[attribute] instanceof Array) {\n        animations[attribute].forEach(function(animationDefinition) {\n          createAnimate.bind(this)(animationDefinition, false);\n        }.bind(this));\n      } else {\n        createAnimate.bind(this)(animations[attribute], guided);\n      }\n\n    }.bind(this));\n\n    return this;\n  }\n\n  Chartist.Svg = Chartist.Class.extend({\n    constructor: Svg,\n    attr: attr,\n    elem: elem,\n    parent: parent,\n    root: root,\n    querySelector: querySelector,\n    querySelectorAll: querySelectorAll,\n    getNode: getNode,\n    foreignObject: foreignObject,\n    text: text,\n    empty: empty,\n    remove: remove,\n    replace: replace,\n    append: append,\n    classes: classes,\n    addClass: addClass,\n    removeClass: removeClass,\n    removeAllClasses: removeAllClasses,\n    height: height,\n    width: width,\n    animate: animate\n  });\n\n  /**\n   * This method checks for support of a given SVG feature like Extensibility, SVG-animation or the like. Check http://www.w3.org/TR/SVG11/feature for a detailed list.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} feature The SVG 1.1 feature that should be checked for support.\n   * @return {Boolean} True of false if the feature is supported or not\n   */\n  Chartist.Svg.isSupported = function(feature) {\n    return document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#' + feature, '1.1');\n  };\n\n  /**\n   * This Object contains some standard easing cubic bezier curves. Then can be used with their name in the `Chartist.Svg.animate`. You can also extend the list and use your own name in the `animate` function. Click the show code button to see the available bezier functions.\n   *\n   * @memberof Chartist.Svg\n   */\n  var easingCubicBeziers = {\n    easeInSine: [0.47, 0, 0.745, 0.715],\n    easeOutSine: [0.39, 0.575, 0.565, 1],\n    easeInOutSine: [0.445, 0.05, 0.55, 0.95],\n    easeInQuad: [0.55, 0.085, 0.68, 0.53],\n    easeOutQuad: [0.25, 0.46, 0.45, 0.94],\n    easeInOutQuad: [0.455, 0.03, 0.515, 0.955],\n    easeInCubic: [0.55, 0.055, 0.675, 0.19],\n    easeOutCubic: [0.215, 0.61, 0.355, 1],\n    easeInOutCubic: [0.645, 0.045, 0.355, 1],\n    easeInQuart: [0.895, 0.03, 0.685, 0.22],\n    easeOutQuart: [0.165, 0.84, 0.44, 1],\n    easeInOutQuart: [0.77, 0, 0.175, 1],\n    easeInQuint: [0.755, 0.05, 0.855, 0.06],\n    easeOutQuint: [0.23, 1, 0.32, 1],\n    easeInOutQuint: [0.86, 0, 0.07, 1],\n    easeInExpo: [0.95, 0.05, 0.795, 0.035],\n    easeOutExpo: [0.19, 1, 0.22, 1],\n    easeInOutExpo: [1, 0, 0, 1],\n    easeInCirc: [0.6, 0.04, 0.98, 0.335],\n    easeOutCirc: [0.075, 0.82, 0.165, 1],\n    easeInOutCirc: [0.785, 0.135, 0.15, 0.86],\n    easeInBack: [0.6, -0.28, 0.735, 0.045],\n    easeOutBack: [0.175, 0.885, 0.32, 1.275],\n    easeInOutBack: [0.68, -0.55, 0.265, 1.55]\n  };\n\n  Chartist.Svg.Easing = easingCubicBeziers;\n\n  /**\n   * This helper class is to wrap multiple `Chartist.Svg` elements into a list where you can call the `Chartist.Svg` functions on all elements in the list with one call. This is helpful when you'd like to perform calls with `Chartist.Svg` on multiple elements.\n   * An instance of this class is also returned by `Chartist.Svg.querySelectorAll`.\n   *\n   * @memberof Chartist.Svg\n   * @param {Array<Node>|NodeList} nodeList An Array of SVG DOM nodes or a SVG DOM NodeList (as returned by document.querySelectorAll)\n   * @constructor\n   */\n  function SvgList(nodeList) {\n    var list = this;\n\n    this.svgElements = [];\n    for(var i = 0; i < nodeList.length; i++) {\n      this.svgElements.push(new Chartist.Svg(nodeList[i]));\n    }\n\n    // Add delegation methods for Chartist.Svg\n    Object.keys(Chartist.Svg.prototype).filter(function(prototypeProperty) {\n      return ['constructor',\n          'parent',\n          'querySelector',\n          'querySelectorAll',\n          'replace',\n          'append',\n          'classes',\n          'height',\n          'width'].indexOf(prototypeProperty) === -1;\n    }).forEach(function(prototypeProperty) {\n      list[prototypeProperty] = function() {\n        var args = Array.prototype.slice.call(arguments, 0);\n        list.svgElements.forEach(function(element) {\n          Chartist.Svg.prototype[prototypeProperty].apply(element, args);\n        });\n        return list;\n      };\n    });\n  }\n\n  Chartist.Svg.List = Chartist.Class.extend({\n    constructor: SvgList\n  });\n}(window, document, Chartist));\n;/**\n * Chartist SVG path module for SVG path description creation and modification.\n *\n * @module Chartist.Svg.Path\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Contains the descriptors of supported element types in a SVG path. Currently only move, line and curve are supported.\n   *\n   * @memberof Chartist.Svg.Path\n   * @type {Object}\n   */\n  var elementDescriptions = {\n    m: ['x', 'y'],\n    l: ['x', 'y'],\n    c: ['x1', 'y1', 'x2', 'y2', 'x', 'y'],\n    a: ['rx', 'ry', 'xAr', 'lAf', 'sf', 'x', 'y']\n  };\n\n  /**\n   * Default options for newly created SVG path objects.\n   *\n   * @memberof Chartist.Svg.Path\n   * @type {Object}\n   */\n  var defaultOptions = {\n    // The accuracy in digit count after the decimal point. This will be used to round numbers in the SVG path. If this option is set to false then no rounding will be performed.\n    accuracy: 3\n  };\n\n  function element(command, params, pathElements, pos, relative, data) {\n    var pathElement = Chartist.extend({\n      command: relative ? command.toLowerCase() : command.toUpperCase()\n    }, params, data ? { data: data } : {} );\n\n    pathElements.splice(pos, 0, pathElement);\n  }\n\n  function forEachParam(pathElements, cb) {\n    pathElements.forEach(function(pathElement, pathElementIndex) {\n      elementDescriptions[pathElement.command.toLowerCase()].forEach(function(paramName, paramIndex) {\n        cb(pathElement, paramName, pathElementIndex, paramIndex, pathElements);\n      });\n    });\n  }\n\n  /**\n   * Used to construct a new path object.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Boolean} close If set to true then this path will be closed when stringified (with a Z at the end)\n   * @param {Object} options Options object that overrides the default objects. See default options for more details.\n   * @constructor\n   */\n  function SvgPath(close, options) {\n    this.pathElements = [];\n    this.pos = 0;\n    this.close = close;\n    this.options = Chartist.extend({}, defaultOptions, options);\n  }\n\n  /**\n   * Gets or sets the current position (cursor) inside of the path. You can move around the cursor freely but limited to 0 or the count of existing elements. All modifications with element functions will insert new elements at the position of this cursor.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} [pos] If a number is passed then the cursor is set to this position in the path element array.\n   * @return {Chartist.Svg.Path|Number} If the position parameter was passed then the return value will be the path object for easy call chaining. If no position parameter was passed then the current position is returned.\n   */\n  function position(pos) {\n    if(pos !== undefined) {\n      this.pos = Math.max(0, Math.min(this.pathElements.length, pos));\n      return this;\n    } else {\n      return this.pos;\n    }\n  }\n\n  /**\n   * Removes elements from the path starting at the current position.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} count Number of path elements that should be removed from the current position.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function remove(count) {\n    this.pathElements.splice(this.pos, count);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new move SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The x coordinate for the move element.\n   * @param {Number} y The y coordinate for the move element.\n   * @param {Boolean} [relative] If set to true the move element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function move(x, y, relative, data) {\n    element('M', {\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new line SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The x coordinate for the line element.\n   * @param {Number} y The y coordinate for the line element.\n   * @param {Boolean} [relative] If set to true the line element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function line(x, y, relative, data) {\n    element('L', {\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new curve SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x1 The x coordinate for the first control point of the bezier curve.\n   * @param {Number} y1 The y coordinate for the first control point of the bezier curve.\n   * @param {Number} x2 The x coordinate for the second control point of the bezier curve.\n   * @param {Number} y2 The y coordinate for the second control point of the bezier curve.\n   * @param {Number} x The x coordinate for the target point of the curve element.\n   * @param {Number} y The y coordinate for the target point of the curve element.\n   * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function curve(x1, y1, x2, y2, x, y, relative, data) {\n    element('C', {\n      x1: +x1,\n      y1: +y1,\n      x2: +x2,\n      y2: +y2,\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new non-bezier curve SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} rx The radius to be used for the x-axis of the arc.\n   * @param {Number} ry The radius to be used for the y-axis of the arc.\n   * @param {Number} xAr Defines the orientation of the arc\n   * @param {Number} lAf Large arc flag\n   * @param {Number} sf Sweep flag\n   * @param {Number} x The x coordinate for the target point of the curve element.\n   * @param {Number} y The y coordinate for the target point of the curve element.\n   * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function arc(rx, ry, xAr, lAf, sf, x, y, relative, data) {\n    element('A', {\n      rx: +rx,\n      ry: +ry,\n      xAr: +xAr,\n      lAf: +lAf,\n      sf: +sf,\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Parses an SVG path seen in the d attribute of path elements, and inserts the parsed elements into the existing path object at the current cursor position. Any closing path indicators (Z at the end of the path) will be ignored by the parser as this is provided by the close option in the options of the path object.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {String} path Any SVG path that contains move (m), line (l) or curve (c) components.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function parse(path) {\n    // Parsing the SVG path string into an array of arrays [['M', '10', '10'], ['L', '100', '100']]\n    var chunks = path.replace(/([A-Za-z])([0-9])/g, '$1 $2')\n      .replace(/([0-9])([A-Za-z])/g, '$1 $2')\n      .split(/[\\s,]+/)\n      .reduce(function(result, element) {\n        if(element.match(/[A-Za-z]/)) {\n          result.push([]);\n        }\n\n        result[result.length - 1].push(element);\n        return result;\n      }, []);\n\n    // If this is a closed path we remove the Z at the end because this is determined by the close option\n    if(chunks[chunks.length - 1][0].toUpperCase() === 'Z') {\n      chunks.pop();\n    }\n\n    // Using svgPathElementDescriptions to map raw path arrays into objects that contain the command and the parameters\n    // For example {command: 'M', x: '10', y: '10'}\n    var elements = chunks.map(function(chunk) {\n        var command = chunk.shift(),\n          description = elementDescriptions[command.toLowerCase()];\n\n        return Chartist.extend({\n          command: command\n        }, description.reduce(function(result, paramName, index) {\n          result[paramName] = +chunk[index];\n          return result;\n        }, {}));\n      });\n\n    // Preparing a splice call with the elements array as var arg params and insert the parsed elements at the current position\n    var spliceArgs = [this.pos, 0];\n    Array.prototype.push.apply(spliceArgs, elements);\n    Array.prototype.splice.apply(this.pathElements, spliceArgs);\n    // Increase the internal position by the element count\n    this.pos += elements.length;\n\n    return this;\n  }\n\n  /**\n   * This function renders to current SVG path object into a final SVG string that can be used in the d attribute of SVG path elements. It uses the accuracy option to round big decimals. If the close parameter was set in the constructor of this path object then a path closing Z will be appended to the output string.\n   *\n   * @memberof Chartist.Svg.Path\n   * @return {String}\n   */\n  function stringify() {\n    var accuracyMultiplier = Math.pow(10, this.options.accuracy);\n\n    return this.pathElements.reduce(function(path, pathElement) {\n        var params = elementDescriptions[pathElement.command.toLowerCase()].map(function(paramName) {\n          return this.options.accuracy ?\n            (Math.round(pathElement[paramName] * accuracyMultiplier) / accuracyMultiplier) :\n            pathElement[paramName];\n        }.bind(this));\n\n        return path + pathElement.command + params.join(',');\n      }.bind(this), '') + (this.close ? 'Z' : '');\n  }\n\n  /**\n   * Scales all elements in the current SVG path object. There is an individual parameter for each coordinate. Scaling will also be done for control points of curves, affecting the given coordinate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The number which will be used to scale the x, x1 and x2 of all path elements.\n   * @param {Number} y The number which will be used to scale the y, y1 and y2 of all path elements.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function scale(x, y) {\n    forEachParam(this.pathElements, function(pathElement, paramName) {\n      pathElement[paramName] *= paramName[0] === 'x' ? x : y;\n    });\n    return this;\n  }\n\n  /**\n   * Translates all elements in the current SVG path object. The translation is relative and there is an individual parameter for each coordinate. Translation will also be done for control points of curves, affecting the given coordinate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The number which will be used to translate the x, x1 and x2 of all path elements.\n   * @param {Number} y The number which will be used to translate the y, y1 and y2 of all path elements.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function translate(x, y) {\n    forEachParam(this.pathElements, function(pathElement, paramName) {\n      pathElement[paramName] += paramName[0] === 'x' ? x : y;\n    });\n    return this;\n  }\n\n  /**\n   * This function will run over all existing path elements and then loop over their attributes. The callback function will be called for every path element attribute that exists in the current path.\n   * The method signature of the callback function looks like this:\n   * ```javascript\n   * function(pathElement, paramName, pathElementIndex, paramIndex, pathElements)\n   * ```\n   * If something else than undefined is returned by the callback function, this value will be used to replace the old value. This allows you to build custom transformations of path objects that can't be achieved using the basic transformation functions scale and translate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Function} transformFnc The callback function for the transformation. Check the signature in the function description.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function transform(transformFnc) {\n    forEachParam(this.pathElements, function(pathElement, paramName, pathElementIndex, paramIndex, pathElements) {\n      var transformed = transformFnc(pathElement, paramName, pathElementIndex, paramIndex, pathElements);\n      if(transformed || transformed === 0) {\n        pathElement[paramName] = transformed;\n      }\n    });\n    return this;\n  }\n\n  /**\n   * This function clones a whole path object with all its properties. This is a deep clone and path element objects will also be cloned.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Boolean} [close] Optional option to set the new cloned path to closed. If not specified or false, the original path close option will be used.\n   * @return {Chartist.Svg.Path}\n   */\n  function clone(close) {\n    var c = new Chartist.Svg.Path(close || this.close);\n    c.pos = this.pos;\n    c.pathElements = this.pathElements.slice().map(function cloneElements(pathElement) {\n      return Chartist.extend({}, pathElement);\n    });\n    c.options = Chartist.extend({}, this.options);\n    return c;\n  }\n\n  /**\n   * Split a Svg.Path object by a specific command in the path chain. The path chain will be split and an array of newly created paths objects will be returned. This is useful if you'd like to split an SVG path by it's move commands, for example, in order to isolate chunks of drawings.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {String} command The command you'd like to use to split the path\n   * @return {Array<Chartist.Svg.Path>}\n   */\n  function splitByCommand(command) {\n    var split = [\n      new Chartist.Svg.Path()\n    ];\n\n    this.pathElements.forEach(function(pathElement) {\n      if(pathElement.command === command.toUpperCase() && split[split.length - 1].pathElements.length !== 0) {\n        split.push(new Chartist.Svg.Path());\n      }\n\n      split[split.length - 1].pathElements.push(pathElement);\n    });\n\n    return split;\n  }\n\n  /**\n   * This static function on `Chartist.Svg.Path` is joining multiple paths together into one paths.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Array<Chartist.Svg.Path>} paths A list of paths to be joined together. The order is important.\n   * @param {boolean} close If the newly created path should be a closed path\n   * @param {Object} options Path options for the newly created path.\n   * @return {Chartist.Svg.Path}\n   */\n\n  function join(paths, close, options) {\n    var joinedPath = new Chartist.Svg.Path(close, options);\n    for(var i = 0; i < paths.length; i++) {\n      var path = paths[i];\n      for(var j = 0; j < path.pathElements.length; j++) {\n        joinedPath.pathElements.push(path.pathElements[j]);\n      }\n    }\n    return joinedPath;\n  }\n\n  Chartist.Svg.Path = Chartist.Class.extend({\n    constructor: SvgPath,\n    position: position,\n    remove: remove,\n    move: move,\n    line: line,\n    curve: curve,\n    arc: arc,\n    scale: scale,\n    translate: translate,\n    transform: transform,\n    parse: parse,\n    stringify: stringify,\n    clone: clone,\n    splitByCommand: splitByCommand\n  });\n\n  Chartist.Svg.Path.elementDescriptions = elementDescriptions;\n  Chartist.Svg.Path.join = join;\n}(window, document, Chartist));\n;/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  var axisUnits = {\n    x: {\n      pos: 'x',\n      len: 'width',\n      dir: 'horizontal',\n      rectStart: 'x1',\n      rectEnd: 'x2',\n      rectOffset: 'y2'\n    },\n    y: {\n      pos: 'y',\n      len: 'height',\n      dir: 'vertical',\n      rectStart: 'y2',\n      rectEnd: 'y1',\n      rectOffset: 'x1'\n    }\n  };\n\n  function Axis(units, chartRect, ticks, options) {\n    this.units = units;\n    this.counterUnits = units === axisUnits.x ? axisUnits.y : axisUnits.x;\n    this.chartRect = chartRect;\n    this.axisLength = chartRect[units.rectEnd] - chartRect[units.rectStart];\n    this.gridOffset = chartRect[units.rectOffset];\n    this.ticks = ticks;\n    this.options = options;\n  }\n\n  function createGridAndLabels(gridGroup, labelGroup, useForeignObject, chartOptions, eventEmitter) {\n    var axisOptions = chartOptions['axis' + this.units.pos.toUpperCase()];\n    var projectedValues = this.ticks.map(this.projectValue.bind(this));\n    var labelValues = this.ticks.map(axisOptions.labelInterpolationFnc);\n\n    projectedValues.forEach(function(projectedValue, index) {\n      var labelOffset = {\n        x: 0,\n        y: 0\n      };\n\n      // TODO: Find better solution for solving this problem\n      // Calculate how much space we have available for the label\n      var labelLength;\n      if(projectedValues[index + 1]) {\n        // If we still have one label ahead, we can calculate the distance to the next tick / label\n        labelLength = projectedValues[index + 1] - projectedValue;\n      } else {\n        // If we don't have a label ahead and we have only two labels in total, we just take the remaining distance to\n        // on the whole axis length. We limit that to a minimum of 30 pixel, so that labels close to the border will\n        // still be visible inside of the chart padding.\n        labelLength = Math.max(this.axisLength - projectedValue, 30);\n      }\n\n      // Skip grid lines and labels where interpolated label values are falsey (execpt for 0)\n      if(Chartist.isFalseyButZero(labelValues[index]) && labelValues[index] !== '') {\n        return;\n      }\n\n      // Transform to global coordinates using the chartRect\n      // We also need to set the label offset for the createLabel function\n      if(this.units.pos === 'x') {\n        projectedValue = this.chartRect.x1 + projectedValue;\n        labelOffset.x = chartOptions.axisX.labelOffset.x;\n\n        // If the labels should be positioned in start position (top side for vertical axis) we need to set a\n        // different offset as for positioned with end (bottom)\n        if(chartOptions.axisX.position === 'start') {\n          labelOffset.y = this.chartRect.padding.top + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);\n        } else {\n          labelOffset.y = this.chartRect.y1 + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);\n        }\n      } else {\n        projectedValue = this.chartRect.y1 - projectedValue;\n        labelOffset.y = chartOptions.axisY.labelOffset.y - (useForeignObject ? labelLength : 0);\n\n        // If the labels should be positioned in start position (left side for horizontal axis) we need to set a\n        // different offset as for positioned with end (right side)\n        if(chartOptions.axisY.position === 'start') {\n          labelOffset.x = useForeignObject ? this.chartRect.padding.left + chartOptions.axisY.labelOffset.x : this.chartRect.x1 - 10;\n        } else {\n          labelOffset.x = this.chartRect.x2 + chartOptions.axisY.labelOffset.x + 10;\n        }\n      }\n\n      if(axisOptions.showGrid) {\n        Chartist.createGrid(projectedValue, index, this, this.gridOffset, this.chartRect[this.counterUnits.len](), gridGroup, [\n          chartOptions.classNames.grid,\n          chartOptions.classNames[this.units.dir]\n        ], eventEmitter);\n      }\n\n      if(axisOptions.showLabel) {\n        Chartist.createLabel(projectedValue, labelLength, index, labelValues, this, axisOptions.offset, labelOffset, labelGroup, [\n          chartOptions.classNames.label,\n          chartOptions.classNames[this.units.dir],\n          (axisOptions.position === 'start' ? chartOptions.classNames[axisOptions.position] : chartOptions.classNames['end'])\n        ], useForeignObject, eventEmitter);\n      }\n    }.bind(this));\n  }\n\n  Chartist.Axis = Chartist.Class.extend({\n    constructor: Axis,\n    createGridAndLabels: createGridAndLabels,\n    projectValue: function(value, index, data) {\n      throw new Error('Base axis can\\'t be instantiated!');\n    }\n  });\n\n  Chartist.Axis.units = axisUnits;\n\n}(window, document, Chartist));\n;/**\n * The auto scale axis uses standard linear scale projection of values along an axis. It uses order of magnitude to find a scale automatically and evaluates the available space in order to find the perfect amount of ticks for your chart.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored\n *   high: 100,\n *   // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored\n *   low: 0,\n *   // This option will be used when finding the right scale division settings. The amount of ticks on the scale will be determined so that as many ticks as possible will be displayed, while not violating this minimum required space (in pixel).\n *   scaleMinSpace: 20,\n *   // Can be set to true or false. If set to true, the scale will be generated with whole numbers only.\n *   onlyInteger: true,\n *   // The reference value can be used to make sure that this value will always be on the chart. This is especially useful on bipolar charts where the bipolar center always needs to be part of the chart.\n *   referenceValue: 5\n * };\n * ```\n *\n * @module Chartist.AutoScaleAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function AutoScaleAxis(axisUnit, data, chartRect, options) {\n    // Usually we calculate highLow based on the data but this can be overriden by a highLow object in the options\n    var highLow = options.highLow || Chartist.getHighLow(data, options, axisUnit.pos);\n    this.bounds = Chartist.getBounds(chartRect[axisUnit.rectEnd] - chartRect[axisUnit.rectStart], highLow, options.scaleMinSpace || 20, options.onlyInteger);\n    this.range = {\n      min: this.bounds.min,\n      max: this.bounds.max\n    };\n\n    Chartist.AutoScaleAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      this.bounds.values,\n      options);\n  }\n\n  function projectValue(value) {\n    return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.bounds.min) / this.bounds.range;\n  }\n\n  Chartist.AutoScaleAxis = Chartist.Axis.extend({\n    constructor: AutoScaleAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The fixed scale axis uses standard linear projection of values along an axis. It makes use of a divisor option to divide the range provided from the minimum and maximum value or the options high and low that will override the computed minimum and maximum.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored\n *   high: 100,\n *   // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored\n *   low: 0,\n *   // If specified then the value range determined from minimum to maximum (or low and high) will be divided by this number and ticks will be generated at those division points. The default divisor is 1.\n *   divisor: 4,\n *   // If ticks is explicitly set, then the axis will not compute the ticks with the divisor, but directly use the data in ticks to determine at what points on the axis a tick need to be generated.\n *   ticks: [1, 10, 20, 30]\n * };\n * ```\n *\n * @module Chartist.FixedScaleAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function FixedScaleAxis(axisUnit, data, chartRect, options) {\n    var highLow = options.highLow || Chartist.getHighLow(data, options, axisUnit.pos);\n    this.divisor = options.divisor || 1;\n    this.ticks = options.ticks || Chartist.times(this.divisor).map(function(value, index) {\n      return highLow.low + (highLow.high - highLow.low) / this.divisor * index;\n    }.bind(this));\n    this.ticks.sort(function(a, b) {\n      return a - b;\n    });\n    this.range = {\n      min: highLow.low,\n      max: highLow.high\n    };\n\n    Chartist.FixedScaleAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      this.ticks,\n      options);\n\n    this.stepLength = this.axisLength / this.divisor;\n  }\n\n  function projectValue(value) {\n    return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.range.min) / (this.range.max - this.range.min);\n  }\n\n  Chartist.FixedScaleAxis = Chartist.Axis.extend({\n    constructor: FixedScaleAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The step axis for step based charts like bar chart or step based line charts. It uses a fixed amount of ticks that will be equally distributed across the whole axis length. The projection is done using the index of the data value rather than the value itself and therefore it's only useful for distribution purpose.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // Ticks to be used to distribute across the axis length. As this axis type relies on the index of the value rather than the value, arbitrary data that can be converted to a string can be used as ticks.\n *   ticks: ['One', 'Two', 'Three'],\n *   // If set to true the full width will be used to distribute the values where the last value will be at the maximum of the axis length. If false the spaces between the ticks will be evenly distributed instead.\n *   stretch: true\n * };\n * ```\n *\n * @module Chartist.StepAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function StepAxis(axisUnit, data, chartRect, options) {\n    Chartist.StepAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      options.ticks,\n      options);\n\n    var calc = Math.max(1, options.ticks.length - (options.stretch ? 1 : 0));\n    this.stepLength = this.axisLength / calc;\n  }\n\n  function projectValue(value, index) {\n    return this.stepLength * index;\n  }\n\n  Chartist.StepAxis = Chartist.Axis.extend({\n    constructor: StepAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The Chartist line chart can be used to draw Line or Scatter charts. If used in the browser you can access the global `Chartist` namespace where you find the `Line` function as a main entry point.\n *\n * For examples on how to use the line chart please check the examples of the `Chartist.Line` method.\n *\n * @module Chartist.Line\n */\n/* global Chartist */\n(function(window, document, Chartist){\n  'use strict';\n\n  /**\n   * Default options in line charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Line\n   */\n  var defaultOptions = {\n    // Options for X-Axis\n    axisX: {\n      // The offset of the labels to the chart area\n      offset: 30,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'end',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // Set the axis type to be used to project values on this axis. If not defined, Chartist.StepAxis will be used for the X-Axis, where the ticks option will be set to the labels in the data and the stretch option will be set to the global fullWidth option. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.\n      type: undefined\n    },\n    // Options for Y-Axis\n    axisY: {\n      // The offset of the labels to the chart area\n      offset: 40,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'start',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // Set the axis type to be used to project values on this axis. If not defined, Chartist.AutoScaleAxis will be used for the Y-Axis, where the high and low options will be set to the global high and low options. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.\n      type: undefined,\n      // This value specifies the minimum height in pixel of the scale steps\n      scaleMinSpace: 20,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // If the line should be drawn or not\n    showLine: true,\n    // If dots should be drawn or not\n    showPoint: true,\n    // If the line chart should draw an area\n    showArea: false,\n    // The base for the area chart that will be used to close the area shape (is normally 0)\n    areaBase: 0,\n    // Specify if the lines should be smoothed. This value can be true or false where true will result in smoothing using the default smoothing interpolation function Chartist.Interpolation.cardinal and false results in Chartist.Interpolation.none. You can also choose other smoothing / interpolation functions available in the Chartist.Interpolation module, or write your own interpolation function. Check the examples for a brief description.\n    lineSmooth: true,\n    // If the line chart should add a background fill to the .ct-grids group.\n    showGridBackground: false,\n    // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value\n    low: undefined,\n    // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value\n    high: undefined,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: {\n      top: 15,\n      right: 15,\n      bottom: 5,\n      left: 10\n    },\n    // When set to true, the last grid line on the x-axis is not drawn and the chart elements will expand to the full available width of the chart. For the last label to be drawn correctly you might need to add chart padding or offset the last label with a draw event handler.\n    fullWidth: false,\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // Override the class names that get used to generate the SVG structure of the chart\n    classNames: {\n      chart: 'ct-chart-line',\n      label: 'ct-label',\n      labelGroup: 'ct-labels',\n      series: 'ct-series',\n      line: 'ct-line',\n      point: 'ct-point',\n      area: 'ct-area',\n      grid: 'ct-grid',\n      gridGroup: 'ct-grids',\n      gridBackground: 'ct-grid-background',\n      vertical: 'ct-vertical',\n      horizontal: 'ct-horizontal',\n      start: 'ct-start',\n      end: 'ct-end'\n    }\n  };\n\n  /**\n   * Creates a new chart\n   *\n   */\n  function createChart(options) {\n    var data = Chartist.normalizeData(this.data, options.reverseData, true);\n\n    // Create new svg object\n    this.svg = Chartist.createSvg(this.container, options.width, options.height, options.classNames.chart);\n    // Create groups for labels, grid and series\n    var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);\n    var seriesGroup = this.svg.elem('g');\n    var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);\n\n    var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n    var axisX, axisY;\n\n    if(options.axisX.type === undefined) {\n      axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data.normalized.series, chartRect, Chartist.extend({}, options.axisX, {\n        ticks: data.normalized.labels,\n        stretch: options.fullWidth\n      }));\n    } else {\n      axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data.normalized.series, chartRect, options.axisX);\n    }\n\n    if(options.axisY.type === undefined) {\n      axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data.normalized.series, chartRect, Chartist.extend({}, options.axisY, {\n        high: Chartist.isNumeric(options.high) ? options.high : options.axisY.high,\n        low: Chartist.isNumeric(options.low) ? options.low : options.axisY.low\n      }));\n    } else {\n      axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data.normalized.series, chartRect, options.axisY);\n    }\n\n    axisX.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n    axisY.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n\n    if (options.showGridBackground) {\n      Chartist.createGridBackground(gridGroup, chartRect, options.classNames.gridBackground, this.eventEmitter);\n    }\n\n    // Draw the series\n    data.raw.series.forEach(function(series, seriesIndex) {\n      var seriesElement = seriesGroup.elem('g');\n\n      // Write attributes to series group element. If series name or meta is undefined the attributes will not be written\n      seriesElement.attr({\n        'ct:series-name': series.name,\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesElement.addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))\n      ].join(' '));\n\n      var pathCoordinates = [],\n        pathData = [];\n\n      data.normalized.series[seriesIndex].forEach(function(value, valueIndex) {\n        var p = {\n          x: chartRect.x1 + axisX.projectValue(value, valueIndex, data.normalized.series[seriesIndex]),\n          y: chartRect.y1 - axisY.projectValue(value, valueIndex, data.normalized.series[seriesIndex])\n        };\n        pathCoordinates.push(p.x, p.y);\n        pathData.push({\n          value: value,\n          valueIndex: valueIndex,\n          meta: Chartist.getMetaData(series, valueIndex)\n        });\n      }.bind(this));\n\n      var seriesOptions = {\n        lineSmooth: Chartist.getSeriesOption(series, options, 'lineSmooth'),\n        showPoint: Chartist.getSeriesOption(series, options, 'showPoint'),\n        showLine: Chartist.getSeriesOption(series, options, 'showLine'),\n        showArea: Chartist.getSeriesOption(series, options, 'showArea'),\n        areaBase: Chartist.getSeriesOption(series, options, 'areaBase')\n      };\n\n      var smoothing = typeof seriesOptions.lineSmooth === 'function' ?\n        seriesOptions.lineSmooth : (seriesOptions.lineSmooth ? Chartist.Interpolation.monotoneCubic() : Chartist.Interpolation.none());\n      // Interpolating path where pathData will be used to annotate each path element so we can trace back the original\n      // index, value and meta data\n      var path = smoothing(pathCoordinates, pathData);\n\n      // If we should show points we need to create them now to avoid secondary loop\n      // Points are drawn from the pathElements returned by the interpolation function\n      // Small offset for Firefox to render squares correctly\n      if (seriesOptions.showPoint) {\n\n        path.pathElements.forEach(function(pathElement) {\n          var point = seriesElement.elem('line', {\n            x1: pathElement.x,\n            y1: pathElement.y,\n            x2: pathElement.x + 0.01,\n            y2: pathElement.y\n          }, options.classNames.point).attr({\n            'ct:value': [pathElement.data.value.x, pathElement.data.value.y].filter(Chartist.isNumeric).join(','),\n            'ct:meta': Chartist.serialize(pathElement.data.meta)\n          });\n\n          this.eventEmitter.emit('draw', {\n            type: 'point',\n            value: pathElement.data.value,\n            index: pathElement.data.valueIndex,\n            meta: pathElement.data.meta,\n            series: series,\n            seriesIndex: seriesIndex,\n            axisX: axisX,\n            axisY: axisY,\n            group: seriesElement,\n            element: point,\n            x: pathElement.x,\n            y: pathElement.y\n          });\n        }.bind(this));\n      }\n\n      if(seriesOptions.showLine) {\n        var line = seriesElement.elem('path', {\n          d: path.stringify()\n        }, options.classNames.line, true);\n\n        this.eventEmitter.emit('draw', {\n          type: 'line',\n          values: data.normalized.series[seriesIndex],\n          path: path.clone(),\n          chartRect: chartRect,\n          index: seriesIndex,\n          series: series,\n          seriesIndex: seriesIndex,\n          seriesMeta: series.meta,\n          axisX: axisX,\n          axisY: axisY,\n          group: seriesElement,\n          element: line\n        });\n      }\n\n      // Area currently only works with axes that support a range!\n      if(seriesOptions.showArea && axisY.range) {\n        // If areaBase is outside the chart area (< min or > max) we need to set it respectively so that\n        // the area is not drawn outside the chart area.\n        var areaBase = Math.max(Math.min(seriesOptions.areaBase, axisY.range.max), axisY.range.min);\n\n        // We project the areaBase value into screen coordinates\n        var areaBaseProjected = chartRect.y1 - axisY.projectValue(areaBase);\n\n        // In order to form the area we'll first split the path by move commands so we can chunk it up into segments\n        path.splitByCommand('M').filter(function onlySolidSegments(pathSegment) {\n          // We filter only \"solid\" segments that contain more than one point. Otherwise there's no need for an area\n          return pathSegment.pathElements.length > 1;\n        }).map(function convertToArea(solidPathSegments) {\n          // Receiving the filtered solid path segments we can now convert those segments into fill areas\n          var firstElement = solidPathSegments.pathElements[0];\n          var lastElement = solidPathSegments.pathElements[solidPathSegments.pathElements.length - 1];\n\n          // Cloning the solid path segment with closing option and removing the first move command from the clone\n          // We then insert a new move that should start at the area base and draw a straight line up or down\n          // at the end of the path we add an additional straight line to the projected area base value\n          // As the closing option is set our path will be automatically closed\n          return solidPathSegments.clone(true)\n            .position(0)\n            .remove(1)\n            .move(firstElement.x, areaBaseProjected)\n            .line(firstElement.x, firstElement.y)\n            .position(solidPathSegments.pathElements.length + 1)\n            .line(lastElement.x, areaBaseProjected);\n\n        }).forEach(function createArea(areaPath) {\n          // For each of our newly created area paths, we'll now create path elements by stringifying our path objects\n          // and adding the created DOM elements to the correct series group\n          var area = seriesElement.elem('path', {\n            d: areaPath.stringify()\n          }, options.classNames.area, true);\n\n          // Emit an event for each area that was drawn\n          this.eventEmitter.emit('draw', {\n            type: 'area',\n            values: data.normalized.series[seriesIndex],\n            path: areaPath.clone(),\n            series: series,\n            seriesIndex: seriesIndex,\n            axisX: axisX,\n            axisY: axisY,\n            chartRect: chartRect,\n            index: seriesIndex,\n            group: seriesElement,\n            element: area\n          });\n        }.bind(this));\n      }\n    }.bind(this));\n\n    this.eventEmitter.emit('created', {\n      bounds: axisY.bounds,\n      chartRect: chartRect,\n      axisX: axisX,\n      axisY: axisY,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new line chart.\n   *\n   * @memberof Chartist.Line\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object that needs to consist of a labels and a series array\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object which exposes the API for the created chart\n   *\n   * @example\n   * // Create a simple line chart\n   * var data = {\n   *   // A labels array that can contain any sort of values\n   *   labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],\n   *   // Our series array that contains series objects or in this case series data arrays\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // As options we currently only set a static size of 300x200 px\n   * var options = {\n   *   width: '300px',\n   *   height: '200px'\n   * };\n   *\n   * // In the global name space Chartist we call the Line function to initialize a line chart. As a first parameter we pass in a selector where we would like to get our chart created. Second parameter is the actual data object and as a third parameter we pass in our options\n   * new Chartist.Line('.ct-chart', data, options);\n   *\n   * @example\n   * // Use specific interpolation function with configuration from the Chartist.Interpolation module\n   *\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [\n   *     [1, 1, 8, 1, 7]\n   *   ]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.cardinal({\n   *     tension: 0.2\n   *   })\n   * });\n   *\n   * @example\n   * // Create a line chart with responsive options\n   *\n   * var data = {\n   *   // A labels array that can contain any sort of values\n   *   labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],\n   *   // Our series array that contains series objects or in this case series data arrays\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // In addition to the regular options we specify responsive option overrides that will override the default configutation based on the matching media queries.\n   * var responsiveOptions = [\n   *   ['screen and (min-width: 641px) and (max-width: 1024px)', {\n   *     showPoint: false,\n   *     axisX: {\n   *       labelInterpolationFnc: function(value) {\n   *         // Will return Mon, Tue, Wed etc. on medium screens\n   *         return value.slice(0, 3);\n   *       }\n   *     }\n   *   }],\n   *   ['screen and (max-width: 640px)', {\n   *     showLine: false,\n   *     axisX: {\n   *       labelInterpolationFnc: function(value) {\n   *         // Will return M, T, W etc. on small screens\n   *         return value[0];\n   *       }\n   *     }\n   *   }]\n   * ];\n   *\n   * new Chartist.Line('.ct-chart', data, null, responsiveOptions);\n   *\n   */\n  function Line(query, data, options, responsiveOptions) {\n    Chartist.Line.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating line chart type in Chartist namespace\n  Chartist.Line = Chartist.Base.extend({\n    constructor: Line,\n    createChart: createChart\n  });\n\n}(window, document, Chartist));\n;/**\n * The bar chart module of Chartist that can be used to draw unipolar or bipolar bar and grouped bar charts.\n *\n * @module Chartist.Bar\n */\n/* global Chartist */\n(function(window, document, Chartist){\n  'use strict';\n\n  /**\n   * Default options in bar charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Bar\n   */\n  var defaultOptions = {\n    // Options for X-Axis\n    axisX: {\n      // The offset of the chart drawing area to the border of the container\n      offset: 30,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'end',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // This value specifies the minimum width in pixel of the scale steps\n      scaleMinSpace: 30,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Options for Y-Axis\n    axisY: {\n      // The offset of the chart drawing area to the border of the container\n      offset: 40,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'start',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // This value specifies the minimum height in pixel of the scale steps\n      scaleMinSpace: 20,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value\n    high: undefined,\n    // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value\n    low: undefined,\n    // Unless low/high are explicitly set, bar chart will be centered at zero by default. Set referenceValue to null to auto scale.\n    referenceValue: 0,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: {\n      top: 15,\n      right: 15,\n      bottom: 5,\n      left: 10\n    },\n    // Specify the distance in pixel of bars in a group\n    seriesBarDistance: 15,\n    // If set to true this property will cause the series bars to be stacked. Check the `stackMode` option for further stacking options.\n    stackBars: false,\n    // If set to 'overlap' this property will force the stacked bars to draw from the zero line.\n    // If set to 'accumulate' this property will form a total for each series point. This will also influence the y-axis and the overall bounds of the chart. In stacked mode the seriesBarDistance property will have no effect.\n    stackMode: 'accumulate',\n    // Inverts the axes of the bar chart in order to draw a horizontal bar chart. Be aware that you also need to invert your axis settings as the Y Axis will now display the labels and the X Axis the values.\n    horizontalBars: false,\n    // If set to true then each bar will represent a series and the data array is expected to be a one dimensional array of data values rather than a series array of series. This is useful if the bar chart should represent a profile rather than some data over time.\n    distributeSeries: false,\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // If the bar chart should add a background fill to the .ct-grids group.\n    showGridBackground: false,\n    // Override the class names that get used to generate the SVG structure of the chart\n    classNames: {\n      chart: 'ct-chart-bar',\n      horizontalBars: 'ct-horizontal-bars',\n      label: 'ct-label',\n      labelGroup: 'ct-labels',\n      series: 'ct-series',\n      bar: 'ct-bar',\n      grid: 'ct-grid',\n      gridGroup: 'ct-grids',\n      gridBackground: 'ct-grid-background',\n      vertical: 'ct-vertical',\n      horizontal: 'ct-horizontal',\n      start: 'ct-start',\n      end: 'ct-end'\n    }\n  };\n\n  /**\n   * Creates a new chart\n   *\n   */\n  function createChart(options) {\n    var data;\n    var highLow;\n\n    if(options.distributeSeries) {\n      data = Chartist.normalizeData(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y');\n      data.normalized.series = data.normalized.series.map(function(value) {\n        return [value];\n      });\n    } else {\n      data = Chartist.normalizeData(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y');\n    }\n\n    // Create new svg element\n    this.svg = Chartist.createSvg(\n      this.container,\n      options.width,\n      options.height,\n      options.classNames.chart + (options.horizontalBars ? ' ' + options.classNames.horizontalBars : '')\n    );\n\n    // Drawing groups in correct order\n    var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);\n    var seriesGroup = this.svg.elem('g');\n    var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);\n\n    if(options.stackBars && data.normalized.series.length !== 0) {\n\n      // If stacked bars we need to calculate the high low from stacked values from each series\n      var serialSums = Chartist.serialMap(data.normalized.series, function serialSums() {\n        return Array.prototype.slice.call(arguments).map(function(value) {\n          return value;\n        }).reduce(function(prev, curr) {\n          return {\n            x: prev.x + (curr && curr.x) || 0,\n            y: prev.y + (curr && curr.y) || 0\n          };\n        }, {x: 0, y: 0});\n      });\n\n      highLow = Chartist.getHighLow([serialSums], options, options.horizontalBars ? 'x' : 'y');\n\n    } else {\n\n      highLow = Chartist.getHighLow(data.normalized.series, options, options.horizontalBars ? 'x' : 'y');\n    }\n\n    // Overrides of high / low from settings\n    highLow.high = +options.high || (options.high === 0 ? 0 : highLow.high);\n    highLow.low = +options.low || (options.low === 0 ? 0 : highLow.low);\n\n    var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n\n    var valueAxis,\n      labelAxisTicks,\n      labelAxis,\n      axisX,\n      axisY;\n\n    // We need to set step count based on some options combinations\n    if(options.distributeSeries && options.stackBars) {\n      // If distributed series are enabled and bars need to be stacked, we'll only have one bar and therefore should\n      // use only the first label for the step axis\n      labelAxisTicks = data.normalized.labels.slice(0, 1);\n    } else {\n      // If distributed series are enabled but stacked bars aren't, we should use the series labels\n      // If we are drawing a regular bar chart with two dimensional series data, we just use the labels array\n      // as the bars are normalized\n      labelAxisTicks = data.normalized.labels;\n    }\n\n    // Set labelAxis and valueAxis based on the horizontalBars setting. This setting will flip the axes if necessary.\n    if(options.horizontalBars) {\n      if(options.axisX.type === undefined) {\n        valueAxis = axisX = new Chartist.AutoScaleAxis(Chartist.Axis.units.x, data.normalized.series, chartRect, Chartist.extend({}, options.axisX, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      } else {\n        valueAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data.normalized.series, chartRect, Chartist.extend({}, options.axisX, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      }\n\n      if(options.axisY.type === undefined) {\n        labelAxis = axisY = new Chartist.StepAxis(Chartist.Axis.units.y, data.normalized.series, chartRect, {\n          ticks: labelAxisTicks\n        });\n      } else {\n        labelAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data.normalized.series, chartRect, options.axisY);\n      }\n    } else {\n      if(options.axisX.type === undefined) {\n        labelAxis = axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data.normalized.series, chartRect, {\n          ticks: labelAxisTicks\n        });\n      } else {\n        labelAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data.normalized.series, chartRect, options.axisX);\n      }\n\n      if(options.axisY.type === undefined) {\n        valueAxis = axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data.normalized.series, chartRect, Chartist.extend({}, options.axisY, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      } else {\n        valueAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data.normalized.series, chartRect, Chartist.extend({}, options.axisY, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      }\n    }\n\n    // Projected 0 point\n    var zeroPoint = options.horizontalBars ? (chartRect.x1 + valueAxis.projectValue(0)) : (chartRect.y1 - valueAxis.projectValue(0));\n    // Used to track the screen coordinates of stacked bars\n    var stackedBarValues = [];\n\n    labelAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n    valueAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n\n    if (options.showGridBackground) {\n      Chartist.createGridBackground(gridGroup, chartRect, options.classNames.gridBackground, this.eventEmitter);\n    }\n\n    // Draw the series\n    data.raw.series.forEach(function(series, seriesIndex) {\n      // Calculating bi-polar value of index for seriesOffset. For i = 0..4 biPol will be -1.5, -0.5, 0.5, 1.5 etc.\n      var biPol = seriesIndex - (data.raw.series.length - 1) / 2;\n      // Half of the period width between vertical grid lines used to position bars\n      var periodHalfLength;\n      // Current series SVG element\n      var seriesElement;\n\n      // We need to set periodHalfLength based on some options combinations\n      if(options.distributeSeries && !options.stackBars) {\n        // If distributed series are enabled but stacked bars aren't, we need to use the length of the normaizedData array\n        // which is the series count and divide by 2\n        periodHalfLength = labelAxis.axisLength / data.normalized.series.length / 2;\n      } else if(options.distributeSeries && options.stackBars) {\n        // If distributed series and stacked bars are enabled we'll only get one bar so we should just divide the axis\n        // length by 2\n        periodHalfLength = labelAxis.axisLength / 2;\n      } else {\n        // On regular bar charts we should just use the series length\n        periodHalfLength = labelAxis.axisLength / data.normalized.series[seriesIndex].length / 2;\n      }\n\n      // Adding the series group to the series element\n      seriesElement = seriesGroup.elem('g');\n\n      // Write attributes to series group element. If series name or meta is undefined the attributes will not be written\n      seriesElement.attr({\n        'ct:series-name': series.name,\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesElement.addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))\n      ].join(' '));\n\n      data.normalized.series[seriesIndex].forEach(function(value, valueIndex) {\n        var projected,\n          bar,\n          previousStack,\n          labelAxisValueIndex;\n\n        // We need to set labelAxisValueIndex based on some options combinations\n        if(options.distributeSeries && !options.stackBars) {\n          // If distributed series are enabled but stacked bars aren't, we can use the seriesIndex for later projection\n          // on the step axis for label positioning\n          labelAxisValueIndex = seriesIndex;\n        } else if(options.distributeSeries && options.stackBars) {\n          // If distributed series and stacked bars are enabled, we will only get one bar and therefore always use\n          // 0 for projection on the label step axis\n          labelAxisValueIndex = 0;\n        } else {\n          // On regular bar charts we just use the value index to project on the label step axis\n          labelAxisValueIndex = valueIndex;\n        }\n\n        // We need to transform coordinates differently based on the chart layout\n        if(options.horizontalBars) {\n          projected = {\n            x: chartRect.x1 + valueAxis.projectValue(value && value.x ? value.x : 0, valueIndex, data.normalized.series[seriesIndex]),\n            y: chartRect.y1 - labelAxis.projectValue(value && value.y ? value.y : 0, labelAxisValueIndex, data.normalized.series[seriesIndex])\n          };\n        } else {\n          projected = {\n            x: chartRect.x1 + labelAxis.projectValue(value && value.x ? value.x : 0, labelAxisValueIndex, data.normalized.series[seriesIndex]),\n            y: chartRect.y1 - valueAxis.projectValue(value && value.y ? value.y : 0, valueIndex, data.normalized.series[seriesIndex])\n          }\n        }\n\n        // If the label axis is a step based axis we will offset the bar into the middle of between two steps using\n        // the periodHalfLength value. Also we do arrange the different series so that they align up to each other using\n        // the seriesBarDistance. If we don't have a step axis, the bar positions can be chosen freely so we should not\n        // add any automated positioning.\n        if(labelAxis instanceof Chartist.StepAxis) {\n          // Offset to center bar between grid lines, but only if the step axis is not stretched\n          if(!labelAxis.options.stretch) {\n            projected[labelAxis.units.pos] += periodHalfLength * (options.horizontalBars ? -1 : 1);\n          }\n          // Using bi-polar offset for multiple series if no stacked bars or series distribution is used\n          projected[labelAxis.units.pos] += (options.stackBars || options.distributeSeries) ? 0 : biPol * options.seriesBarDistance * (options.horizontalBars ? -1 : 1);\n        }\n\n        // Enter value in stacked bar values used to remember previous screen value for stacking up bars\n        previousStack = stackedBarValues[valueIndex] || zeroPoint;\n        stackedBarValues[valueIndex] = previousStack - (zeroPoint - projected[labelAxis.counterUnits.pos]);\n\n        // Skip if value is undefined\n        if(value === undefined) {\n          return;\n        }\n\n        var positions = {};\n        positions[labelAxis.units.pos + '1'] = projected[labelAxis.units.pos];\n        positions[labelAxis.units.pos + '2'] = projected[labelAxis.units.pos];\n\n        if(options.stackBars && (options.stackMode === 'accumulate' || !options.stackMode)) {\n          // Stack mode: accumulate (default)\n          // If bars are stacked we use the stackedBarValues reference and otherwise base all bars off the zero line\n          // We want backwards compatibility, so the expected fallback without the 'stackMode' option\n          // to be the original behaviour (accumulate)\n          positions[labelAxis.counterUnits.pos + '1'] = previousStack;\n          positions[labelAxis.counterUnits.pos + '2'] = stackedBarValues[valueIndex];\n        } else {\n          // Draw from the zero line normally\n          // This is also the same code for Stack mode: overlap\n          positions[labelAxis.counterUnits.pos + '1'] = zeroPoint;\n          positions[labelAxis.counterUnits.pos + '2'] = projected[labelAxis.counterUnits.pos];\n        }\n\n        // Limit x and y so that they are within the chart rect\n        positions.x1 = Math.min(Math.max(positions.x1, chartRect.x1), chartRect.x2);\n        positions.x2 = Math.min(Math.max(positions.x2, chartRect.x1), chartRect.x2);\n        positions.y1 = Math.min(Math.max(positions.y1, chartRect.y2), chartRect.y1);\n        positions.y2 = Math.min(Math.max(positions.y2, chartRect.y2), chartRect.y1);\n\n        var metaData = Chartist.getMetaData(series, valueIndex);\n\n        // Create bar element\n        bar = seriesElement.elem('line', positions, options.classNames.bar).attr({\n          'ct:value': [value.x, value.y].filter(Chartist.isNumeric).join(','),\n          'ct:meta': Chartist.serialize(metaData)\n        });\n\n        this.eventEmitter.emit('draw', Chartist.extend({\n          type: 'bar',\n          value: value,\n          index: valueIndex,\n          meta: metaData,\n          series: series,\n          seriesIndex: seriesIndex,\n          axisX: axisX,\n          axisY: axisY,\n          chartRect: chartRect,\n          group: seriesElement,\n          element: bar\n        }, positions));\n      }.bind(this));\n    }.bind(this));\n\n    this.eventEmitter.emit('created', {\n      bounds: valueAxis.bounds,\n      chartRect: chartRect,\n      axisX: axisX,\n      axisY: axisY,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new bar chart and returns API object that you can use for later changes.\n   *\n   * @memberof Chartist.Bar\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object that needs to consist of a labels and a series array\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object which exposes the API for the created chart\n   *\n   * @example\n   * // Create a simple bar chart\n   * var data = {\n   *   labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // In the global name space Chartist we call the Bar function to initialize a bar chart. As a first parameter we pass in a selector where we would like to get our chart created and as a second parameter we pass our data object.\n   * new Chartist.Bar('.ct-chart', data);\n   *\n   * @example\n   * // This example creates a bipolar grouped bar chart where the boundaries are limitted to -10 and 10\n   * new Chartist.Bar('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5, 6, 7],\n   *   series: [\n   *     [1, 3, 2, -5, -3, 1, -6],\n   *     [-5, -2, -4, -1, 2, -3, 1]\n   *   ]\n   * }, {\n   *   seriesBarDistance: 12,\n   *   low: -10,\n   *   high: 10\n   * });\n   *\n   */\n  function Bar(query, data, options, responsiveOptions) {\n    Chartist.Bar.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating bar chart type in Chartist namespace\n  Chartist.Bar = Chartist.Base.extend({\n    constructor: Bar,\n    createChart: createChart\n  });\n\n}(window, document, Chartist));\n;/**\n * The pie chart module of Chartist that can be used to draw pie, donut or gauge charts\n *\n * @module Chartist.Pie\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Default options in line charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Pie\n   */\n  var defaultOptions = {\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: 5,\n    // Override the class names that are used to generate the SVG structure of the chart\n    classNames: {\n      chartPie: 'ct-chart-pie',\n      chartDonut: 'ct-chart-donut',\n      series: 'ct-series',\n      slicePie: 'ct-slice-pie',\n      sliceDonut: 'ct-slice-donut',\n      sliceDonutSolid: 'ct-slice-donut-solid',\n      label: 'ct-label'\n    },\n    // The start angle of the pie chart in degrees where 0 points north. A higher value offsets the start angle clockwise.\n    startAngle: 0,\n    // An optional total you can specify. By specifying a total value, the sum of the values in the series must be this total in order to draw a full pie. You can use this parameter to draw only parts of a pie or gauge charts.\n    total: undefined,\n    // If specified the donut CSS classes will be used and strokes will be drawn instead of pie slices.\n    donut: false,\n    // If specified the donut segments will be drawn as shapes instead of strokes.\n    donutSolid: false,\n    // Specify the donut stroke width, currently done in javascript for convenience. May move to CSS styles in the future.\n    // This option can be set as number or string to specify a relative width (i.e. 100 or '30%').\n    donutWidth: 60,\n    // If a label should be shown or not\n    showLabel: true,\n    // Label position offset from the standard position which is half distance of the radius. This value can be either positive or negative. Positive values will position the label away from the center.\n    labelOffset: 0,\n    // This option can be set to 'inside', 'outside' or 'center'. Positioned with 'inside' the labels will be placed on half the distance of the radius to the border of the Pie by respecting the 'labelOffset'. The 'outside' option will place the labels at the border of the pie and 'center' will place the labels in the absolute center point of the chart. The 'center' option only makes sense in conjunction with the 'labelOffset' option.\n    labelPosition: 'inside',\n    // An interpolation function for the label value\n    labelInterpolationFnc: Chartist.noop,\n    // Label direction can be 'neutral', 'explode' or 'implode'. The labels anchor will be positioned based on those settings as well as the fact if the labels are on the right or left side of the center of the chart. Usually explode is useful when labels are positioned far away from the center.\n    labelDirection: 'neutral',\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // If true empty values will be ignored to avoid drawing unncessary slices and labels\n    ignoreEmptyValues: false\n  };\n\n  /**\n   * Determines SVG anchor position based on direction and center parameter\n   *\n   * @param center\n   * @param label\n   * @param direction\n   * @return {string}\n   */\n  function determineAnchorPosition(center, label, direction) {\n    var toTheRight = label.x > center.x;\n\n    if(toTheRight && direction === 'explode' ||\n      !toTheRight && direction === 'implode') {\n      return 'start';\n    } else if(toTheRight && direction === 'implode' ||\n      !toTheRight && direction === 'explode') {\n      return 'end';\n    } else {\n      return 'middle';\n    }\n  }\n\n  /**\n   * Creates the pie chart\n   *\n   * @param options\n   */\n  function createChart(options) {\n    var data = Chartist.normalizeData(this.data);\n    var seriesGroups = [],\n      labelsGroup,\n      chartRect,\n      radius,\n      labelRadius,\n      totalDataSum,\n      startAngle = options.startAngle;\n\n    // Create SVG.js draw\n    this.svg = Chartist.createSvg(this.container, options.width, options.height,options.donut ? options.classNames.chartDonut : options.classNames.chartPie);\n    // Calculate charting rect\n    chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n    // Get biggest circle radius possible within chartRect\n    radius = Math.min(chartRect.width() / 2, chartRect.height() / 2);\n    // Calculate total of all series to get reference value or use total reference from optional options\n    totalDataSum = options.total || data.normalized.series.reduce(function(previousValue, currentValue) {\n      return previousValue + currentValue;\n    }, 0);\n\n    var donutWidth = Chartist.quantity(options.donutWidth);\n    if (donutWidth.unit === '%') {\n      donutWidth.value *= radius / 100;\n    }\n\n    // If this is a donut chart we need to adjust our radius to enable strokes to be drawn inside\n    // Unfortunately this is not possible with the current SVG Spec\n    // See this proposal for more details: http://lists.w3.org/Archives/Public/www-svg/2003Oct/0000.html\n    radius -= options.donut && !options.donutSolid ? donutWidth.value / 2  : 0;\n\n    // If labelPosition is set to `outside` or a donut chart is drawn then the label position is at the radius,\n    // if regular pie chart it's half of the radius\n    if(options.labelPosition === 'outside' || options.donut && !options.donutSolid) {\n      labelRadius = radius;\n    } else if(options.labelPosition === 'center') {\n      // If labelPosition is center we start with 0 and will later wait for the labelOffset\n      labelRadius = 0;\n    } else if(options.donutSolid) {\n      labelRadius = radius - donutWidth.value / 2;\n    } else {\n      // Default option is 'inside' where we use half the radius so the label will be placed in the center of the pie\n      // slice\n      labelRadius = radius / 2;\n    }\n    // Add the offset to the labelRadius where a negative offset means closed to the center of the chart\n    labelRadius += options.labelOffset;\n\n    // Calculate end angle based on total sum and current data value and offset with padding\n    var center = {\n      x: chartRect.x1 + chartRect.width() / 2,\n      y: chartRect.y2 + chartRect.height() / 2\n    };\n\n    // Check if there is only one non-zero value in the series array.\n    var hasSingleValInSeries = data.raw.series.filter(function(val) {\n      return val.hasOwnProperty('value') ? val.value !== 0 : val !== 0;\n    }).length === 1;\n\n    // Creating the series groups\n    data.raw.series.forEach(function(series, index) {\n      seriesGroups[index] = this.svg.elem('g', null, null);\n    }.bind(this));\n    //if we need to show labels we create the label group now\n    if(options.showLabel) {\n      labelsGroup = this.svg.elem('g', null, null);\n    }\n\n    // Draw the series\n    // initialize series groups\n    data.raw.series.forEach(function(series, index) {\n      // If current value is zero and we are ignoring empty values then skip to next value\n      if (data.normalized.series[index] === 0 && options.ignoreEmptyValues) return;\n\n      // If the series is an object and contains a name or meta data we add a custom attribute\n      seriesGroups[index].attr({\n        'ct:series-name': series.name\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesGroups[index].addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(index))\n      ].join(' '));\n\n      // If the whole dataset is 0 endAngle should be zero. Can't divide by 0.\n      var endAngle = (totalDataSum > 0 ? startAngle + data.normalized.series[index] / totalDataSum * 360 : 0);\n\n      // Use slight offset so there are no transparent hairline issues\n      var overlappigStartAngle = Math.max(0, startAngle - (index === 0 || hasSingleValInSeries ? 0 : 0.2));\n\n      // If we need to draw the arc for all 360 degrees we need to add a hack where we close the circle\n      // with Z and use 359.99 degrees\n      if(endAngle - overlappigStartAngle >= 359.99) {\n        endAngle = overlappigStartAngle + 359.99;\n      }\n\n      var start = Chartist.polarToCartesian(center.x, center.y, radius, overlappigStartAngle),\n        end = Chartist.polarToCartesian(center.x, center.y, radius, endAngle);\n\n      var innerStart,\n        innerEnd,\n        donutSolidRadius;\n\n      // Create a new path element for the pie chart. If this isn't a donut chart we should close the path for a correct stroke\n      var path = new Chartist.Svg.Path(!options.donut || options.donutSolid)\n        .move(end.x, end.y)\n        .arc(radius, radius, 0, endAngle - startAngle > 180, 0, start.x, start.y);\n\n      // If regular pie chart (no donut) we add a line to the center of the circle for completing the pie\n      if(!options.donut) {\n        path.line(center.x, center.y);\n      } else if (options.donutSolid) {\n        donutSolidRadius = radius - donutWidth.value;\n        innerStart = Chartist.polarToCartesian(center.x, center.y, donutSolidRadius, startAngle - (index === 0 || hasSingleValInSeries ? 0 : 0.2));\n        innerEnd = Chartist.polarToCartesian(center.x, center.y, donutSolidRadius, endAngle);\n        path.line(innerStart.x, innerStart.y);\n        path.arc(donutSolidRadius, donutSolidRadius, 0, endAngle - startAngle  > 180, 1, innerEnd.x, innerEnd.y);\n      }\n\n      // Create the SVG path\n      // If this is a donut chart we add the donut class, otherwise just a regular slice\n      var pathClassName = options.classNames.slicePie;\n      if (options.donut) {\n        pathClassName = options.classNames.sliceDonut;\n        if (options.donutSolid) {\n          pathClassName = options.classNames.sliceDonutSolid;\n        }\n      }\n      var pathElement = seriesGroups[index].elem('path', {\n        d: path.stringify()\n      }, pathClassName);\n\n      // Adding the pie series value to the path\n      pathElement.attr({\n        'ct:value': data.normalized.series[index],\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // If this is a donut, we add the stroke-width as style attribute\n      if(options.donut && !options.donutSolid) {\n        pathElement._node.style.strokeWidth = donutWidth.value + 'px';\n      }\n\n      // Fire off draw event\n      this.eventEmitter.emit('draw', {\n        type: 'slice',\n        value: data.normalized.series[index],\n        totalDataSum: totalDataSum,\n        index: index,\n        meta: series.meta,\n        series: series,\n        group: seriesGroups[index],\n        element: pathElement,\n        path: path.clone(),\n        center: center,\n        radius: radius,\n        startAngle: startAngle,\n        endAngle: endAngle\n      });\n\n      // If we need to show labels we need to add the label for this slice now\n      if(options.showLabel) {\n        var labelPosition;\n        if(data.raw.series.length === 1) {\n          // If we have only 1 series, we can position the label in the center of the pie\n          labelPosition = {\n            x: center.x,\n            y: center.y\n          };\n        } else {\n          // Position at the labelRadius distance from center and between start and end angle\n          labelPosition = Chartist.polarToCartesian(\n            center.x,\n            center.y,\n            labelRadius,\n            startAngle + (endAngle - startAngle) / 2\n          );\n        }\n\n        var rawValue;\n        if(data.normalized.labels && !Chartist.isFalseyButZero(data.normalized.labels[index])) {\n          rawValue = data.normalized.labels[index];\n        } else {\n          rawValue = data.normalized.series[index];\n        }\n\n        var interpolatedValue = options.labelInterpolationFnc(rawValue, index);\n\n        if(interpolatedValue || interpolatedValue === 0) {\n          var labelElement = labelsGroup.elem('text', {\n            dx: labelPosition.x,\n            dy: labelPosition.y,\n            'text-anchor': determineAnchorPosition(center, labelPosition, options.labelDirection)\n          }, options.classNames.label).text('' + interpolatedValue);\n\n          // Fire off draw event\n          this.eventEmitter.emit('draw', {\n            type: 'label',\n            index: index,\n            group: labelsGroup,\n            element: labelElement,\n            text: '' + interpolatedValue,\n            x: labelPosition.x,\n            y: labelPosition.y\n          });\n        }\n      }\n\n      // Set next startAngle to current endAngle.\n      // (except for last slice)\n      startAngle = endAngle;\n    }.bind(this));\n\n    this.eventEmitter.emit('created', {\n      chartRect: chartRect,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new pie chart and returns an object that can be used to redraw the chart.\n   *\n   * @memberof Chartist.Pie\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object in the pie chart needs to have a series property with a one dimensional data array. The values will be normalized against each other and don't necessarily need to be in percentage. The series property can also be an array of value objects that contain a value property and a className property to override the CSS class name for the series group.\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object with a version and an update method to manually redraw the chart\n   *\n   * @example\n   * // Simple pie chart example with four series\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [10, 2, 4, 3]\n   * });\n   *\n   * @example\n   * // Drawing a donut chart\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [10, 2, 4, 3]\n   * }, {\n   *   donut: true\n   * });\n   *\n   * @example\n   * // Using donut, startAngle and total to draw a gauge chart\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [20, 10, 30, 40]\n   * }, {\n   *   donut: true,\n   *   donutWidth: 20,\n   *   startAngle: 270,\n   *   total: 200\n   * });\n   *\n   * @example\n   * // Drawing a pie chart with padding and labels that are outside the pie\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [20, 10, 30, 40]\n   * }, {\n   *   chartPadding: 30,\n   *   labelOffset: 50,\n   *   labelDirection: 'explode'\n   * });\n   *\n   * @example\n   * // Overriding the class names for individual series as well as a name and meta data.\n   * // The name will be written as ct:series-name attribute and the meta data will be serialized and written\n   * // to a ct:meta attribute.\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [{\n   *     value: 20,\n   *     name: 'Series 1',\n   *     className: 'my-custom-class-one',\n   *     meta: 'Meta One'\n   *   }, {\n   *     value: 10,\n   *     name: 'Series 2',\n   *     className: 'my-custom-class-two',\n   *     meta: 'Meta Two'\n   *   }, {\n   *     value: 70,\n   *     name: 'Series 3',\n   *     className: 'my-custom-class-three',\n   *     meta: 'Meta Three'\n   *   }]\n   * });\n   */\n  function Pie(query, data, options, responsiveOptions) {\n    Chartist.Pie.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating pie chart type in Chartist namespace\n  Chartist.Pie = Chartist.Base.extend({\n    constructor: Pie,\n    createChart: createChart,\n    determineAnchorPosition: determineAnchorPosition\n  });\n\n}(window, document, Chartist));\n\nreturn Chartist;\n\n}));\n"
  },
  {
    "path": "pubnot/chartist/scss/chartist.scss",
    "content": "@import \"settings/chartist-settings\";\n\n@mixin ct-responsive-svg-container($width: 100%, $ratio: $ct-container-ratio) {\n  display: block;\n  position: relative;\n  width: $width;\n\n  &:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: $ratio * 100%;\n  }\n\n  &:after {\n    content: \"\";\n    display: table;\n    clear: both;\n  }\n\n  > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0;\n  }\n}\n\n@mixin ct-align-justify($ct-text-align: $ct-text-align, $ct-text-justify: $ct-text-justify) {\n  -webkit-box-align: $ct-text-align;\n  -webkit-align-items: $ct-text-align;\n  -ms-flex-align: $ct-text-align;\n  align-items: $ct-text-align;\n  -webkit-box-pack: $ct-text-justify;\n  -webkit-justify-content: $ct-text-justify;\n  -ms-flex-pack: $ct-text-justify;\n  justify-content: $ct-text-justify;\n  // Fallback to text-align for non-flex browsers\n  @if($ct-text-justify == 'flex-start') {\n    text-align: left;\n  } @else if ($ct-text-justify == 'flex-end') {\n    text-align: right;\n  } @else {\n    text-align: center;\n  }\n}\n\n@mixin ct-flex() {\n  // Fallback to block\n  display: block;\n  display: -webkit-box;\n  display: -moz-box;\n  display: -ms-flexbox;\n  display: -webkit-flex;\n  display: flex;\n}\n\n@mixin ct-chart-label($ct-text-color: $ct-text-color, $ct-text-size: $ct-text-size, $ct-text-line-height: $ct-text-line-height) {\n  fill: $ct-text-color;\n  color: $ct-text-color;\n  font-size: $ct-text-size;\n  line-height: $ct-text-line-height;\n}\n\n@mixin ct-chart-grid($ct-grid-color: $ct-grid-color, $ct-grid-width: $ct-grid-width, $ct-grid-dasharray: $ct-grid-dasharray) {\n  stroke: $ct-grid-color;\n  stroke-width: $ct-grid-width;\n\n  @if ($ct-grid-dasharray) {\n    stroke-dasharray: $ct-grid-dasharray;\n  }\n}\n\n@mixin ct-chart-point($ct-point-size: $ct-point-size, $ct-point-shape: $ct-point-shape) {\n  stroke-width: $ct-point-size;\n  stroke-linecap: $ct-point-shape;\n}\n\n@mixin ct-chart-line($ct-line-width: $ct-line-width, $ct-line-dasharray: $ct-line-dasharray) {\n  fill: none;\n  stroke-width: $ct-line-width;\n\n  @if ($ct-line-dasharray) {\n    stroke-dasharray: $ct-line-dasharray;\n  }\n}\n\n@mixin ct-chart-area($ct-area-opacity: $ct-area-opacity) {\n  stroke: none;\n  fill-opacity: $ct-area-opacity;\n}\n\n@mixin ct-chart-bar($ct-bar-width: $ct-bar-width) {\n  fill: none;\n  stroke-width: $ct-bar-width;\n}\n\n@mixin ct-chart-donut($ct-donut-width: $ct-donut-width) {\n  fill: none;\n  stroke-width: $ct-donut-width;\n}\n\n@mixin ct-chart-series-color($color) {\n  .#{$ct-class-point}, .#{$ct-class-line}, .#{$ct-class-bar}, .#{$ct-class-slice-donut} {\n    stroke: $color;\n  }\n\n  .#{$ct-class-slice-pie}, .#{$ct-class-slice-donut-solid}, .#{$ct-class-area} {\n    fill: $color;\n  }\n}\n\n@mixin ct-chart($ct-container-ratio: $ct-container-ratio, $ct-text-color: $ct-text-color, $ct-text-size: $ct-text-size, $ct-grid-color: $ct-grid-color, $ct-grid-width: $ct-grid-width, $ct-grid-dasharray: $ct-grid-dasharray, $ct-point-size: $ct-point-size, $ct-point-shape: $ct-point-shape, $ct-line-width: $ct-line-width, $ct-bar-width: $ct-bar-width, $ct-donut-width: $ct-donut-width, $ct-series-names: $ct-series-names, $ct-series-colors: $ct-series-colors) {\n\n  .#{$ct-class-label} {\n    @include ct-chart-label($ct-text-color, $ct-text-size);\n  }\n\n  .#{$ct-class-chart-line} .#{$ct-class-label},\n  .#{$ct-class-chart-bar} .#{$ct-class-label} {\n    @include ct-flex();\n  }\n\n  .#{$ct-class-chart-pie} .#{$ct-class-label},\n  .#{$ct-class-chart-donut} .#{$ct-class-label} {\n    dominant-baseline: central;\n  }\n\n  .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-start} {\n    @include ct-align-justify(flex-end, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-end} {\n    @include ct-align-justify(flex-start, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-label}.#{$ct-class-vertical}.#{$ct-class-start} {\n    @include ct-align-justify(flex-end, flex-end);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: end;\n  }\n\n  .#{$ct-class-label}.#{$ct-class-vertical}.#{$ct-class-end} {\n    @include ct-align-justify(flex-end, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-chart-bar} .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-start} {\n    @include ct-align-justify(flex-end, center);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-chart-bar} .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-end} {\n    @include ct-align-justify(flex-start, center);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-chart-bar}.#{$ct-class-horizontal-bars} .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-start} {\n    @include ct-align-justify(flex-end, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-chart-bar}.#{$ct-class-horizontal-bars} .#{$ct-class-label}.#{$ct-class-horizontal}.#{$ct-class-end} {\n    @include ct-align-justify(flex-start, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: start;\n  }\n\n  .#{$ct-class-chart-bar}.#{$ct-class-horizontal-bars} .#{$ct-class-label}.#{$ct-class-vertical}.#{$ct-class-start} {\n    //@include ct-chart-label($ct-text-color, $ct-text-size, center, $ct-vertical-text-justify);\n    @include ct-align-justify(center, flex-end);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: end;\n  }\n\n  .#{$ct-class-chart-bar}.#{$ct-class-horizontal-bars} .#{$ct-class-label}.#{$ct-class-vertical}.#{$ct-class-end} {\n    @include ct-align-justify(center, flex-start);\n    // Fallback for browsers that don't support foreignObjects\n    text-anchor: end;\n  }\n\n  .#{$ct-class-grid} {\n    @include ct-chart-grid($ct-grid-color, $ct-grid-width, $ct-grid-dasharray);\n  }\n\n  .#{$ct-class-grid-background} {\n    fill: $ct-grid-background-fill;\n  }\n\n  .#{$ct-class-point} {\n    @include ct-chart-point($ct-point-size, $ct-point-shape);\n  }\n\n  .#{$ct-class-line} {\n    @include ct-chart-line($ct-line-width);\n  }\n\n  .#{$ct-class-area} {\n    @include ct-chart-area();\n  }\n\n  .#{$ct-class-bar} {\n    @include ct-chart-bar($ct-bar-width);\n  }\n\n  .#{$ct-class-slice-donut} {\n    @include ct-chart-donut($ct-donut-width);\n  }\n\n  @if $ct-include-colored-series {\n    @for $i from 0 to length($ct-series-names) {\n      .#{$ct-class-series}-#{nth($ct-series-names, $i + 1)} {\n        $color: nth($ct-series-colors, $i + 1);\n\n        @include ct-chart-series-color($color);\n      }\n    }\n  }\n}\n\n@if $ct-include-classes {\n  @include ct-chart();\n\n  @if $ct-include-alternative-responsive-containers {\n    @for $i from 0 to length($ct-scales-names) {\n      .#{nth($ct-scales-names, $i + 1)} {\n        @include ct-responsive-svg-container($ratio: nth($ct-scales, $i + 1));\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "pubnot/chartist/scss/settings/_chartist-settings.scss",
    "content": "// Scales for responsive SVG containers\n$ct-scales: ((1), (15/16), (8/9), (5/6), (4/5), (3/4), (2/3), (5/8), (1/1.618), (3/5), (9/16), (8/15), (1/2), (2/5), (3/8), (1/3), (1/4)) !default;\n$ct-scales-names: (ct-square, ct-minor-second, ct-major-second, ct-minor-third, ct-major-third, ct-perfect-fourth, ct-perfect-fifth, ct-minor-sixth, ct-golden-section, ct-major-sixth, ct-minor-seventh, ct-major-seventh, ct-octave, ct-major-tenth, ct-major-eleventh, ct-major-twelfth, ct-double-octave) !default;\n\n// Class names to be used when generating CSS\n$ct-class-chart: ct-chart !default;\n$ct-class-chart-line: ct-chart-line !default;\n$ct-class-chart-bar: ct-chart-bar !default;\n$ct-class-horizontal-bars: ct-horizontal-bars !default;\n$ct-class-chart-pie: ct-chart-pie !default;\n$ct-class-chart-donut: ct-chart-donut !default;\n$ct-class-label: ct-label !default;\n$ct-class-series: ct-series !default;\n$ct-class-line: ct-line !default;\n$ct-class-point: ct-point !default;\n$ct-class-area: ct-area !default;\n$ct-class-bar: ct-bar !default;\n$ct-class-slice-pie: ct-slice-pie !default;\n$ct-class-slice-donut: ct-slice-donut !default;\n$ct-class-slice-donut-solid: ct-slice-donut-solid !default;\n$ct-class-grid: ct-grid !default;\n$ct-class-grid-background: ct-grid-background !default;\n$ct-class-vertical: ct-vertical !default;\n$ct-class-horizontal: ct-horizontal !default;\n$ct-class-start: ct-start !default;\n$ct-class-end: ct-end !default;\n\n// Container ratio\n$ct-container-ratio: (1/1.618) !default;\n\n// Text styles for labels\n$ct-text-color: rgba(0, 0, 0, 0.4) !default;\n$ct-text-size: 0.75rem !default;\n$ct-text-align: flex-start !default;\n$ct-text-justify: flex-start !default;\n$ct-text-line-height: 1;\n\n// Grid styles\n$ct-grid-color: rgba(0, 0, 0, 0.2) !default;\n$ct-grid-dasharray: 2px !default;\n$ct-grid-width: 1px !default;\n$ct-grid-background-fill: none !default;\n\n// Line chart properties\n$ct-line-width: 4px !default;\n$ct-line-dasharray: false !default;\n$ct-point-size: 10px !default;\n// Line chart point, can be either round or square\n$ct-point-shape: round !default;\n// Area fill transparency between 0 and 1\n$ct-area-opacity: 0.1 !default;\n\n// Bar chart bar width\n$ct-bar-width: 10px !default;\n\n// Donut width (If donut width is to big it can cause issues where the shape gets distorted)\n$ct-donut-width: 60px !default;\n\n// If set to true it will include the default classes and generate CSS output. If you're planning to use the mixins you\n// should set this property to false\n$ct-include-classes: true !default;\n\n// If this is set to true the CSS will contain colored series. You can extend or change the color with the\n// properties below\n$ct-include-colored-series: $ct-include-classes !default;\n\n// If set to true this will include all responsive container variations using the scales defined at the top of the script\n$ct-include-alternative-responsive-containers: $ct-include-classes !default;\n\n// Series names and colors. This can be extended or customized as desired. Just add more series and colors.\n$ct-series-names: (a, b, c, d, e, f, g, h, i, j, k, l, m, n, o) !default;\n$ct-series-colors: (\n  #d70206,\n  #f05b4f,\n  #f4c63d,\n  #d17905,\n  #453d3f,\n  #59922b,\n  #0544d3,\n  #6b0392,\n  #f05b4f,\n  #dda458,\n  #eacf7d,\n  #86797d,\n  #b2c326,\n  #6188e2,\n  #a748ca\n) !default;\n"
  },
  {
    "path": "pubnot/font-awesome-4.7.0/css/font-awesome.css",
    "content": "/*!\n *  Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome\n *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)\n */\n/* FONT PATH\n * -------------------------- */\n@font-face {\n  font-family: 'FontAwesome';\n  src: url('../fonts/fontawesome-webfont.eot?v=4.7.0');\n  src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');\n  font-weight: normal;\n  font-style: normal;\n}\n.fa {\n  display: inline-block;\n  font: normal normal normal 14px/1 FontAwesome;\n  font-size: inherit;\n  text-rendering: auto;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n/* makes the font 33% larger relative to the icon container */\n.fa-lg {\n  font-size: 1.33333333em;\n  line-height: 0.75em;\n  vertical-align: -15%;\n}\n.fa-2x {\n  font-size: 2em;\n}\n.fa-3x {\n  font-size: 3em;\n}\n.fa-4x {\n  font-size: 4em;\n}\n.fa-5x {\n  font-size: 5em;\n}\n.fa-fw {\n  width: 1.28571429em;\n  text-align: center;\n}\n.fa-ul {\n  padding-left: 0;\n  margin-left: 2.14285714em;\n  list-style-type: none;\n}\n.fa-ul > li {\n  position: relative;\n}\n.fa-li {\n  position: absolute;\n  left: -2.14285714em;\n  width: 2.14285714em;\n  top: 0.14285714em;\n  text-align: center;\n}\n.fa-li.fa-lg {\n  left: -1.85714286em;\n}\n.fa-border {\n  padding: .2em .25em .15em;\n  border: solid 0.08em #eeeeee;\n  border-radius: .1em;\n}\n.fa-pull-left {\n  float: left;\n}\n.fa-pull-right {\n  float: right;\n}\n.fa.fa-pull-left {\n  margin-right: .3em;\n}\n.fa.fa-pull-right {\n  margin-left: .3em;\n}\n/* Deprecated as of 4.4.0 */\n.pull-right {\n  float: right;\n}\n.pull-left {\n  float: left;\n}\n.fa.pull-left {\n  margin-right: .3em;\n}\n.fa.pull-right {\n  margin-left: .3em;\n}\n.fa-spin {\n  -webkit-animation: fa-spin 2s infinite linear;\n  animation: fa-spin 2s infinite linear;\n}\n.fa-pulse {\n  -webkit-animation: fa-spin 1s infinite steps(8);\n  animation: fa-spin 1s infinite steps(8);\n}\n@-webkit-keyframes fa-spin {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(359deg);\n    transform: rotate(359deg);\n  }\n}\n@keyframes fa-spin {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(359deg);\n    transform: rotate(359deg);\n  }\n}\n.fa-rotate-90 {\n  -ms-filter: \"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)\";\n  -webkit-transform: rotate(90deg);\n  -ms-transform: rotate(90deg);\n  transform: rotate(90deg);\n}\n.fa-rotate-180 {\n  -ms-filter: \"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)\";\n  -webkit-transform: rotate(180deg);\n  -ms-transform: rotate(180deg);\n  transform: rotate(180deg);\n}\n.fa-rotate-270 {\n  -ms-filter: \"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)\";\n  -webkit-transform: rotate(270deg);\n  -ms-transform: rotate(270deg);\n  transform: rotate(270deg);\n}\n.fa-flip-horizontal {\n  -ms-filter: \"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)\";\n  -webkit-transform: scale(-1, 1);\n  -ms-transform: scale(-1, 1);\n  transform: scale(-1, 1);\n}\n.fa-flip-vertical {\n  -ms-filter: \"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)\";\n  -webkit-transform: scale(1, -1);\n  -ms-transform: scale(1, -1);\n  transform: scale(1, -1);\n}\n:root .fa-rotate-90,\n:root .fa-rotate-180,\n:root .fa-rotate-270,\n:root .fa-flip-horizontal,\n:root .fa-flip-vertical {\n  filter: none;\n}\n.fa-stack {\n  position: relative;\n  display: inline-block;\n  width: 2em;\n  height: 2em;\n  line-height: 2em;\n  vertical-align: middle;\n}\n.fa-stack-1x,\n.fa-stack-2x {\n  position: absolute;\n  left: 0;\n  width: 100%;\n  text-align: center;\n}\n.fa-stack-1x {\n  line-height: inherit;\n}\n.fa-stack-2x {\n  font-size: 2em;\n}\n.fa-inverse {\n  color: #ffffff;\n}\n/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen\n   readers do not read off random characters that represent icons */\n.fa-glass:before {\n  content: \"\\f000\";\n}\n.fa-music:before {\n  content: \"\\f001\";\n}\n.fa-search:before {\n  content: \"\\f002\";\n}\n.fa-envelope-o:before {\n  content: \"\\f003\";\n}\n.fa-heart:before {\n  content: \"\\f004\";\n}\n.fa-star:before {\n  content: \"\\f005\";\n}\n.fa-star-o:before {\n  content: \"\\f006\";\n}\n.fa-user:before {\n  content: \"\\f007\";\n}\n.fa-film:before {\n  content: \"\\f008\";\n}\n.fa-th-large:before {\n  content: \"\\f009\";\n}\n.fa-th:before {\n  content: \"\\f00a\";\n}\n.fa-th-list:before {\n  content: \"\\f00b\";\n}\n.fa-check:before {\n  content: \"\\f00c\";\n}\n.fa-remove:before,\n.fa-close:before,\n.fa-times:before {\n  content: \"\\f00d\";\n}\n.fa-search-plus:before {\n  content: \"\\f00e\";\n}\n.fa-search-minus:before {\n  content: \"\\f010\";\n}\n.fa-power-off:before {\n  content: \"\\f011\";\n}\n.fa-signal:before {\n  content: \"\\f012\";\n}\n.fa-gear:before,\n.fa-cog:before {\n  content: \"\\f013\";\n}\n.fa-trash-o:before {\n  content: \"\\f014\";\n}\n.fa-home:before {\n  content: \"\\f015\";\n}\n.fa-file-o:before {\n  content: \"\\f016\";\n}\n.fa-clock-o:before {\n  content: \"\\f017\";\n}\n.fa-road:before {\n  content: \"\\f018\";\n}\n.fa-download:before {\n  content: \"\\f019\";\n}\n.fa-arrow-circle-o-down:before {\n  content: \"\\f01a\";\n}\n.fa-arrow-circle-o-up:before {\n  content: \"\\f01b\";\n}\n.fa-inbox:before {\n  content: \"\\f01c\";\n}\n.fa-play-circle-o:before {\n  content: \"\\f01d\";\n}\n.fa-rotate-right:before,\n.fa-repeat:before {\n  content: \"\\f01e\";\n}\n.fa-refresh:before {\n  content: \"\\f021\";\n}\n.fa-list-alt:before {\n  content: \"\\f022\";\n}\n.fa-lock:before {\n  content: \"\\f023\";\n}\n.fa-flag:before {\n  content: \"\\f024\";\n}\n.fa-headphones:before {\n  content: \"\\f025\";\n}\n.fa-volume-off:before {\n  content: \"\\f026\";\n}\n.fa-volume-down:before {\n  content: \"\\f027\";\n}\n.fa-volume-up:before {\n  content: \"\\f028\";\n}\n.fa-qrcode:before {\n  content: \"\\f029\";\n}\n.fa-barcode:before {\n  content: \"\\f02a\";\n}\n.fa-tag:before {\n  content: \"\\f02b\";\n}\n.fa-tags:before {\n  content: \"\\f02c\";\n}\n.fa-book:before {\n  content: \"\\f02d\";\n}\n.fa-bookmark:before {\n  content: \"\\f02e\";\n}\n.fa-print:before {\n  content: \"\\f02f\";\n}\n.fa-camera:before {\n  content: \"\\f030\";\n}\n.fa-font:before {\n  content: \"\\f031\";\n}\n.fa-bold:before {\n  content: \"\\f032\";\n}\n.fa-italic:before {\n  content: \"\\f033\";\n}\n.fa-text-height:before {\n  content: \"\\f034\";\n}\n.fa-text-width:before {\n  content: \"\\f035\";\n}\n.fa-align-left:before {\n  content: \"\\f036\";\n}\n.fa-align-center:before {\n  content: \"\\f037\";\n}\n.fa-align-right:before {\n  content: \"\\f038\";\n}\n.fa-align-justify:before {\n  content: \"\\f039\";\n}\n.fa-list:before {\n  content: \"\\f03a\";\n}\n.fa-dedent:before,\n.fa-outdent:before {\n  content: \"\\f03b\";\n}\n.fa-indent:before {\n  content: \"\\f03c\";\n}\n.fa-video-camera:before {\n  content: \"\\f03d\";\n}\n.fa-photo:before,\n.fa-image:before,\n.fa-picture-o:before {\n  content: \"\\f03e\";\n}\n.fa-pencil:before {\n  content: \"\\f040\";\n}\n.fa-map-marker:before {\n  content: \"\\f041\";\n}\n.fa-adjust:before {\n  content: \"\\f042\";\n}\n.fa-tint:before {\n  content: \"\\f043\";\n}\n.fa-edit:before,\n.fa-pencil-square-o:before {\n  content: \"\\f044\";\n}\n.fa-share-square-o:before {\n  content: \"\\f045\";\n}\n.fa-check-square-o:before {\n  content: \"\\f046\";\n}\n.fa-arrows:before {\n  content: \"\\f047\";\n}\n.fa-step-backward:before {\n  content: \"\\f048\";\n}\n.fa-fast-backward:before {\n  content: \"\\f049\";\n}\n.fa-backward:before {\n  content: \"\\f04a\";\n}\n.fa-play:before {\n  content: \"\\f04b\";\n}\n.fa-pause:before {\n  content: \"\\f04c\";\n}\n.fa-stop:before {\n  content: \"\\f04d\";\n}\n.fa-forward:before {\n  content: \"\\f04e\";\n}\n.fa-fast-forward:before {\n  content: \"\\f050\";\n}\n.fa-step-forward:before {\n  content: \"\\f051\";\n}\n.fa-eject:before {\n  content: \"\\f052\";\n}\n.fa-chevron-left:before {\n  content: \"\\f053\";\n}\n.fa-chevron-right:before {\n  content: \"\\f054\";\n}\n.fa-plus-circle:before {\n  content: \"\\f055\";\n}\n.fa-minus-circle:before {\n  content: \"\\f056\";\n}\n.fa-times-circle:before {\n  content: \"\\f057\";\n}\n.fa-check-circle:before {\n  content: \"\\f058\";\n}\n.fa-question-circle:before {\n  content: \"\\f059\";\n}\n.fa-info-circle:before {\n  content: \"\\f05a\";\n}\n.fa-crosshairs:before {\n  content: \"\\f05b\";\n}\n.fa-times-circle-o:before {\n  content: \"\\f05c\";\n}\n.fa-check-circle-o:before {\n  content: \"\\f05d\";\n}\n.fa-ban:before {\n  content: \"\\f05e\";\n}\n.fa-arrow-left:before {\n  content: \"\\f060\";\n}\n.fa-arrow-right:before {\n  content: \"\\f061\";\n}\n.fa-arrow-up:before {\n  content: \"\\f062\";\n}\n.fa-arrow-down:before {\n  content: \"\\f063\";\n}\n.fa-mail-forward:before,\n.fa-share:before {\n  content: \"\\f064\";\n}\n.fa-expand:before {\n  content: \"\\f065\";\n}\n.fa-compress:before {\n  content: \"\\f066\";\n}\n.fa-plus:before {\n  content: \"\\f067\";\n}\n.fa-minus:before {\n  content: \"\\f068\";\n}\n.fa-asterisk:before {\n  content: \"\\f069\";\n}\n.fa-exclamation-circle:before {\n  content: \"\\f06a\";\n}\n.fa-gift:before {\n  content: \"\\f06b\";\n}\n.fa-leaf:before {\n  content: \"\\f06c\";\n}\n.fa-fire:before {\n  content: \"\\f06d\";\n}\n.fa-eye:before {\n  content: \"\\f06e\";\n}\n.fa-eye-slash:before {\n  content: \"\\f070\";\n}\n.fa-warning:before,\n.fa-exclamation-triangle:before {\n  content: \"\\f071\";\n}\n.fa-plane:before {\n  content: \"\\f072\";\n}\n.fa-calendar:before {\n  content: \"\\f073\";\n}\n.fa-random:before {\n  content: \"\\f074\";\n}\n.fa-comment:before {\n  content: \"\\f075\";\n}\n.fa-magnet:before {\n  content: \"\\f076\";\n}\n.fa-chevron-up:before {\n  content: \"\\f077\";\n}\n.fa-chevron-down:before {\n  content: \"\\f078\";\n}\n.fa-retweet:before {\n  content: \"\\f079\";\n}\n.fa-shopping-cart:before {\n  content: \"\\f07a\";\n}\n.fa-folder:before {\n  content: \"\\f07b\";\n}\n.fa-folder-open:before {\n  content: \"\\f07c\";\n}\n.fa-arrows-v:before {\n  content: \"\\f07d\";\n}\n.fa-arrows-h:before {\n  content: \"\\f07e\";\n}\n.fa-bar-chart-o:before,\n.fa-bar-chart:before {\n  content: \"\\f080\";\n}\n.fa-twitter-square:before {\n  content: \"\\f081\";\n}\n.fa-facebook-square:before {\n  content: \"\\f082\";\n}\n.fa-camera-retro:before {\n  content: \"\\f083\";\n}\n.fa-key:before {\n  content: \"\\f084\";\n}\n.fa-gears:before,\n.fa-cogs:before {\n  content: \"\\f085\";\n}\n.fa-comments:before {\n  content: \"\\f086\";\n}\n.fa-thumbs-o-up:before {\n  content: \"\\f087\";\n}\n.fa-thumbs-o-down:before {\n  content: \"\\f088\";\n}\n.fa-star-half:before {\n  content: \"\\f089\";\n}\n.fa-heart-o:before {\n  content: \"\\f08a\";\n}\n.fa-sign-out:before {\n  content: \"\\f08b\";\n}\n.fa-linkedin-square:before {\n  content: \"\\f08c\";\n}\n.fa-thumb-tack:before {\n  content: \"\\f08d\";\n}\n.fa-external-link:before {\n  content: \"\\f08e\";\n}\n.fa-sign-in:before {\n  content: \"\\f090\";\n}\n.fa-trophy:before {\n  content: \"\\f091\";\n}\n.fa-github-square:before {\n  content: \"\\f092\";\n}\n.fa-upload:before {\n  content: \"\\f093\";\n}\n.fa-lemon-o:before {\n  content: \"\\f094\";\n}\n.fa-phone:before {\n  content: \"\\f095\";\n}\n.fa-square-o:before {\n  content: \"\\f096\";\n}\n.fa-bookmark-o:before {\n  content: \"\\f097\";\n}\n.fa-phone-square:before {\n  content: \"\\f098\";\n}\n.fa-twitter:before {\n  content: \"\\f099\";\n}\n.fa-facebook-f:before,\n.fa-facebook:before {\n  content: \"\\f09a\";\n}\n.fa-github:before {\n  content: \"\\f09b\";\n}\n.fa-unlock:before {\n  content: \"\\f09c\";\n}\n.fa-credit-card:before {\n  content: \"\\f09d\";\n}\n.fa-feed:before,\n.fa-rss:before {\n  content: \"\\f09e\";\n}\n.fa-hdd-o:before {\n  content: \"\\f0a0\";\n}\n.fa-bullhorn:before {\n  content: \"\\f0a1\";\n}\n.fa-bell:before {\n  content: \"\\f0f3\";\n}\n.fa-certificate:before {\n  content: \"\\f0a3\";\n}\n.fa-hand-o-right:before {\n  content: \"\\f0a4\";\n}\n.fa-hand-o-left:before {\n  content: \"\\f0a5\";\n}\n.fa-hand-o-up:before {\n  content: \"\\f0a6\";\n}\n.fa-hand-o-down:before {\n  content: \"\\f0a7\";\n}\n.fa-arrow-circle-left:before {\n  content: \"\\f0a8\";\n}\n.fa-arrow-circle-right:before {\n  content: \"\\f0a9\";\n}\n.fa-arrow-circle-up:before {\n  content: \"\\f0aa\";\n}\n.fa-arrow-circle-down:before {\n  content: \"\\f0ab\";\n}\n.fa-globe:before {\n  content: \"\\f0ac\";\n}\n.fa-wrench:before {\n  content: \"\\f0ad\";\n}\n.fa-tasks:before {\n  content: \"\\f0ae\";\n}\n.fa-filter:before {\n  content: \"\\f0b0\";\n}\n.fa-briefcase:before {\n  content: \"\\f0b1\";\n}\n.fa-arrows-alt:before {\n  content: \"\\f0b2\";\n}\n.fa-group:before,\n.fa-users:before {\n  content: \"\\f0c0\";\n}\n.fa-chain:before,\n.fa-link:before {\n  content: \"\\f0c1\";\n}\n.fa-cloud:before {\n  content: \"\\f0c2\";\n}\n.fa-flask:before {\n  content: \"\\f0c3\";\n}\n.fa-cut:before,\n.fa-scissors:before {\n  content: \"\\f0c4\";\n}\n.fa-copy:before,\n.fa-files-o:before {\n  content: \"\\f0c5\";\n}\n.fa-paperclip:before {\n  content: \"\\f0c6\";\n}\n.fa-save:before,\n.fa-floppy-o:before {\n  content: \"\\f0c7\";\n}\n.fa-square:before {\n  content: \"\\f0c8\";\n}\n.fa-navicon:before,\n.fa-reorder:before,\n.fa-bars:before {\n  content: \"\\f0c9\";\n}\n.fa-list-ul:before {\n  content: \"\\f0ca\";\n}\n.fa-list-ol:before {\n  content: \"\\f0cb\";\n}\n.fa-strikethrough:before {\n  content: \"\\f0cc\";\n}\n.fa-underline:before {\n  content: \"\\f0cd\";\n}\n.fa-table:before {\n  content: \"\\f0ce\";\n}\n.fa-magic:before {\n  content: \"\\f0d0\";\n}\n.fa-truck:before {\n  content: \"\\f0d1\";\n}\n.fa-pinterest:before {\n  content: \"\\f0d2\";\n}\n.fa-pinterest-square:before {\n  content: \"\\f0d3\";\n}\n.fa-google-plus-square:before {\n  content: \"\\f0d4\";\n}\n.fa-google-plus:before {\n  content: \"\\f0d5\";\n}\n.fa-money:before {\n  content: \"\\f0d6\";\n}\n.fa-caret-down:before {\n  content: \"\\f0d7\";\n}\n.fa-caret-up:before {\n  content: \"\\f0d8\";\n}\n.fa-caret-left:before {\n  content: \"\\f0d9\";\n}\n.fa-caret-right:before {\n  content: \"\\f0da\";\n}\n.fa-columns:before {\n  content: \"\\f0db\";\n}\n.fa-unsorted:before,\n.fa-sort:before {\n  content: \"\\f0dc\";\n}\n.fa-sort-down:before,\n.fa-sort-desc:before {\n  content: \"\\f0dd\";\n}\n.fa-sort-up:before,\n.fa-sort-asc:before {\n  content: \"\\f0de\";\n}\n.fa-envelope:before {\n  content: \"\\f0e0\";\n}\n.fa-linkedin:before {\n  content: \"\\f0e1\";\n}\n.fa-rotate-left:before,\n.fa-undo:before {\n  content: \"\\f0e2\";\n}\n.fa-legal:before,\n.fa-gavel:before {\n  content: \"\\f0e3\";\n}\n.fa-dashboard:before,\n.fa-tachometer:before {\n  content: \"\\f0e4\";\n}\n.fa-comment-o:before {\n  content: \"\\f0e5\";\n}\n.fa-comments-o:before {\n  content: \"\\f0e6\";\n}\n.fa-flash:before,\n.fa-bolt:before {\n  content: \"\\f0e7\";\n}\n.fa-sitemap:before {\n  content: \"\\f0e8\";\n}\n.fa-umbrella:before {\n  content: \"\\f0e9\";\n}\n.fa-paste:before,\n.fa-clipboard:before {\n  content: \"\\f0ea\";\n}\n.fa-lightbulb-o:before {\n  content: \"\\f0eb\";\n}\n.fa-exchange:before {\n  content: \"\\f0ec\";\n}\n.fa-cloud-download:before {\n  content: \"\\f0ed\";\n}\n.fa-cloud-upload:before {\n  content: \"\\f0ee\";\n}\n.fa-user-md:before {\n  content: \"\\f0f0\";\n}\n.fa-stethoscope:before {\n  content: \"\\f0f1\";\n}\n.fa-suitcase:before {\n  content: \"\\f0f2\";\n}\n.fa-bell-o:before {\n  content: \"\\f0a2\";\n}\n.fa-coffee:before {\n  content: \"\\f0f4\";\n}\n.fa-cutlery:before {\n  content: \"\\f0f5\";\n}\n.fa-file-text-o:before {\n  content: \"\\f0f6\";\n}\n.fa-building-o:before {\n  content: \"\\f0f7\";\n}\n.fa-hospital-o:before {\n  content: \"\\f0f8\";\n}\n.fa-ambulance:before {\n  content: \"\\f0f9\";\n}\n.fa-medkit:before {\n  content: \"\\f0fa\";\n}\n.fa-fighter-jet:before {\n  content: \"\\f0fb\";\n}\n.fa-beer:before {\n  content: \"\\f0fc\";\n}\n.fa-h-square:before {\n  content: \"\\f0fd\";\n}\n.fa-plus-square:before {\n  content: \"\\f0fe\";\n}\n.fa-angle-double-left:before {\n  content: \"\\f100\";\n}\n.fa-angle-double-right:before {\n  content: \"\\f101\";\n}\n.fa-angle-double-up:before {\n  content: \"\\f102\";\n}\n.fa-angle-double-down:before {\n  content: \"\\f103\";\n}\n.fa-angle-left:before {\n  content: \"\\f104\";\n}\n.fa-angle-right:before {\n  content: \"\\f105\";\n}\n.fa-angle-up:before {\n  content: \"\\f106\";\n}\n.fa-angle-down:before {\n  content: \"\\f107\";\n}\n.fa-desktop:before {\n  content: \"\\f108\";\n}\n.fa-laptop:before {\n  content: \"\\f109\";\n}\n.fa-tablet:before {\n  content: \"\\f10a\";\n}\n.fa-mobile-phone:before,\n.fa-mobile:before {\n  content: \"\\f10b\";\n}\n.fa-circle-o:before {\n  content: \"\\f10c\";\n}\n.fa-quote-left:before {\n  content: \"\\f10d\";\n}\n.fa-quote-right:before {\n  content: \"\\f10e\";\n}\n.fa-spinner:before {\n  content: \"\\f110\";\n}\n.fa-circle:before {\n  content: \"\\f111\";\n}\n.fa-mail-reply:before,\n.fa-reply:before {\n  content: \"\\f112\";\n}\n.fa-github-alt:before {\n  content: \"\\f113\";\n}\n.fa-folder-o:before {\n  content: \"\\f114\";\n}\n.fa-folder-open-o:before {\n  content: \"\\f115\";\n}\n.fa-smile-o:before {\n  content: \"\\f118\";\n}\n.fa-frown-o:before {\n  content: \"\\f119\";\n}\n.fa-meh-o:before {\n  content: \"\\f11a\";\n}\n.fa-gamepad:before {\n  content: \"\\f11b\";\n}\n.fa-keyboard-o:before {\n  content: \"\\f11c\";\n}\n.fa-flag-o:before {\n  content: \"\\f11d\";\n}\n.fa-flag-checkered:before {\n  content: \"\\f11e\";\n}\n.fa-terminal:before {\n  content: \"\\f120\";\n}\n.fa-code:before {\n  content: \"\\f121\";\n}\n.fa-mail-reply-all:before,\n.fa-reply-all:before {\n  content: \"\\f122\";\n}\n.fa-star-half-empty:before,\n.fa-star-half-full:before,\n.fa-star-half-o:before {\n  content: \"\\f123\";\n}\n.fa-location-arrow:before {\n  content: \"\\f124\";\n}\n.fa-crop:before {\n  content: \"\\f125\";\n}\n.fa-code-fork:before {\n  content: \"\\f126\";\n}\n.fa-unlink:before,\n.fa-chain-broken:before {\n  content: \"\\f127\";\n}\n.fa-question:before {\n  content: \"\\f128\";\n}\n.fa-info:before {\n  content: \"\\f129\";\n}\n.fa-exclamation:before {\n  content: \"\\f12a\";\n}\n.fa-superscript:before {\n  content: \"\\f12b\";\n}\n.fa-subscript:before {\n  content: \"\\f12c\";\n}\n.fa-eraser:before {\n  content: \"\\f12d\";\n}\n.fa-puzzle-piece:before {\n  content: \"\\f12e\";\n}\n.fa-microphone:before {\n  content: \"\\f130\";\n}\n.fa-microphone-slash:before {\n  content: \"\\f131\";\n}\n.fa-shield:before {\n  content: \"\\f132\";\n}\n.fa-calendar-o:before {\n  content: \"\\f133\";\n}\n.fa-fire-extinguisher:before {\n  content: \"\\f134\";\n}\n.fa-rocket:before {\n  content: \"\\f135\";\n}\n.fa-maxcdn:before {\n  content: \"\\f136\";\n}\n.fa-chevron-circle-left:before {\n  content: \"\\f137\";\n}\n.fa-chevron-circle-right:before {\n  content: \"\\f138\";\n}\n.fa-chevron-circle-up:before {\n  content: \"\\f139\";\n}\n.fa-chevron-circle-down:before {\n  content: \"\\f13a\";\n}\n.fa-html5:before {\n  content: \"\\f13b\";\n}\n.fa-css3:before {\n  content: \"\\f13c\";\n}\n.fa-anchor:before {\n  content: \"\\f13d\";\n}\n.fa-unlock-alt:before {\n  content: \"\\f13e\";\n}\n.fa-bullseye:before {\n  content: \"\\f140\";\n}\n.fa-ellipsis-h:before {\n  content: \"\\f141\";\n}\n.fa-ellipsis-v:before {\n  content: \"\\f142\";\n}\n.fa-rss-square:before {\n  content: \"\\f143\";\n}\n.fa-play-circle:before {\n  content: \"\\f144\";\n}\n.fa-ticket:before {\n  content: \"\\f145\";\n}\n.fa-minus-square:before {\n  content: \"\\f146\";\n}\n.fa-minus-square-o:before {\n  content: \"\\f147\";\n}\n.fa-level-up:before {\n  content: \"\\f148\";\n}\n.fa-level-down:before {\n  content: \"\\f149\";\n}\n.fa-check-square:before {\n  content: \"\\f14a\";\n}\n.fa-pencil-square:before {\n  content: \"\\f14b\";\n}\n.fa-external-link-square:before {\n  content: \"\\f14c\";\n}\n.fa-share-square:before {\n  content: \"\\f14d\";\n}\n.fa-compass:before {\n  content: \"\\f14e\";\n}\n.fa-toggle-down:before,\n.fa-caret-square-o-down:before {\n  content: \"\\f150\";\n}\n.fa-toggle-up:before,\n.fa-caret-square-o-up:before {\n  content: \"\\f151\";\n}\n.fa-toggle-right:before,\n.fa-caret-square-o-right:before {\n  content: \"\\f152\";\n}\n.fa-euro:before,\n.fa-eur:before {\n  content: \"\\f153\";\n}\n.fa-gbp:before {\n  content: \"\\f154\";\n}\n.fa-dollar:before,\n.fa-usd:before {\n  content: \"\\f155\";\n}\n.fa-rupee:before,\n.fa-inr:before {\n  content: \"\\f156\";\n}\n.fa-cny:before,\n.fa-rmb:before,\n.fa-yen:before,\n.fa-jpy:before {\n  content: \"\\f157\";\n}\n.fa-ruble:before,\n.fa-rouble:before,\n.fa-rub:before {\n  content: \"\\f158\";\n}\n.fa-won:before,\n.fa-krw:before {\n  content: \"\\f159\";\n}\n.fa-bitcoin:before,\n.fa-btc:before {\n  content: \"\\f15a\";\n}\n.fa-file:before {\n  content: \"\\f15b\";\n}\n.fa-file-text:before {\n  content: \"\\f15c\";\n}\n.fa-sort-alpha-asc:before {\n  content: \"\\f15d\";\n}\n.fa-sort-alpha-desc:before {\n  content: \"\\f15e\";\n}\n.fa-sort-amount-asc:before {\n  content: \"\\f160\";\n}\n.fa-sort-amount-desc:before {\n  content: \"\\f161\";\n}\n.fa-sort-numeric-asc:before {\n  content: \"\\f162\";\n}\n.fa-sort-numeric-desc:before {\n  content: \"\\f163\";\n}\n.fa-thumbs-up:before {\n  content: \"\\f164\";\n}\n.fa-thumbs-down:before {\n  content: \"\\f165\";\n}\n.fa-youtube-square:before {\n  content: \"\\f166\";\n}\n.fa-youtube:before {\n  content: \"\\f167\";\n}\n.fa-xing:before {\n  content: \"\\f168\";\n}\n.fa-xing-square:before {\n  content: \"\\f169\";\n}\n.fa-youtube-play:before {\n  content: \"\\f16a\";\n}\n.fa-dropbox:before {\n  content: \"\\f16b\";\n}\n.fa-stack-overflow:before {\n  content: \"\\f16c\";\n}\n.fa-instagram:before {\n  content: \"\\f16d\";\n}\n.fa-flickr:before {\n  content: \"\\f16e\";\n}\n.fa-adn:before {\n  content: \"\\f170\";\n}\n.fa-bitbucket:before {\n  content: \"\\f171\";\n}\n.fa-bitbucket-square:before {\n  content: \"\\f172\";\n}\n.fa-tumblr:before {\n  content: \"\\f173\";\n}\n.fa-tumblr-square:before {\n  content: \"\\f174\";\n}\n.fa-long-arrow-down:before {\n  content: \"\\f175\";\n}\n.fa-long-arrow-up:before {\n  content: \"\\f176\";\n}\n.fa-long-arrow-left:before {\n  content: \"\\f177\";\n}\n.fa-long-arrow-right:before {\n  content: \"\\f178\";\n}\n.fa-apple:before {\n  content: \"\\f179\";\n}\n.fa-windows:before {\n  content: \"\\f17a\";\n}\n.fa-android:before {\n  content: \"\\f17b\";\n}\n.fa-linux:before {\n  content: \"\\f17c\";\n}\n.fa-dribbble:before {\n  content: \"\\f17d\";\n}\n.fa-skype:before {\n  content: \"\\f17e\";\n}\n.fa-foursquare:before {\n  content: \"\\f180\";\n}\n.fa-trello:before {\n  content: \"\\f181\";\n}\n.fa-female:before {\n  content: \"\\f182\";\n}\n.fa-male:before {\n  content: \"\\f183\";\n}\n.fa-gittip:before,\n.fa-gratipay:before {\n  content: \"\\f184\";\n}\n.fa-sun-o:before {\n  content: \"\\f185\";\n}\n.fa-moon-o:before {\n  content: \"\\f186\";\n}\n.fa-archive:before {\n  content: \"\\f187\";\n}\n.fa-bug:before {\n  content: \"\\f188\";\n}\n.fa-vk:before {\n  content: \"\\f189\";\n}\n.fa-weibo:before {\n  content: \"\\f18a\";\n}\n.fa-renren:before {\n  content: \"\\f18b\";\n}\n.fa-pagelines:before {\n  content: \"\\f18c\";\n}\n.fa-stack-exchange:before {\n  content: \"\\f18d\";\n}\n.fa-arrow-circle-o-right:before {\n  content: \"\\f18e\";\n}\n.fa-arrow-circle-o-left:before {\n  content: \"\\f190\";\n}\n.fa-toggle-left:before,\n.fa-caret-square-o-left:before {\n  content: \"\\f191\";\n}\n.fa-dot-circle-o:before {\n  content: \"\\f192\";\n}\n.fa-wheelchair:before {\n  content: \"\\f193\";\n}\n.fa-vimeo-square:before {\n  content: \"\\f194\";\n}\n.fa-turkish-lira:before,\n.fa-try:before {\n  content: \"\\f195\";\n}\n.fa-plus-square-o:before {\n  content: \"\\f196\";\n}\n.fa-space-shuttle:before {\n  content: \"\\f197\";\n}\n.fa-slack:before {\n  content: \"\\f198\";\n}\n.fa-envelope-square:before {\n  content: \"\\f199\";\n}\n.fa-wordpress:before {\n  content: \"\\f19a\";\n}\n.fa-openid:before {\n  content: \"\\f19b\";\n}\n.fa-institution:before,\n.fa-bank:before,\n.fa-university:before {\n  content: \"\\f19c\";\n}\n.fa-mortar-board:before,\n.fa-graduation-cap:before {\n  content: \"\\f19d\";\n}\n.fa-yahoo:before {\n  content: \"\\f19e\";\n}\n.fa-google:before {\n  content: \"\\f1a0\";\n}\n.fa-reddit:before {\n  content: \"\\f1a1\";\n}\n.fa-reddit-square:before {\n  content: \"\\f1a2\";\n}\n.fa-stumbleupon-circle:before {\n  content: \"\\f1a3\";\n}\n.fa-stumbleupon:before {\n  content: \"\\f1a4\";\n}\n.fa-delicious:before {\n  content: \"\\f1a5\";\n}\n.fa-digg:before {\n  content: \"\\f1a6\";\n}\n.fa-pied-piper-pp:before {\n  content: \"\\f1a7\";\n}\n.fa-pied-piper-alt:before {\n  content: \"\\f1a8\";\n}\n.fa-drupal:before {\n  content: \"\\f1a9\";\n}\n.fa-joomla:before {\n  content: \"\\f1aa\";\n}\n.fa-language:before {\n  content: \"\\f1ab\";\n}\n.fa-fax:before {\n  content: \"\\f1ac\";\n}\n.fa-building:before {\n  content: \"\\f1ad\";\n}\n.fa-child:before {\n  content: \"\\f1ae\";\n}\n.fa-paw:before {\n  content: \"\\f1b0\";\n}\n.fa-spoon:before {\n  content: \"\\f1b1\";\n}\n.fa-cube:before {\n  content: \"\\f1b2\";\n}\n.fa-cubes:before {\n  content: \"\\f1b3\";\n}\n.fa-behance:before {\n  content: \"\\f1b4\";\n}\n.fa-behance-square:before {\n  content: \"\\f1b5\";\n}\n.fa-steam:before {\n  content: \"\\f1b6\";\n}\n.fa-steam-square:before {\n  content: \"\\f1b7\";\n}\n.fa-recycle:before {\n  content: \"\\f1b8\";\n}\n.fa-automobile:before,\n.fa-car:before {\n  content: \"\\f1b9\";\n}\n.fa-cab:before,\n.fa-taxi:before {\n  content: \"\\f1ba\";\n}\n.fa-tree:before {\n  content: \"\\f1bb\";\n}\n.fa-spotify:before {\n  content: \"\\f1bc\";\n}\n.fa-deviantart:before {\n  content: \"\\f1bd\";\n}\n.fa-soundcloud:before {\n  content: \"\\f1be\";\n}\n.fa-database:before {\n  content: \"\\f1c0\";\n}\n.fa-file-pdf-o:before {\n  content: \"\\f1c1\";\n}\n.fa-file-word-o:before {\n  content: \"\\f1c2\";\n}\n.fa-file-excel-o:before {\n  content: \"\\f1c3\";\n}\n.fa-file-powerpoint-o:before {\n  content: \"\\f1c4\";\n}\n.fa-file-photo-o:before,\n.fa-file-picture-o:before,\n.fa-file-image-o:before {\n  content: \"\\f1c5\";\n}\n.fa-file-zip-o:before,\n.fa-file-archive-o:before {\n  content: \"\\f1c6\";\n}\n.fa-file-sound-o:before,\n.fa-file-audio-o:before {\n  content: \"\\f1c7\";\n}\n.fa-file-movie-o:before,\n.fa-file-video-o:before {\n  content: \"\\f1c8\";\n}\n.fa-file-code-o:before {\n  content: \"\\f1c9\";\n}\n.fa-vine:before {\n  content: \"\\f1ca\";\n}\n.fa-codepen:before {\n  content: \"\\f1cb\";\n}\n.fa-jsfiddle:before {\n  content: \"\\f1cc\";\n}\n.fa-life-bouy:before,\n.fa-life-buoy:before,\n.fa-life-saver:before,\n.fa-support:before,\n.fa-life-ring:before {\n  content: \"\\f1cd\";\n}\n.fa-circle-o-notch:before {\n  content: \"\\f1ce\";\n}\n.fa-ra:before,\n.fa-resistance:before,\n.fa-rebel:before {\n  content: \"\\f1d0\";\n}\n.fa-ge:before,\n.fa-empire:before {\n  content: \"\\f1d1\";\n}\n.fa-git-square:before {\n  content: \"\\f1d2\";\n}\n.fa-git:before {\n  content: \"\\f1d3\";\n}\n.fa-y-combinator-square:before,\n.fa-yc-square:before,\n.fa-hacker-news:before {\n  content: \"\\f1d4\";\n}\n.fa-tencent-weibo:before {\n  content: \"\\f1d5\";\n}\n.fa-qq:before {\n  content: \"\\f1d6\";\n}\n.fa-wechat:before,\n.fa-weixin:before {\n  content: \"\\f1d7\";\n}\n.fa-send:before,\n.fa-paper-plane:before {\n  content: \"\\f1d8\";\n}\n.fa-send-o:before,\n.fa-paper-plane-o:before {\n  content: \"\\f1d9\";\n}\n.fa-history:before {\n  content: \"\\f1da\";\n}\n.fa-circle-thin:before {\n  content: \"\\f1db\";\n}\n.fa-header:before {\n  content: \"\\f1dc\";\n}\n.fa-paragraph:before {\n  content: \"\\f1dd\";\n}\n.fa-sliders:before {\n  content: \"\\f1de\";\n}\n.fa-share-alt:before {\n  content: \"\\f1e0\";\n}\n.fa-share-alt-square:before {\n  content: \"\\f1e1\";\n}\n.fa-bomb:before {\n  content: \"\\f1e2\";\n}\n.fa-soccer-ball-o:before,\n.fa-futbol-o:before {\n  content: \"\\f1e3\";\n}\n.fa-tty:before {\n  content: \"\\f1e4\";\n}\n.fa-binoculars:before {\n  content: \"\\f1e5\";\n}\n.fa-plug:before {\n  content: \"\\f1e6\";\n}\n.fa-slideshare:before {\n  content: \"\\f1e7\";\n}\n.fa-twitch:before {\n  content: \"\\f1e8\";\n}\n.fa-yelp:before {\n  content: \"\\f1e9\";\n}\n.fa-newspaper-o:before {\n  content: \"\\f1ea\";\n}\n.fa-wifi:before {\n  content: \"\\f1eb\";\n}\n.fa-calculator:before {\n  content: \"\\f1ec\";\n}\n.fa-paypal:before {\n  content: \"\\f1ed\";\n}\n.fa-google-wallet:before {\n  content: \"\\f1ee\";\n}\n.fa-cc-visa:before {\n  content: \"\\f1f0\";\n}\n.fa-cc-mastercard:before {\n  content: \"\\f1f1\";\n}\n.fa-cc-discover:before {\n  content: \"\\f1f2\";\n}\n.fa-cc-amex:before {\n  content: \"\\f1f3\";\n}\n.fa-cc-paypal:before {\n  content: \"\\f1f4\";\n}\n.fa-cc-stripe:before {\n  content: \"\\f1f5\";\n}\n.fa-bell-slash:before {\n  content: \"\\f1f6\";\n}\n.fa-bell-slash-o:before {\n  content: \"\\f1f7\";\n}\n.fa-trash:before {\n  content: \"\\f1f8\";\n}\n.fa-copyright:before {\n  content: \"\\f1f9\";\n}\n.fa-at:before {\n  content: \"\\f1fa\";\n}\n.fa-eyedropper:before {\n  content: \"\\f1fb\";\n}\n.fa-paint-brush:before {\n  content: \"\\f1fc\";\n}\n.fa-birthday-cake:before {\n  content: \"\\f1fd\";\n}\n.fa-area-chart:before {\n  content: \"\\f1fe\";\n}\n.fa-pie-chart:before {\n  content: \"\\f200\";\n}\n.fa-line-chart:before {\n  content: \"\\f201\";\n}\n.fa-lastfm:before {\n  content: \"\\f202\";\n}\n.fa-lastfm-square:before {\n  content: \"\\f203\";\n}\n.fa-toggle-off:before {\n  content: \"\\f204\";\n}\n.fa-toggle-on:before {\n  content: \"\\f205\";\n}\n.fa-bicycle:before {\n  content: \"\\f206\";\n}\n.fa-bus:before {\n  content: \"\\f207\";\n}\n.fa-ioxhost:before {\n  content: \"\\f208\";\n}\n.fa-angellist:before {\n  content: \"\\f209\";\n}\n.fa-cc:before {\n  content: \"\\f20a\";\n}\n.fa-shekel:before,\n.fa-sheqel:before,\n.fa-ils:before {\n  content: \"\\f20b\";\n}\n.fa-meanpath:before {\n  content: \"\\f20c\";\n}\n.fa-buysellads:before {\n  content: \"\\f20d\";\n}\n.fa-connectdevelop:before {\n  content: \"\\f20e\";\n}\n.fa-dashcube:before {\n  content: \"\\f210\";\n}\n.fa-forumbee:before {\n  content: \"\\f211\";\n}\n.fa-leanpub:before {\n  content: \"\\f212\";\n}\n.fa-sellsy:before {\n  content: \"\\f213\";\n}\n.fa-shirtsinbulk:before {\n  content: \"\\f214\";\n}\n.fa-simplybuilt:before {\n  content: \"\\f215\";\n}\n.fa-skyatlas:before {\n  content: \"\\f216\";\n}\n.fa-cart-plus:before {\n  content: \"\\f217\";\n}\n.fa-cart-arrow-down:before {\n  content: \"\\f218\";\n}\n.fa-diamond:before {\n  content: \"\\f219\";\n}\n.fa-ship:before {\n  content: \"\\f21a\";\n}\n.fa-user-secret:before {\n  content: \"\\f21b\";\n}\n.fa-motorcycle:before {\n  content: \"\\f21c\";\n}\n.fa-street-view:before {\n  content: \"\\f21d\";\n}\n.fa-heartbeat:before {\n  content: \"\\f21e\";\n}\n.fa-venus:before {\n  content: \"\\f221\";\n}\n.fa-mars:before {\n  content: \"\\f222\";\n}\n.fa-mercury:before {\n  content: \"\\f223\";\n}\n.fa-intersex:before,\n.fa-transgender:before {\n  content: \"\\f224\";\n}\n.fa-transgender-alt:before {\n  content: \"\\f225\";\n}\n.fa-venus-double:before {\n  content: \"\\f226\";\n}\n.fa-mars-double:before {\n  content: \"\\f227\";\n}\n.fa-venus-mars:before {\n  content: \"\\f228\";\n}\n.fa-mars-stroke:before {\n  content: \"\\f229\";\n}\n.fa-mars-stroke-v:before {\n  content: \"\\f22a\";\n}\n.fa-mars-stroke-h:before {\n  content: \"\\f22b\";\n}\n.fa-neuter:before {\n  content: \"\\f22c\";\n}\n.fa-genderless:before {\n  content: \"\\f22d\";\n}\n.fa-facebook-official:before {\n  content: \"\\f230\";\n}\n.fa-pinterest-p:before {\n  content: \"\\f231\";\n}\n.fa-whatsapp:before {\n  content: \"\\f232\";\n}\n.fa-server:before {\n  content: \"\\f233\";\n}\n.fa-user-plus:before {\n  content: \"\\f234\";\n}\n.fa-user-times:before {\n  content: \"\\f235\";\n}\n.fa-hotel:before,\n.fa-bed:before {\n  content: \"\\f236\";\n}\n.fa-viacoin:before {\n  content: \"\\f237\";\n}\n.fa-train:before {\n  content: \"\\f238\";\n}\n.fa-subway:before {\n  content: \"\\f239\";\n}\n.fa-medium:before {\n  content: \"\\f23a\";\n}\n.fa-yc:before,\n.fa-y-combinator:before {\n  content: \"\\f23b\";\n}\n.fa-optin-monster:before {\n  content: \"\\f23c\";\n}\n.fa-opencart:before {\n  content: \"\\f23d\";\n}\n.fa-expeditedssl:before {\n  content: \"\\f23e\";\n}\n.fa-battery-4:before,\n.fa-battery:before,\n.fa-battery-full:before {\n  content: \"\\f240\";\n}\n.fa-battery-3:before,\n.fa-battery-three-quarters:before {\n  content: \"\\f241\";\n}\n.fa-battery-2:before,\n.fa-battery-half:before {\n  content: \"\\f242\";\n}\n.fa-battery-1:before,\n.fa-battery-quarter:before {\n  content: \"\\f243\";\n}\n.fa-battery-0:before,\n.fa-battery-empty:before {\n  content: \"\\f244\";\n}\n.fa-mouse-pointer:before {\n  content: \"\\f245\";\n}\n.fa-i-cursor:before {\n  content: \"\\f246\";\n}\n.fa-object-group:before {\n  content: \"\\f247\";\n}\n.fa-object-ungroup:before {\n  content: \"\\f248\";\n}\n.fa-sticky-note:before {\n  content: \"\\f249\";\n}\n.fa-sticky-note-o:before {\n  content: \"\\f24a\";\n}\n.fa-cc-jcb:before {\n  content: \"\\f24b\";\n}\n.fa-cc-diners-club:before {\n  content: \"\\f24c\";\n}\n.fa-clone:before {\n  content: \"\\f24d\";\n}\n.fa-balance-scale:before {\n  content: \"\\f24e\";\n}\n.fa-hourglass-o:before {\n  content: \"\\f250\";\n}\n.fa-hourglass-1:before,\n.fa-hourglass-start:before {\n  content: \"\\f251\";\n}\n.fa-hourglass-2:before,\n.fa-hourglass-half:before {\n  content: \"\\f252\";\n}\n.fa-hourglass-3:before,\n.fa-hourglass-end:before {\n  content: \"\\f253\";\n}\n.fa-hourglass:before {\n  content: \"\\f254\";\n}\n.fa-hand-grab-o:before,\n.fa-hand-rock-o:before {\n  content: \"\\f255\";\n}\n.fa-hand-stop-o:before,\n.fa-hand-paper-o:before {\n  content: \"\\f256\";\n}\n.fa-hand-scissors-o:before {\n  content: \"\\f257\";\n}\n.fa-hand-lizard-o:before {\n  content: \"\\f258\";\n}\n.fa-hand-spock-o:before {\n  content: \"\\f259\";\n}\n.fa-hand-pointer-o:before {\n  content: \"\\f25a\";\n}\n.fa-hand-peace-o:before {\n  content: \"\\f25b\";\n}\n.fa-trademark:before {\n  content: \"\\f25c\";\n}\n.fa-registered:before {\n  content: \"\\f25d\";\n}\n.fa-creative-commons:before {\n  content: \"\\f25e\";\n}\n.fa-gg:before {\n  content: \"\\f260\";\n}\n.fa-gg-circle:before {\n  content: \"\\f261\";\n}\n.fa-tripadvisor:before {\n  content: \"\\f262\";\n}\n.fa-odnoklassniki:before {\n  content: \"\\f263\";\n}\n.fa-odnoklassniki-square:before {\n  content: \"\\f264\";\n}\n.fa-get-pocket:before {\n  content: \"\\f265\";\n}\n.fa-wikipedia-w:before {\n  content: \"\\f266\";\n}\n.fa-safari:before {\n  content: \"\\f267\";\n}\n.fa-chrome:before {\n  content: \"\\f268\";\n}\n.fa-firefox:before {\n  content: \"\\f269\";\n}\n.fa-opera:before {\n  content: \"\\f26a\";\n}\n.fa-internet-explorer:before {\n  content: \"\\f26b\";\n}\n.fa-tv:before,\n.fa-television:before {\n  content: \"\\f26c\";\n}\n.fa-contao:before {\n  content: \"\\f26d\";\n}\n.fa-500px:before {\n  content: \"\\f26e\";\n}\n.fa-amazon:before {\n  content: \"\\f270\";\n}\n.fa-calendar-plus-o:before {\n  content: \"\\f271\";\n}\n.fa-calendar-minus-o:before {\n  content: \"\\f272\";\n}\n.fa-calendar-times-o:before {\n  content: \"\\f273\";\n}\n.fa-calendar-check-o:before {\n  content: \"\\f274\";\n}\n.fa-industry:before {\n  content: \"\\f275\";\n}\n.fa-map-pin:before {\n  content: \"\\f276\";\n}\n.fa-map-signs:before {\n  content: \"\\f277\";\n}\n.fa-map-o:before {\n  content: \"\\f278\";\n}\n.fa-map:before {\n  content: \"\\f279\";\n}\n.fa-commenting:before {\n  content: \"\\f27a\";\n}\n.fa-commenting-o:before {\n  content: \"\\f27b\";\n}\n.fa-houzz:before {\n  content: \"\\f27c\";\n}\n.fa-vimeo:before {\n  content: \"\\f27d\";\n}\n.fa-black-tie:before {\n  content: \"\\f27e\";\n}\n.fa-fonticons:before {\n  content: \"\\f280\";\n}\n.fa-reddit-alien:before {\n  content: \"\\f281\";\n}\n.fa-edge:before {\n  content: \"\\f282\";\n}\n.fa-credit-card-alt:before {\n  content: \"\\f283\";\n}\n.fa-codiepie:before {\n  content: \"\\f284\";\n}\n.fa-modx:before {\n  content: \"\\f285\";\n}\n.fa-fort-awesome:before {\n  content: \"\\f286\";\n}\n.fa-usb:before {\n  content: \"\\f287\";\n}\n.fa-product-hunt:before {\n  content: \"\\f288\";\n}\n.fa-mixcloud:before {\n  content: \"\\f289\";\n}\n.fa-scribd:before {\n  content: \"\\f28a\";\n}\n.fa-pause-circle:before {\n  content: \"\\f28b\";\n}\n.fa-pause-circle-o:before {\n  content: \"\\f28c\";\n}\n.fa-stop-circle:before {\n  content: \"\\f28d\";\n}\n.fa-stop-circle-o:before {\n  content: \"\\f28e\";\n}\n.fa-shopping-bag:before {\n  content: \"\\f290\";\n}\n.fa-shopping-basket:before {\n  content: \"\\f291\";\n}\n.fa-hashtag:before {\n  content: \"\\f292\";\n}\n.fa-bluetooth:before {\n  content: \"\\f293\";\n}\n.fa-bluetooth-b:before {\n  content: \"\\f294\";\n}\n.fa-percent:before {\n  content: \"\\f295\";\n}\n.fa-gitlab:before {\n  content: \"\\f296\";\n}\n.fa-wpbeginner:before {\n  content: \"\\f297\";\n}\n.fa-wpforms:before {\n  content: \"\\f298\";\n}\n.fa-envira:before {\n  content: \"\\f299\";\n}\n.fa-universal-access:before {\n  content: \"\\f29a\";\n}\n.fa-wheelchair-alt:before {\n  content: \"\\f29b\";\n}\n.fa-question-circle-o:before {\n  content: \"\\f29c\";\n}\n.fa-blind:before {\n  content: \"\\f29d\";\n}\n.fa-audio-description:before {\n  content: \"\\f29e\";\n}\n.fa-volume-control-phone:before {\n  content: \"\\f2a0\";\n}\n.fa-braille:before {\n  content: \"\\f2a1\";\n}\n.fa-assistive-listening-systems:before {\n  content: \"\\f2a2\";\n}\n.fa-asl-interpreting:before,\n.fa-american-sign-language-interpreting:before {\n  content: \"\\f2a3\";\n}\n.fa-deafness:before,\n.fa-hard-of-hearing:before,\n.fa-deaf:before {\n  content: \"\\f2a4\";\n}\n.fa-glide:before {\n  content: \"\\f2a5\";\n}\n.fa-glide-g:before {\n  content: \"\\f2a6\";\n}\n.fa-signing:before,\n.fa-sign-language:before {\n  content: \"\\f2a7\";\n}\n.fa-low-vision:before {\n  content: \"\\f2a8\";\n}\n.fa-viadeo:before {\n  content: \"\\f2a9\";\n}\n.fa-viadeo-square:before {\n  content: \"\\f2aa\";\n}\n.fa-snapchat:before {\n  content: \"\\f2ab\";\n}\n.fa-snapchat-ghost:before {\n  content: \"\\f2ac\";\n}\n.fa-snapchat-square:before {\n  content: \"\\f2ad\";\n}\n.fa-pied-piper:before {\n  content: \"\\f2ae\";\n}\n.fa-first-order:before {\n  content: \"\\f2b0\";\n}\n.fa-yoast:before {\n  content: \"\\f2b1\";\n}\n.fa-themeisle:before {\n  content: \"\\f2b2\";\n}\n.fa-google-plus-circle:before,\n.fa-google-plus-official:before {\n  content: \"\\f2b3\";\n}\n.fa-fa:before,\n.fa-font-awesome:before {\n  content: \"\\f2b4\";\n}\n.fa-handshake-o:before {\n  content: \"\\f2b5\";\n}\n.fa-envelope-open:before {\n  content: \"\\f2b6\";\n}\n.fa-envelope-open-o:before {\n  content: \"\\f2b7\";\n}\n.fa-linode:before {\n  content: \"\\f2b8\";\n}\n.fa-address-book:before {\n  content: \"\\f2b9\";\n}\n.fa-address-book-o:before {\n  content: \"\\f2ba\";\n}\n.fa-vcard:before,\n.fa-address-card:before {\n  content: \"\\f2bb\";\n}\n.fa-vcard-o:before,\n.fa-address-card-o:before {\n  content: \"\\f2bc\";\n}\n.fa-user-circle:before {\n  content: \"\\f2bd\";\n}\n.fa-user-circle-o:before {\n  content: \"\\f2be\";\n}\n.fa-user-o:before {\n  content: \"\\f2c0\";\n}\n.fa-id-badge:before {\n  content: \"\\f2c1\";\n}\n.fa-drivers-license:before,\n.fa-id-card:before {\n  content: \"\\f2c2\";\n}\n.fa-drivers-license-o:before,\n.fa-id-card-o:before {\n  content: \"\\f2c3\";\n}\n.fa-quora:before {\n  content: \"\\f2c4\";\n}\n.fa-free-code-camp:before {\n  content: \"\\f2c5\";\n}\n.fa-telegram:before {\n  content: \"\\f2c6\";\n}\n.fa-thermometer-4:before,\n.fa-thermometer:before,\n.fa-thermometer-full:before {\n  content: \"\\f2c7\";\n}\n.fa-thermometer-3:before,\n.fa-thermometer-three-quarters:before {\n  content: \"\\f2c8\";\n}\n.fa-thermometer-2:before,\n.fa-thermometer-half:before {\n  content: \"\\f2c9\";\n}\n.fa-thermometer-1:before,\n.fa-thermometer-quarter:before {\n  content: \"\\f2ca\";\n}\n.fa-thermometer-0:before,\n.fa-thermometer-empty:before {\n  content: \"\\f2cb\";\n}\n.fa-shower:before {\n  content: \"\\f2cc\";\n}\n.fa-bathtub:before,\n.fa-s15:before,\n.fa-bath:before {\n  content: \"\\f2cd\";\n}\n.fa-podcast:before {\n  content: \"\\f2ce\";\n}\n.fa-window-maximize:before {\n  content: \"\\f2d0\";\n}\n.fa-window-minimize:before {\n  content: \"\\f2d1\";\n}\n.fa-window-restore:before {\n  content: \"\\f2d2\";\n}\n.fa-times-rectangle:before,\n.fa-window-close:before {\n  content: \"\\f2d3\";\n}\n.fa-times-rectangle-o:before,\n.fa-window-close-o:before {\n  content: \"\\f2d4\";\n}\n.fa-bandcamp:before {\n  content: \"\\f2d5\";\n}\n.fa-grav:before {\n  content: \"\\f2d6\";\n}\n.fa-etsy:before {\n  content: \"\\f2d7\";\n}\n.fa-imdb:before {\n  content: \"\\f2d8\";\n}\n.fa-ravelry:before {\n  content: \"\\f2d9\";\n}\n.fa-eercast:before {\n  content: \"\\f2da\";\n}\n.fa-microchip:before {\n  content: \"\\f2db\";\n}\n.fa-snowflake-o:before {\n  content: \"\\f2dc\";\n}\n.fa-superpowers:before {\n  content: \"\\f2dd\";\n}\n.fa-wpexplorer:before {\n  content: \"\\f2de\";\n}\n.fa-meetup:before {\n  content: \"\\f2e0\";\n}\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n  position: static;\n  width: auto;\n  height: auto;\n  margin: 0;\n  overflow: visible;\n  clip: auto;\n}\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/base64/trumbowyg.base64.js",
    "content": "/* ===========================================================\n * trumbowyg.base64.js v1.0\n * Base64 plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Cyril Biencourt (lizardK)\n */\n\n(function ($) {\n    'use strict';\n\n    var isSupported = function () {\n        return typeof FileReader !== 'undefined';\n    };\n\n    var isValidImage = function (type) {\n        return /^data:image\\/[a-z]?/i.test(type);\n    };\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            // jshint camelcase:false\n            en: {\n                base64: 'Image as base64',\n                file: 'File',\n                errFileReaderNotSupported: 'FileReader is not supported by your browser.',\n                errInvalidImage: 'Invalid image file.'\n            },\n            fr: {\n                base64: 'Image en base64',\n                file: 'Fichier'\n            },\n            cs: {\n                base64: 'Vložit obrázek',\n                file: 'Soubor'\n            },\n            zh_cn: {\n                base64: '图片（Base64编码）',\n                file: '文件'\n            },\n            nl: {\n                errFileReaderNotSupported: 'Uw browser ondersteunt deze functionaliteit niet.',\n                errInvalidImage: 'De gekozen afbeelding is ongeldig.'\n            },\n            ru: {\n                base64: 'Изображение как код в base64',\n                file: 'Файл',\n                errFileReaderNotSupported: 'FileReader не поддерживается вашим браузером.',\n                errInvalidImage: 'Недопустимый файл изображения.'\n            },\n            ja: {\n                base64: '画像 (Base64形式)',\n                file: 'ファイル',\n                errFileReaderNotSupported: 'あなたのブラウザーはFileReaderをサポートしていません',\n                errInvalidImage: '画像形式が正しくありません'\n            }\n        },\n        // jshint camelcase:true\n\n        plugins: {\n            base64: {\n                shouldInit: isSupported,\n                init: function (trumbowyg) {\n                    var btnDef = {\n                        isSupported: isSupported,\n                        fn: function () {\n                            trumbowyg.saveRange();\n\n                            var file;\n                            var $modal = trumbowyg.openModalInsert(\n                                // Title\n                                trumbowyg.lang.base64,\n\n                                // Fields\n                                {\n                                    file: {\n                                        type: 'file',\n                                        required: true,\n                                        attributes: {\n                                            accept: 'image/*'\n                                        }\n                                    },\n                                    alt: {\n                                        label: 'description',\n                                        value: trumbowyg.getRangeText()\n                                    }\n                                },\n\n                                // Callback\n                                function (values) {\n                                    var fReader = new FileReader();\n\n                                    fReader.onloadend = function (e) {\n                                        if (isValidImage(e.target.result)) {\n                                            trumbowyg.execCmd('insertImage', fReader.result);\n                                            $(['img[src=\"', fReader.result, '\"]:not([alt])'].join(''), trumbowyg.$box).attr('alt', values.alt);\n                                            trumbowyg.closeModal();\n                                        } else {\n                                            trumbowyg.addErrorOnModalField(\n                                                $('input[type=file]', $modal),\n                                                trumbowyg.lang.errInvalidImage\n                                            );\n                                        }\n                                    };\n\n                                    fReader.readAsDataURL(file);\n                                }\n                            );\n\n                            $('input[type=file]').on('change', function (e) {\n                                file = e.target.files[0];\n                            });\n                        }\n                    };\n\n                    trumbowyg.addBtnDef('base64', btnDef);\n                }\n            }\n        }\n    });\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/cleanpaste/trumbowyg.cleanpaste.js",
    "content": "/* ===========================================================\n * trumbowyg.cleanpaste.js v1.0\n * Font Clean paste plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Eric Radin\n */\n\n/**\n * This plugin will perform a \"cleaning\" on any paste, in particular\n * it will clean pasted content of microsoft word document tags and classes.\n */\n(function ($) {\n    'use strict';\n\n    function reverse(sentString) {\n        var theString = '';\n        for (var i = sentString.length - 1; i >= 0; i -= 1) {\n            theString += sentString.charAt(i);\n        }\n        return theString;\n    }\n\n    function checkValidTags(snippet) {\n        var theString = snippet;\n\n        // Replace uppercase element names with lowercase\n        theString = theString.replace(/<[^> ]*/g, function (match) {\n            return match.toLowerCase();\n        });\n\n        // Replace uppercase attribute names with lowercase\n        theString = theString.replace(/<[^>]*>/g, function (match) {\n            match = match.replace(/ [^=]+=/g, function (match2) {\n                return match2.toLowerCase();\n            });\n            return match;\n        });\n\n        // Put quotes around unquoted attributes\n        theString = theString.replace(/<[^>]*>/g, function (match) {\n            match = match.replace(/( [^=]+=)([^\"][^ >]*)/g, '$1\\\"$2\\\"');\n            return match;\n        });\n\n        return theString;\n    }\n\n    function cleanIt(htmlBefore, htmlAfter) {\n        var matchedHead = '';\n        var matchedTail = '';\n        var afterStart;\n        var afterFinish;\n        var newSnippet;\n\n        // we need to extract the inserted block\n        for (afterStart = 0; htmlAfter.charAt(afterStart) === htmlBefore.charAt(afterStart); afterStart += 1) {\n            matchedHead += htmlAfter.charAt(afterStart);\n        }\n\n        // If afterStart is inside a HTML tag, move to opening brace of tag\n        for (var i = afterStart; i >= 0; i -= 1) {\n            if (htmlBefore.charAt(i) === '<') {\n                afterStart = i;\n                matchedHead = htmlBefore.substring(0, afterStart);\n                break;\n            } else if (htmlBefore.charAt(i) === '>') {\n                break;\n            }\n        }\n\n        // now reverse string and work from the end in\n        htmlAfter = reverse(htmlAfter);\n        htmlBefore = reverse(htmlBefore);\n\n        // Find end of both strings that matches\n        for (afterFinish = 0; htmlAfter.charAt(afterFinish) === htmlBefore.charAt(afterFinish); afterFinish += 1) {\n            matchedTail += htmlAfter.charAt(afterFinish);\n        }\n\n        // If afterFinish is inside a HTML tag, move to closing brace of tag\n        for (var j = afterFinish; j >= 0; j -= 1) {\n            if (htmlBefore.charAt(j) === '>') {\n                afterFinish = j;\n                matchedTail = htmlBefore.substring(0, afterFinish);\n                break;\n            } else if (htmlBefore.charAt(j) === '<') {\n                break;\n            }\n        }\n\n        matchedTail = reverse(matchedTail);\n\n        // If there's no difference in pasted content\n        if (afterStart === (htmlAfter.length - afterFinish)) {\n            return false;\n        }\n\n        htmlAfter = reverse(htmlAfter);\n        newSnippet = htmlAfter.substring(afterStart, htmlAfter.length - afterFinish);\n\n        // first make sure all tags and attributes are made valid\n        newSnippet = checkValidTags(newSnippet);\n\n        // Replace opening bold tags with strong\n        newSnippet = newSnippet.replace(/<b(\\s+|>)/g, '<strong$1');\n        // Replace closing bold tags with closing strong\n        newSnippet = newSnippet.replace(/<\\/b(\\s+|>)/g, '</strong$1');\n\n        // Replace italic tags with em\n        newSnippet = newSnippet.replace(/<i(\\s+|>)/g, '<em$1');\n        // Replace closing italic tags with closing em\n        newSnippet = newSnippet.replace(/<\\/i(\\s+|>)/g, '</em$1');\n\n        // strip out comments -cgCraft\n        newSnippet = newSnippet.replace(/<!(?:--[\\s\\S]*?--\\s*)?>\\s*/g, '');\n\n        // strip out &nbsp; -cgCraft\n        newSnippet = newSnippet.replace(/&nbsp;/gi, ' ');\n        // strip out extra spaces -cgCraft\n        newSnippet = newSnippet.replace(/ <\\//gi, '</');\n\n        while (newSnippet.indexOf('  ') !== -1) {\n            var anArray = newSnippet.split('  ');\n            newSnippet = anArray.join(' ');\n        }\n\n        // strip &nbsp; -cgCraft\n        newSnippet = newSnippet.replace(/^\\s*|\\s*$/g, '');\n\n        // Strip out unaccepted attributes\n        newSnippet = newSnippet.replace(/<[^>]*>/g, function (match) {\n            match = match.replace(/ ([^=]+)=\"[^\"]*\"/g, function (match2, attributeName) {\n                if (['alt', 'href', 'src', 'title'].indexOf(attributeName) !== -1) {\n                    return match2;\n                }\n                return '';\n            });\n            return match;\n        });\n\n        // Final cleanout for MS Word crud\n        newSnippet = newSnippet.replace(/<\\?xml[^>]*>/g, '');\n        newSnippet = newSnippet.replace(/<[^ >]+:[^>]*>/g, '');\n        newSnippet = newSnippet.replace(/<\\/[^ >]+:[^>]*>/g, '');\n\n        // remove unwanted tags\n        newSnippet = newSnippet.replace(/<(div|span|style|meta|link){1}.*?>/gi, '');\n\n        htmlAfter = matchedHead + newSnippet + matchedTail;\n        return htmlAfter;\n    }\n\n    // clean editor\n    // this will clean the inserted contents\n    // it does a compare, before and after paste to determine the\n    // pasted contents\n    $.extend(true, $.trumbowyg, {\n        plugins: {\n            cleanPaste: {\n                init: function (trumbowyg) {\n                    trumbowyg.pasteHandlers.push(function () {\n                        try {\n                            var contentBefore = trumbowyg.$ed.html();\n                            setTimeout(function () {\n                                var contentAfter = trumbowyg.$ed.html();\n                                contentAfter = cleanIt(contentBefore, contentAfter);\n                                trumbowyg.$ed.html(contentAfter);\n                            }, 0);\n                        } catch (c) {\n                        }\n                    });\n                }\n            }\n        }\n    });\n})(jQuery);\n\n\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/colors/trumbowyg.colors.js",
    "content": "/* ===========================================================\n * trumbowyg.colors.js v1.2\n * Colors picker plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Alexandre Demode (Alex-D)\n *          Twitter : @AlexandreDemode\n *          Website : alex-d.fr\n */\n\n(function ($) {\n    'use strict';\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            // jshint camelcase:false\n            cs: {\n                foreColor: 'Barva textu',\n                backColor: 'Barva pozadí'\n            },\n            en: {\n                foreColor: 'Text color',\n                backColor: 'Background color'\n            },\n            fr: {\n                foreColor: 'Couleur du texte',\n                backColor: 'Couleur de fond'\n            },\n            sk: {\n                foreColor: 'Farba textu',\n                backColor: 'Farba pozadia'\n            },\n            zh_cn: {\n                foreColor: '文字颜色',\n                backColor: '背景颜色'\n            },\n            ru: {\n                foreColor: 'Цвет текста',\n                backColor: 'Цвет выделения текста'\n            },\n            ja: {\n                foreColor: '文字色',\n                backColor: '背景色'\n            }\n        }\n    });\n    // jshint camelcase:true\n\n\n    function hex(x) {\n        return ('0' + parseInt(x).toString(16)).slice(-2);\n    }\n\n    function colorToHex(rgb) {\n        if (rgb.search('rgb') === -1) {\n            return rgb.replace('#', '');\n        } else if (rgb === 'rgba(0, 0, 0, 0)') {\n            return 'transparent';\n        } else {\n            rgb = rgb.match(/^rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*(\\d+))?\\)$/);\n            return hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]);\n        }\n    }\n\n    function colorTagHandler(element, trumbowyg) {\n        var tags = [];\n\n        if(!element.style){\n            return tags;\n        }\n\n        // background color\n        if (element.style.backgroundColor !== '') {\n            var backColor = colorToHex(element.style.backgroundColor);\n            if (trumbowyg.o.plugins.colors.colorList.indexOf(backColor) >= 0) {\n                tags.push('backColor' + backColor);\n            } else {\n                tags.push('backColorFree');\n            }\n        }\n\n        // text color\n        var foreColor;\n        if (element.style.color !== '') {\n            foreColor = colorToHex(element.style.color);\n        } else if (element.hasAttribute('color')) {\n            foreColor = colorToHex(element.getAttribute('color'));\n        }\n        if (foreColor) {\n            if (trumbowyg.o.plugins.colors.colorList.indexOf(foreColor) >= 0) {\n                tags.push('foreColor' + foreColor);\n            } else {\n                tags.push('foreColorFree');\n            }\n        }\n\n        return tags;\n    }\n\n    var defaultOptions = {\n        colorList: ['ffffff', '000000', 'eeece1', '1f497d', '4f81bd', 'c0504d', '9bbb59', '8064a2', '4bacc6', 'f79646', 'ffff00', 'f2f2f2', '7f7f7f', 'ddd9c3', 'c6d9f0', 'dbe5f1', 'f2dcdb', 'ebf1dd', 'e5e0ec', 'dbeef3', 'fdeada', 'fff2ca', 'd8d8d8', '595959', 'c4bd97', '8db3e2', 'b8cce4', 'e5b9b7', 'd7e3bc', 'ccc1d9', 'b7dde8', 'fbd5b5', 'ffe694', 'bfbfbf', '3f3f3f', '938953', '548dd4', '95b3d7', 'd99694', 'c3d69b', 'b2a2c7', 'b7dde8', 'fac08f', 'f2c314', 'a5a5a5', '262626', '494429', '17365d', '366092', '953734', '76923c', '5f497a', '92cddc', 'e36c09', 'c09100', '7f7f7f', '0c0c0c', '1d1b10', '0f243e', '244061', '632423', '4f6128', '3f3151', '31859b', '974806', '7f6000']\n    };\n\n    // Add all colors in two dropdowns\n    $.extend(true, $.trumbowyg, {\n        plugins: {\n            color: {\n                init: function (trumbowyg) {\n                    trumbowyg.o.plugins.colors = trumbowyg.o.plugins.colors || defaultOptions;\n                    var foreColorBtnDef = {\n                            dropdown: buildDropdown('foreColor', trumbowyg)\n                        },\n                        backColorBtnDef = {\n                            dropdown: buildDropdown('backColor', trumbowyg)\n                        };\n\n                    trumbowyg.addBtnDef('foreColor', foreColorBtnDef);\n                    trumbowyg.addBtnDef('backColor', backColorBtnDef);\n                },\n                tagHandler: colorTagHandler\n            }\n        }\n    });\n\n    function buildDropdown(fn, trumbowyg) {\n        var dropdown = [];\n\n        $.each(trumbowyg.o.plugins.colors.colorList, function (i, color) {\n            var btn = fn + color,\n                btnDef = {\n                    fn: fn,\n                    forceCss: true,\n                    param: '#' + color,\n                    style: 'background-color: #' + color + ';'\n                };\n            trumbowyg.addBtnDef(btn, btnDef);\n            dropdown.push(btn);\n        });\n\n        var removeColorButtonName = fn + 'Remove',\n            removeColorBtnDef = {\n                fn: 'removeFormat',\n                param: fn,\n                style: 'background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAG0lEQVQIW2NkQAAfEJMRmwBYhoGBYQtMBYoAADziAp0jtJTgAAAAAElFTkSuQmCC);'\n            };\n        trumbowyg.addBtnDef(removeColorButtonName, removeColorBtnDef);\n        dropdown.push(removeColorButtonName);\n\n        // add free color btn\n        var freeColorButtonName = fn + 'Free',\n            freeColorBtnDef = {\n                fn: function () {\n                    trumbowyg.openModalInsert(trumbowyg.lang[fn],\n                        {\n                            color: {\n                                label: fn,\n                                value: '#FFFFFF'\n                            }\n                        },\n                        // callback\n                        function (values) {\n                            trumbowyg.execCmd(fn, values.color);\n                            return true;\n                        }\n                    );\n                },\n                text: '#',\n                // style adjust for displaying the text\n                style: 'text-indent: 0;line-height: 20px;padding: 0 5px;'\n            };\n        trumbowyg.addBtnDef(freeColorButtonName, freeColorBtnDef);\n        dropdown.push(freeColorButtonName);\n\n        return dropdown;\n    }\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/colors/ui/sass/trumbowyg.colors.scss",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor plugin\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n.trumbowyg-dropdown-foreColor,\n.trumbowyg-dropdown-backColor {\n    width: 276px;\n    padding: 7px 5px;\n\n    svg {\n        display: none !important;\n    }\n\n    button {\n        display: block;\n        position: relative;\n        float: left;\n        text-indent: -9999px;\n        height: 20px;\n        width: 20px;\n        border: 1px solid #333;\n        padding: 0;\n        margin: 2px;\n\n        &:hover,\n        &:focus {\n            &::after {\n                content: \" \";\n                display: block;\n                position: absolute;\n                top: -5px;\n                left: -5px;\n                height: 27px;\n                width: 27px;\n                background: inherit;\n                border: 1px solid #FFF;\n                box-shadow: #000 0 0 2px;\n                z-index: 10;\n            }\n        }\n    }\n}"
  },
  {
    "path": "pubnot/trumbowyg/plugins/colors/ui/trumbowyg.colors.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Trumbowyg plugin stylesheet\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n.trumbowyg-dropdown-foreColor,\n.trumbowyg-dropdown-backColor {\n  width: 276px;\n  padding: 7px 5px; }\n  .trumbowyg-dropdown-foreColor svg,\n  .trumbowyg-dropdown-backColor svg {\n    display: none !important; }\n  .trumbowyg-dropdown-foreColor button,\n  .trumbowyg-dropdown-backColor button {\n    display: block;\n    position: relative;\n    float: left;\n    text-indent: -9999px;\n    height: 20px;\n    width: 20px;\n    border: 1px solid #333;\n    padding: 0;\n    margin: 2px; }\n    .trumbowyg-dropdown-foreColor button:hover::after, .trumbowyg-dropdown-foreColor button:focus::after,\n    .trumbowyg-dropdown-backColor button:hover::after,\n    .trumbowyg-dropdown-backColor button:focus::after {\n      content: \" \";\n      display: block;\n      position: absolute;\n      top: -5px;\n      left: -5px;\n      height: 27px;\n      width: 27px;\n      background: inherit;\n      border: 1px solid #FFF;\n      box-shadow: #000 0 0 2px;\n      z-index: 10; }\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/emoji/trumbowyg.emoji.js",
    "content": "/* ===========================================================\n * trumbowyg.emoji.js v0.1\n * Emoji picker plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Nicolas Pion\n *          Twitter : @nicolas_pion\n */\n\n(function ($) {\n    'use strict';\n\n    var defaultOptions = {\n        emojiList: [\n            ':bowtie:',\n            ':smile:',\n            ':laughing:',\n            ':blush:',\n            ':smiley:',\n            ':relaxed:',\n            ':smirk:',\n            ':heart_eyes:',\n            ':kissing_heart:',\n            ':kissing_closed_eyes:',\n            ':flushed:',\n            ':relieved:',\n            ':satisfied:',\n            ':grin:',\n            ':wink:',\n            ':stuck_out_tongue_winking_eye:',\n            ':stuck_out_tongue_closed_eyes:',\n            ':grinning:',\n            ':kissing:',\n            ':kissing_smiling_eyes:',\n            ':stuck_out_tongue:',\n            ':sleeping:',\n            ':worried:',\n            ':frowning:',\n            ':anguished:',\n            ':open_mouth:',\n            ':grimacing:',\n            ':confused:',\n            ':hushed:',\n            ':expressionless:',\n            ':unamused:',\n            ':sweat_smile:',\n            ':sweat:',\n            ':disappointed_relieved:',\n            ':weary:',\n            ':pensive:',\n            ':disappointed:',\n            ':confounded:',\n            ':fearful:',\n            ':cold_sweat:',\n            ':persevere:',\n            ':cry:',\n            ':sob:',\n            ':joy:',\n            ':astonished:',\n            ':scream:',\n            ':neckbeard:',\n            ':tired_face:',\n            ':angry:',\n            ':rage:',\n            ':triumph:',\n            ':sleepy:',\n            ':yum:',\n            ':mask:',\n            ':sunglasses:',\n            ':dizzy_face:',\n            ':imp:',\n            ':smiling_imp:',\n            ':neutral_face:',\n            ':no_mouth:',\n            ':innocent:',\n            ':alien:',\n            ':yellow_heart:',\n            ':blue_heart:',\n            ':purple_heart:',\n            ':heart:',\n            ':green_heart:',\n            ':broken_heart:',\n            ':heartbeat:',\n            ':heartpulse:',\n            ':two_hearts:',\n            ':revolving_hearts:',\n            ':cupid:',\n            ':sparkling_heart:',\n            ':sparkles:',\n            ':star:',\n            ':star2:',\n            ':dizzy:',\n            ':boom:',\n            ':collision:',\n            ':anger:',\n            ':exclamation:',\n            ':question:',\n            ':grey_exclamation:',\n            ':grey_question:',\n            ':zzz:',\n            ':dash:',\n            ':sweat_drops:',\n            ':notes:',\n            ':musical_note:',\n            ':fire:',\n            ':hankey:',\n            ':poop:',\n            ':shit:',\n            ':+1:',\n            ':thumbsup:',\n            ':-1:',\n            ':thumbsdown:',\n            ':ok_hand:',\n            ':punch:',\n            ':facepunch:',\n            ':fist:',\n            ':v:',\n            ':wave:',\n            ':hand:',\n            ':raised_hand:',\n            ':open_hands:',\n            ':point_up:',\n            ':point_down:',\n            ':point_left:',\n            ':point_right:',\n            ':raised_hands:',\n            ':pray:',\n            ':point_up_2:',\n            ':clap:',\n            ':muscle:',\n            ':metal:',\n            ':fu:',\n            ':runner:',\n            ':running:',\n            ':couple:',\n            ':family:',\n            ':two_men_holding_hands:',\n            ':two_women_holding_hands:',\n            ':dancer:',\n            ':dancers:',\n            ':ok_woman:',\n            ':no_good:',\n            ':information_desk_person:',\n            ':raising_hand:',\n            ':bride_with_veil:',\n            ':person_with_pouting_face:',\n            ':person_frowning:',\n            ':bow:',\n            ':couplekiss:',\n            ':couple_with_heart:',\n            ':massage:',\n            ':haircut:',\n            ':nail_care:',\n            ':boy:',\n            ':girl:',\n            ':woman:',\n            ':man:',\n            ':baby:',\n            ':older_woman:',\n            ':older_man:',\n            ':person_with_blond_hair:',\n            ':man_with_gua_pi_mao:',\n            ':man_with_turban:',\n            ':construction_worker:',\n            ':cop:',\n            ':angel:',\n            ':princess:',\n            ':smiley_cat:',\n            ':smile_cat:',\n            ':heart_eyes_cat:',\n            ':kissing_cat:',\n            ':smirk_cat:',\n            ':scream_cat:',\n            ':crying_cat_face:',\n            ':joy_cat:',\n            ':pouting_cat:',\n            ':japanese_ogre:',\n            ':japanese_goblin:',\n            ':see_no_evil:',\n            ':hear_no_evil:',\n            ':speak_no_evil:',\n            ':guardsman:',\n            ':skull:',\n            ':feet:',\n            ':lips:',\n            ':kiss:',\n            ':droplet:',\n            ':ear:',\n            ':eyes:',\n            ':nose:',\n            ':tongue:',\n            ':love_letter:',\n            ':bust_in_silhouette:',\n            ':busts_in_silhouette:',\n            ':speech_balloon:',\n            ':thought_balloon:',\n            ':feelsgood:',\n            ':finnadie:',\n            ':goberserk:',\n            ':godmode:',\n            ':hurtrealbad:',\n            ':rage1:',\n            ':rage2:',\n            ':rage3:',\n            ':rage4:',\n            ':suspect:',\n            ':trollface:',\n            ':sunny:',\n            ':umbrella:',\n            ':cloud:',\n            ':snowflake:',\n            ':snowman:',\n            ':zap:',\n            ':cyclone:',\n            ':foggy:',\n            ':ocean:',\n            ':cat:',\n            ':dog:',\n            ':mouse:',\n            ':hamster:',\n            ':rabbit:',\n            ':wolf:',\n            ':frog:',\n            ':tiger:',\n            ':koala:',\n            ':bear:',\n            ':pig:',\n            ':pig_nose:',\n            ':cow:',\n            ':boar:',\n            ':monkey_face:',\n            ':monkey:',\n            ':horse:',\n            ':racehorse:',\n            ':camel:',\n            ':sheep:',\n            ':elephant:',\n            ':panda_face:',\n            ':snake:',\n            ':bird:',\n            ':baby_chick:',\n            ':hatched_chick:',\n            ':hatching_chick:',\n            ':chicken:',\n            ':penguin:',\n            ':turtle:',\n            ':bug:',\n            ':honeybee:',\n            ':ant:',\n            ':beetle:',\n            ':snail:',\n            ':octopus:',\n            ':tropical_fish:',\n            ':fish:',\n            ':whale:',\n            ':whale2:',\n            ':dolphin:',\n            ':cow2:',\n            ':ram:',\n            ':rat:',\n            ':water_buffalo:',\n            ':tiger2:',\n            ':rabbit2:',\n            ':dragon:',\n            ':goat:',\n            ':rooster:',\n            ':dog2:',\n            ':pig2:',\n            ':mouse2:',\n            ':ox:',\n            ':dragon_face:',\n            ':blowfish:',\n            ':crocodile:',\n            ':dromedary_camel:',\n            ':leopard:',\n            ':cat2:',\n            ':poodle:',\n            ':paw_prints:',\n            ':bouquet:',\n            ':cherry_blossom:',\n            ':tulip:',\n            ':four_leaf_clover:',\n            ':rose:',\n            ':sunflower:',\n            ':hibiscus:',\n            ':maple_leaf:',\n            ':leaves:',\n            ':fallen_leaf:',\n            ':herb:',\n            ':mushroom:',\n            ':cactus:',\n            ':palm_tree:',\n            ':evergreen_tree:',\n            ':deciduous_tree:',\n            ':chestnut:',\n            ':seedling:',\n            ':blossom:',\n            ':ear_of_rice:',\n            ':shell:',\n            ':globe_with_meridians:',\n            ':sun_with_face:',\n            ':full_moon_with_face:',\n            ':new_moon_with_face:',\n            ':new_moon:',\n            ':waxing_crescent_moon:',\n            ':first_quarter_moon:',\n            ':waxing_gibbous_moon:',\n            ':full_moon:',\n            ':waning_gibbous_moon:',\n            ':last_quarter_moon:',\n            ':waning_crescent_moon:',\n            ':last_quarter_moon_with_face:',\n            ':first_quarter_moon_with_face:',\n            ':crescent_moon:',\n            ':earth_africa:',\n            ':earth_americas:',\n            ':earth_asia:',\n            ':volcano:',\n            ':milky_way:',\n            ':partly_sunny:',\n            ':octocat:',\n            ':squirrel:',\n            ':bamboo:',\n            ':gift_heart:',\n            ':dolls:',\n            ':school_satchel:',\n            ':mortar_board:',\n            ':flags:',\n            ':fireworks:',\n            ':sparkler:',\n            ':wind_chime:',\n            ':rice_scene:',\n            ':jack_o_lantern:',\n            ':ghost:',\n            ':santa:',\n            ':christmas_tree:',\n            ':gift:',\n            ':bell:',\n            ':no_bell:',\n            ':tanabata_tree:',\n            ':tada:',\n            ':confetti_ball:',\n            ':balloon:',\n            ':crystal_ball:',\n            ':cd:',\n            ':dvd:',\n            ':floppy_disk:',\n            ':camera:',\n            ':video_camera:',\n            ':movie_camera:',\n            ':computer:',\n            ':tv:',\n            ':iphone:',\n            ':phone:',\n            ':telephone:',\n            ':telephone_receiver:',\n            ':pager:',\n            ':fax:',\n            ':minidisc:',\n            ':vhs:',\n            ':sound:',\n            ':speaker:',\n            ':mute:',\n            ':loudspeaker:',\n            ':mega:',\n            ':hourglass:',\n            ':hourglass_flowing_sand:',\n            ':alarm_clock:',\n            ':watch:',\n            ':radio:',\n            ':satellite:',\n            ':loop:',\n            ':mag:',\n            ':mag_right:',\n            ':unlock:',\n            ':lock:',\n            ':lock_with_ink_pen:',\n            ':closed_lock_with_key:',\n            ':key:',\n            ':bulb:',\n            ':flashlight:',\n            ':high_brightness:',\n            ':low_brightness:',\n            ':electric_plug:',\n            ':battery:',\n            ':calling:',\n            ':email:',\n            ':mailbox:',\n            ':postbox:',\n            ':bath:',\n            ':bathtub:',\n            ':shower:',\n            ':toilet:',\n            ':wrench:',\n            ':nut_and_bolt:',\n            ':hammer:',\n            ':seat:',\n            ':moneybag:',\n            ':yen:',\n            ':dollar:',\n            ':pound:',\n            ':euro:',\n            ':credit_card:',\n            ':money_with_wings:',\n            ':e-mail:',\n            ':inbox_tray:',\n            ':outbox_tray:',\n            ':envelope:',\n            ':incoming_envelope:',\n            ':postal_horn:',\n            ':mailbox_closed:',\n            ':mailbox_with_mail:',\n            ':mailbox_with_no_mail:',\n            ':package:',\n            ':door:',\n            ':smoking:',\n            ':bomb:',\n            ':gun:',\n            ':hocho:',\n            ':pill:',\n            ':syringe:',\n            ':page_facing_up:',\n            ':page_with_curl:',\n            ':bookmark_tabs:',\n            ':bar_chart:',\n            ':chart_with_upwards_trend:',\n            ':chart_with_downwards_trend:',\n            ':scroll:',\n            ':clipboard:',\n            ':calendar:',\n            ':date:',\n            ':card_index:',\n            ':file_folder:',\n            ':open_file_folder:',\n            ':scissors:',\n            ':pushpin:',\n            ':paperclip:',\n            ':black_nib:',\n            ':pencil2:',\n            ':straight_ruler:',\n            ':triangular_ruler:',\n            ':closed_book:',\n            ':green_book:',\n            ':blue_book:',\n            ':orange_book:',\n            ':notebook:',\n            ':notebook_with_decorative_cover:',\n            ':ledger:',\n            ':books:',\n            ':bookmark:',\n            ':name_badge:',\n            ':microscope:',\n            ':telescope:',\n            ':newspaper:',\n            ':football:',\n            ':basketball:',\n            ':soccer:',\n            ':baseball:',\n            ':tennis:',\n            ':8ball:',\n            ':rugby_football:',\n            ':bowling:',\n            ':golf:',\n            ':mountain_bicyclist:',\n            ':bicyclist:',\n            ':horse_racing:',\n            ':snowboarder:',\n            ':swimmer:',\n            ':surfer:',\n            ':ski:',\n            ':spades:',\n            ':hearts:',\n            ':clubs:',\n            ':diamonds:',\n            ':gem:',\n            ':ring:',\n            ':trophy:',\n            ':musical_score:',\n            ':musical_keyboard:',\n            ':violin:',\n            ':space_invader:',\n            ':video_game:',\n            ':black_joker:',\n            ':flower_playing_cards:',\n            ':game_die:',\n            ':dart:',\n            ':mahjong:',\n            ':clapper:',\n            ':memo:',\n            ':pencil:',\n            ':book:',\n            ':art:',\n            ':microphone:',\n            ':headphones:',\n            ':trumpet:',\n            ':saxophone:',\n            ':guitar:',\n            ':shoe:',\n            ':sandal:',\n            ':high_heel:',\n            ':lipstick:',\n            ':boot:',\n            ':shirt:',\n            ':tshirt:',\n            ':necktie:',\n            ':womans_clothes:',\n            ':dress:',\n            ':running_shirt_with_sash:',\n            ':jeans:',\n            ':kimono:',\n            ':bikini:',\n            ':ribbon:',\n            ':tophat:',\n            ':crown:',\n            ':womans_hat:',\n            ':mans_shoe:',\n            ':closed_umbrella:',\n            ':briefcase:',\n            ':handbag:',\n            ':pouch:',\n            ':purse:',\n            ':eyeglasses:',\n            ':fishing_pole_and_fish:',\n            ':coffee:',\n            ':tea:',\n            ':sake:',\n            ':baby_bottle:',\n            ':beer:',\n            ':beers:',\n            ':cocktail:',\n            ':tropical_drink:',\n            ':wine_glass:',\n            ':fork_and_knife:',\n            ':pizza:',\n            ':hamburger:',\n            ':fries:',\n            ':poultry_leg:',\n            ':meat_on_bone:',\n            ':spaghetti:',\n            ':curry:',\n            ':fried_shrimp:',\n            ':bento:',\n            ':sushi:',\n            ':fish_cake:',\n            ':rice_ball:',\n            ':rice_cracker:',\n            ':rice:',\n            ':ramen:',\n            ':stew:',\n            ':oden:',\n            ':dango:',\n            ':egg:',\n            ':bread:',\n            ':doughnut:',\n            ':custard:',\n            ':icecream:',\n            ':ice_cream:',\n            ':shaved_ice:',\n            ':birthday:',\n            ':cake:',\n            ':cookie:',\n            ':chocolate_bar:',\n            ':candy:',\n            ':lollipop:',\n            ':honey_pot:',\n            ':apple:',\n            ':green_apple:',\n            ':tangerine:',\n            ':lemon:',\n            ':cherries:',\n            ':grapes:',\n            ':watermelon:',\n            ':strawberry:',\n            ':peach:',\n            ':melon:',\n            ':banana:',\n            ':pear:',\n            ':pineapple:',\n            ':sweet_potato:',\n            ':eggplant:',\n            ':tomato:',\n            ':corn:',\n            ':house:',\n            ':house_with_garden:',\n            ':school:',\n            ':office:',\n            ':post_office:',\n            ':hospital:',\n            ':bank:',\n            ':convenience_store:',\n            ':love_hotel:',\n            ':hotel:',\n            ':wedding:',\n            ':church:',\n            ':department_store:',\n            ':european_post_office:',\n            ':city_sunrise:',\n            ':city_sunset:',\n            ':japanese_castle:',\n            ':european_castle:',\n            ':tent:',\n            ':factory:',\n            ':tokyo_tower:',\n            ':japan:',\n            ':mount_fuji:',\n            ':sunrise_over_mountains:',\n            ':sunrise:',\n            ':stars:',\n            ':statue_of_liberty:',\n            ':bridge_at_night:',\n            ':carousel_horse:',\n            ':rainbow:',\n            ':ferris_wheel:',\n            ':fountain:',\n            ':roller_coaster:',\n            ':ship:',\n            ':speedboat:',\n            ':boat:',\n            ':sailboat:',\n            ':rowboat:',\n            ':anchor:',\n            ':rocket:',\n            ':airplane:',\n            ':helicopter:',\n            ':steam_locomotive:',\n            ':tram:',\n            ':mountain_railway:',\n            ':bike:',\n            ':aerial_tramway:',\n            ':suspension_railway:',\n            ':mountain_cableway:',\n            ':tractor:',\n            ':blue_car:',\n            ':oncoming_automobile:',\n            ':car:',\n            ':red_car:',\n            ':taxi:',\n            ':oncoming_taxi:',\n            ':articulated_lorry:',\n            ':bus:',\n            ':oncoming_bus:',\n            ':rotating_light:',\n            ':police_car:',\n            ':oncoming_police_car:',\n            ':fire_engine:',\n            ':ambulance:',\n            ':minibus:',\n            ':truck:',\n            ':train:',\n            ':station:',\n            ':train2:',\n            ':bullettrain_front:',\n            ':bullettrain_side:',\n            ':light_rail:',\n            ':monorail:',\n            ':railway_car:',\n            ':trolleybus:',\n            ':ticket:',\n            ':fuelpump:',\n            ':vertical_traffic_light:',\n            ':traffic_light:',\n            ':warning:',\n            ':construction:',\n            ':beginner:',\n            ':atm:',\n            ':slot_machine:',\n            ':busstop:',\n            ':barber:',\n            ':hotsprings:',\n            ':checkered_flag:',\n            ':crossed_flags:',\n            ':izakaya_lantern:',\n            ':moyai:',\n            ':circus_tent:',\n            ':performing_arts:',\n            ':round_pushpin:',\n            ':triangular_flag_on_post:',\n            ':jp:',\n            ':kr:',\n            ':cn:',\n            ':us:',\n            ':fr:',\n            ':es:',\n            ':it:',\n            ':ru:',\n            ':gb:',\n            ':uk:',\n            ':de:',\n            ':one:',\n            ':two:',\n            ':three:',\n            ':four:',\n            ':five:',\n            ':six:',\n            ':seven:',\n            ':eight:',\n            ':nine:',\n            ':keycap_ten:',\n            ':1234:',\n            ':zero:',\n            ':hash:',\n            ':symbols:',\n            ':arrow_backward:',\n            ':arrow_down:',\n            ':arrow_forward:',\n            ':arrow_left:',\n            ':capital_abcd:',\n            ':abcd:',\n            ':abc:',\n            ':arrow_lower_left:',\n            ':arrow_lower_right:',\n            ':arrow_right:',\n            ':arrow_up:',\n            ':arrow_upper_left:',\n            ':arrow_upper_right:',\n            ':arrow_double_down:',\n            ':arrow_double_up:',\n            ':arrow_down_small:',\n            ':arrow_heading_down:',\n            ':arrow_heading_up:',\n            ':leftwards_arrow_with_hook:',\n            ':arrow_right_hook:',\n            ':left_right_arrow:',\n            ':arrow_up_down:',\n            ':arrow_up_small:',\n            ':arrows_clockwise:',\n            ':arrows_counterclockwise:',\n            ':rewind:',\n            ':fast_forward:',\n            ':information_source:',\n            ':ok:',\n            ':twisted_rightwards_arrows:',\n            ':repeat:',\n            ':repeat_one:',\n            ':new:',\n            ':top:',\n            ':up:',\n            ':cool:',\n            ':free:',\n            ':ng:',\n            ':cinema:',\n            ':koko:',\n            ':signal_strength:',\n            ':u5272:',\n            ':u5408:',\n            ':u55b6:',\n            ':u6307:',\n            ':u6708:',\n            ':u6709:',\n            ':u6e80:',\n            ':u7121:',\n            ':u7533:',\n            ':u7a7a:',\n            ':u7981:',\n            ':sa:',\n            ':restroom:',\n            ':mens:',\n            ':womens:',\n            ':baby_symbol:',\n            ':no_smoking:',\n            ':parking:',\n            ':wheelchair:',\n            ':metro:',\n            ':baggage_claim:',\n            ':accept:',\n            ':wc:',\n            ':potable_water:',\n            ':put_litter_in_its_place:',\n            ':secret:',\n            ':congratulations:',\n            ':m:',\n            ':passport_control:',\n            ':left_luggage:',\n            ':customs:',\n            ':ideograph_advantage:',\n            ':cl:',\n            ':sos:',\n            ':id:',\n            ':no_entry_sign:',\n            ':underage:',\n            ':no_mobile_phones:',\n            ':do_not_litter:',\n            ':non-potable_water:',\n            ':no_bicycles:',\n            ':no_pedestrians:',\n            ':children_crossing:',\n            ':no_entry:',\n            ':eight_spoked_asterisk:',\n            ':sparkle:',\n            ':eight_pointed_black_star:',\n            ':heart_decoration:',\n            ':vs:',\n            ':vibration_mode:',\n            ':mobile_phone_off:',\n            ':chart:',\n            ':currency_exchange:',\n            ':aries:',\n            ':taurus:',\n            ':gemini:',\n            ':cancer:',\n            ':leo:',\n            ':virgo:',\n            ':libra:',\n            ':scorpius:',\n            ':sagittarius:',\n            ':capricorn:',\n            ':aquarius:',\n            ':pisces:',\n            ':ophiuchus:',\n            ':six_pointed_star:',\n            ':negative_squared_cross_mark:',\n            ':a:',\n            ':b:',\n            ':ab:',\n            ':o2:',\n            ':diamond_shape_with_a_dot_inside:',\n            ':recycle:',\n            ':end:',\n            ':back:',\n            ':on:',\n            ':soon:',\n            ':clock1:',\n            ':clock130:',\n            ':clock10:',\n            ':clock1030:',\n            ':clock11:',\n            ':clock1130:',\n            ':clock12:',\n            ':clock1230:',\n            ':clock2:',\n            ':clock230:',\n            ':clock3:',\n            ':clock330:',\n            ':clock4:',\n            ':clock430:',\n            ':clock5:',\n            ':clock530:',\n            ':clock6:',\n            ':clock630:',\n            ':clock7:',\n            ':clock730:',\n            ':clock8:',\n            ':clock830:',\n            ':clock9:',\n            ':clock930:',\n            ':heavy_dollar_sign:',\n            ':copyright:',\n            ':registered:',\n            ':tm:',\n            ':x:',\n            ':heavy_exclamation_mark:',\n            ':bangbang:',\n            ':interrobang:',\n            ':o:',\n            ':heavy_multiplication_x:',\n            ':heavy_plus_sign:',\n            ':heavy_minus_sign:',\n            ':heavy_division_sign:',\n            ':white_flower:',\n            ':100:',\n            ':heavy_check_mark:',\n            ':ballot_box_with_check:',\n            ':radio_button:',\n            ':link:',\n            ':curly_loop:',\n            ':wavy_dash:',\n            ':part_alternation_mark:',\n            ':trident:',\n            ':black_small_square:',\n            ':white_small_square:',\n            ':black_medium_small_square:',\n            ':white_medium_small_square:',\n            ':black_medium_square:',\n            ':white_medium_square:',\n            ':white_large_square:',\n            ':white_check_mark:',\n            ':black_square_button:',\n            ':white_square_button:',\n            ':black_circle:',\n            ':white_circle:',\n            ':red_circle:',\n            ':large_blue_circle:',\n            ':large_blue_diamond:',\n            ':large_orange_diamond:',\n            ':small_blue_diamond:',\n            ':small_orange_diamond:',\n            ':small_red_triangle:',\n            ':small_red_triangle_down:',\n            ':shipit:'\n        ]\n    };\n\n    // Add all emoji in a dropdown\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            // jshint camelcase:false\n            en: {\n                emoji: 'Add an emoji'\n            },\n            fr: {\n                emoji: 'Ajouter un emoji'\n            },\n            zh_cn: {\n                emoji: '添加表情'\n            },\n            ru: {\n                emoji: 'Вставить emoji'\n            },\n            ja: {\n                emoji: '絵文字の挿入'\n            }\n        },\n        // jshint camelcase:true\n        plugins: {\n            emoji: {\n                init: function (trumbowyg) {\n                    trumbowyg.o.plugins.emoji = trumbowyg.o.plugins.emoji || defaultOptions;\n                    var emojiBtnDef = {\n                        dropdown: buildDropdown(trumbowyg)\n                    };\n                    trumbowyg.addBtnDef('emoji', emojiBtnDef);\n                }\n            }\n        }\n    });\n\n    function buildDropdown(trumbowyg) {\n        var dropdown = [];\n\n        $.each(trumbowyg.o.plugins.emoji.emojiList, function (i, emoji) {\n            if ($.isArray(emoji)) { // Custom emoji behaviour\n                var emojiCode = emoji[0],\n                    emojiUrl = emoji[1],\n                    emojiHtml = '<img src=\"' + emojiUrl + '\" alt=\"' + emojiCode + '\">',\n                    customEmojiBtnName = 'emoji-' + emojiCode.replace(/:/g, ''),\n                    customEmojiBtnDef = {\n                        hasIcon: false,\n                        text: emojiHtml,\n                        fn: function () {\n                            trumbowyg.execCmd('insertImage', emojiUrl, false, true);\n                            return true;\n                        }\n                    };\n\n                trumbowyg.addBtnDef(customEmojiBtnName, customEmojiBtnDef);\n                dropdown.push(customEmojiBtnName);\n            } else { // Default behaviour\n                var btn = emoji.replace(/:/g, ''),\n                    defaultEmojiBtnName = 'emoji-' + btn,\n                    defaultEmojiBtnDef = {\n                        text: emoji,\n                        fn: function () {\n                            trumbowyg.execCmd('insertText', emoji);\n                            return true;\n                        }\n                    };\n\n                trumbowyg.addBtnDef(defaultEmojiBtnName, defaultEmojiBtnDef);\n                dropdown.push(defaultEmojiBtnName);\n            }\n        });\n\n        return dropdown;\n    }\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/emoji/ui/sass/trumbowyg.emoji.scss",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor plugin\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n.trumbowyg-dropdown-emoji {\n    width: 265px;\n    padding: 7px 0 7px 5px;\n    height: 200px;\n    overflow-y: scroll;\n    overflow-x: hidden;\n}\n\n.trumbowyg-dropdown-emoji svg {\n    display: none !important;\n}\n\n.trumbowyg-dropdown-emoji button {\n    display: block;\n    position: relative;\n    float: left;\n    height: 26px;\n    width: 26px;\n    padding: 0;\n    margin: 2px;\n    line-height: 24px;\n    text-align: center;\n\n    &:hover,\n    &:focus {\n        &::after {\n            display: block;\n            position: absolute;\n            top: -5px;\n            left: -5px;\n            height: 27px;\n            width: 27px;\n            background: inherit;\n            box-shadow: #000 0 0 2px;\n            z-index: 10;\n            background-color: transparent;\n        }\n    }\n}\n\n.trumbowyg .emoji {\n    width: 22px;\n    height: 22px;\n    display: inline-block;\n}\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/emoji/ui/trumbowyg.emoji.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Trumbowyg plugin stylesheet\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n.trumbowyg-dropdown-emoji {\n  width: 265px;\n  padding: 7px 0 7px 5px;\n  height: 200px;\n  overflow-y: scroll;\n  overflow-x: hidden; }\n\n.trumbowyg-dropdown-emoji svg {\n  display: none !important; }\n\n.trumbowyg-dropdown-emoji button {\n  display: block;\n  position: relative;\n  float: left;\n  height: 26px;\n  width: 26px;\n  padding: 0;\n  margin: 2px;\n  line-height: 24px;\n  text-align: center; }\n  .trumbowyg-dropdown-emoji button:hover::after, .trumbowyg-dropdown-emoji button:focus::after {\n    display: block;\n    position: absolute;\n    top: -5px;\n    left: -5px;\n    height: 27px;\n    width: 27px;\n    background: inherit;\n    box-shadow: #000 0 0 2px;\n    z-index: 10;\n    background-color: transparent; }\n\n.trumbowyg .emoji {\n  width: 22px;\n  height: 22px;\n  display: inline-block; }\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/insertaudio/trumbowyg.insertaudio.js",
    "content": "/*/* ===========================================================\n * trumbowyg.insertaudio.js v1.0\n * InsertAudio plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Adam Hess (AdamHess)\n */\n\n(function ($) {\n    'use strict';\n\n    var insertAudioOptions = {\n        src: {\n            label: 'URL',\n            required: true\n        },\n        autoplay: {\n            label: 'AutoPlay',\n            required: false,\n            type: 'checkbox'\n        },\n        muted: {\n            label: 'Muted',\n            required: false,\n            type: 'checkbox'\n        },\n        preload: {\n            label: 'preload options',\n            required: false\n        }\n    };\n\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            en: {\n                insertAudio: 'Insert Audio'\n            },\n            ru: {\n                insertAudio: 'Вставить аудио'\n            },\n            ja: {\n                insertAudio: '音声の挿入'\n            }\n        },\n        plugins: {\n            insertAudio: {\n                init: function (trumbowyg) {\n                    var btnDef = {\n                        fn: function () {\n                            var insertAudioCallback = function (v) {\n                                // controls should always be show otherwise the audio will\n                                // be invisible defeating the point of a wysiwyg\n                                var html = '<audio controls';\n                                if (v.src) {\n                                    html += ' src=\\'' + v.src + '\\'';\n                                }\n                                if (v.autoplay) {\n                                    html += ' autoplay';\n                                }\n                                if (v.muted) {\n                                    html += ' muted';\n                                }\n                                if (v.preload) {\n                                    html += ' preload=\\'' + v + '\\'';\n                                }\n                                html += '></audio>';\n                                var node = $(html)[0];\n                                trumbowyg.range.deleteContents();\n                                trumbowyg.range.insertNode(node);\n                                return true;\n                            };\n\n                            trumbowyg.openModalInsert(trumbowyg.lang.insertAudio, insertAudioOptions, insertAudioCallback);\n                        }\n                    };\n\n                    trumbowyg.addBtnDef('insertAudio', btnDef);\n                }\n            }\n        }\n    });\n})(jQuery);"
  },
  {
    "path": "pubnot/trumbowyg/plugins/noembed/trumbowyg.noembed.js",
    "content": "/* ===========================================================\n * trumbowyg.noembed.js v1.0\n * noEmbed plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Jake Johns (jakejohns)\n */\n\n(function ($) {\n    'use strict';\n\n    var defaultOptions = {\n        proxy: 'https://noembed.com/embed?nowrap=on',\n        urlFiled: 'url',\n        data: [],\n        success: undefined,\n        error: undefined\n    };\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            en: {\n                noembed: 'Noembed',\n                noembedError: 'Error'\n            },\n            sk: {\n                noembedError: 'Chyba'\n            },\n            fr: {\n                noembedError: 'Erreur'\n            },\n            cs: {\n                noembedError: 'Chyba'\n            },\n            ru: {\n                noembedError: 'Ошибка'\n            },\n            ja: {\n                noembedError: 'エラー'\n            }\n        },\n\n        plugins: {\n            noembed: {\n                init: function (trumbowyg) {\n                    trumbowyg.o.plugins.noembed = $.extend(true, {}, defaultOptions, trumbowyg.o.plugins.noembed || {});\n\n                    var btnDef = {\n                        fn: function () {\n                            var $modal = trumbowyg.openModalInsert(\n                                // Title\n                                trumbowyg.lang.noembed,\n\n                                // Fields\n                                {\n                                    url: {\n                                        label: 'URL',\n                                        required: true\n                                    }\n                                },\n\n                                // Callback\n                                function (data) {\n                                    $.ajax({\n                                        url: trumbowyg.o.plugins.noembed.proxy,\n                                        type: 'GET',\n                                        data: data,\n                                        cache: false,\n                                        dataType: 'json',\n\n                                        success: trumbowyg.o.plugins.noembed.success || function (data) {\n                                            if (data.html) {\n                                                trumbowyg.execCmd('insertHTML', data.html);\n                                                setTimeout(function () {\n                                                    trumbowyg.closeModal();\n                                                }, 250);\n                                            } else {\n                                                trumbowyg.addErrorOnModalField(\n                                                    $('input[type=text]', $modal),\n                                                    data.error\n                                                );\n                                            }\n                                        },\n                                        error: trumbowyg.o.plugins.noembed.error || function () {\n                                            trumbowyg.addErrorOnModalField(\n                                                $('input[type=text]', $modal),\n                                                trumbowyg.lang.noembedError\n                                            );\n                                        }\n                                    });\n                                }\n                            );\n                        }\n                    };\n\n                    trumbowyg.addBtnDef('noembed', btnDef);\n                }\n            }\n        }\n    });\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/pasteimage/trumbowyg.pasteimage.js",
    "content": "/* ===========================================================\n * trumbowyg.pasteimage.js v1.0\n * Basic base64 paste plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Alexandre Demode (Alex-D)\n *          Twitter : @AlexandreDemode\n *          Website : alex-d.fr\n */\n\n(function ($) {\n    'use strict';\n\n    $.extend(true, $.trumbowyg, {\n        plugins: {\n            pasteImage: {\n                init: function (trumbowyg) {\n                    trumbowyg.pasteHandlers.push(function (pasteEvent) {\n                        try {\n                            var items = (pasteEvent.originalEvent || pasteEvent).clipboardData.items,\n                                reader;\n\n                            for (var i = items.length -1; i >= 0; i += 1) {\n                                if (items[i].type.match(/^image\\//)) {\n                                    reader = new FileReader();\n                                    /* jshint -W083 */\n                                    reader.onloadend = function (event) {\n                                        trumbowyg.execCmd('insertImage', event.target.result, undefined, true);\n                                    };\n                                    /* jshint +W083 */\n                                    reader.readAsDataURL(items[i].getAsFile());\n                                }\n                            }\n                        } catch (c) {\n                        }\n                    });\n                }\n            }\n        }\n    });\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/preformatted/trumbowyg.preformatted.js",
    "content": "/* ===========================================================\n * trumbowyg.preformatted.js v1.0\n * Preformatted plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Casella Edoardo (Civile)\n */\n\n\n(function ($) {\n    'use strict';\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            // jshint camelcase:false\n            en: {\n                preformatted: 'Code sample <pre>'\n            },\n            fr: {\n                preformatted: 'Exemple de code'\n            },\n            it: {\n                preformatted: 'Codice <pre>'\n            },\n            zh_cn: {\n                preformatted: '代码示例 <pre>'\n            },\n            ru: {\n                preformatted: 'Пример кода <pre>'\n            },\n            ja: {\n                preformatted: 'コードサンプル <pre>'\n            }\n        },\n        // jshint camelcase:true\n\n        plugins: {\n            preformatted: {\n                init: function (trumbowyg) {\n                    var btnDef = {\n                        fn: function () {\n                            trumbowyg.saveRange();\n                            var text = trumbowyg.getRangeText();\n                            if (text.replace(/\\s/g, '') !== '') {\n                                try {\n                                    var curtag = getSelectionParentElement().tagName.toLowerCase();\n                                    if (curtag === 'code' || curtag === 'pre') {\n                                        return unwrapCode();\n                                    }\n                                    else {\n                                        trumbowyg.execCmd('insertHTML', '<pre><code>' + strip(text) + '</code></pre>');\n                                    }\n                                } catch (e) {\n                                }\n                            }\n                        },\n                        tag: 'pre'\n                    };\n\n                    trumbowyg.addBtnDef('preformatted', btnDef);\n                }\n            }\n        }\n    });\n\n    /*\n     * GetSelectionParentElement\n     */\n    function getSelectionParentElement() {\n        var parentEl = null,\n            selection;\n        if (window.getSelection) {\n            selection = window.getSelection();\n            if (selection.rangeCount) {\n                parentEl = selection.getRangeAt(0).commonAncestorContainer;\n                if (parentEl.nodeType !== 1) {\n                    parentEl = parentEl.parentNode;\n                }\n            }\n        } else if ((selection = document.selection) && selection.type !== 'Control') {\n            parentEl = selection.createRange().parentElement();\n        }\n        return parentEl;\n    }\n\n    /*\n     * Strip\n     * returns a text without HTML tags\n     */\n    function strip(html) {\n        var tmp = document.createElement('DIV');\n        tmp.innerHTML = html;\n        return tmp.textContent || tmp.innerText || '';\n    }\n\n    /*\n     * UnwrapCode\n     * ADD/FIX: to improve, works but can be better\n     * \"paranoic\" solution\n     */\n    function unwrapCode() {\n        var container = null;\n        if (document.selection) { //for IE\n            container = document.selection.createRange().parentElement();\n        } else {\n            var select = window.getSelection();\n            if (select.rangeCount > 0) {\n                container = select.getRangeAt(0).startContainer.parentNode;\n            }\n        }\n        //'paranoic' unwrap\n        var ispre = $(container).contents().closest('pre').length;\n        var iscode = $(container).contents().closest('code').length;\n        if (ispre && iscode) {\n            $(container).contents().unwrap('code').unwrap('pre');\n        } else if (ispre) {\n            $(container).contents().unwrap('pre');\n        } else if (iscode) {\n            $(container).contents().unwrap('code');\n        }\n    }\n\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/plugins/table/trumbowyg.table.js",
    "content": "/* ===========================================================\n * trumbowyg.table.js v1.2\n * Table plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Lawrence Meckan\n *          Twitter : @absalomedia\n *          Website : absalom.biz\n */\n\n(function ($) {\n    'use strict';\n\n    var defaultOptions = {\n        rows: 0,\n        columns: 0,\n        styler: ''\n    };\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            en: {\n                table: 'Insert table',\n                tableAddRow: 'Add rows',\n                tableAddColumn: 'Add columns',\n                rows: 'Rows',\n                columns: 'Columns',\n                styler: 'Table class',\n                error: 'Error'\n            },\n            sk: {\n                table: 'Vytvoriť tabuľky',\n                tableAddRow: 'Pridať riadok',\n                tableAddColumn: 'Pridať stĺpec',\n                rows: 'Riadky',\n                columns: 'Stĺpce',\n                styler: 'Tabuľku triedy',\n                error: 'Chyba'\n            },\n            fr: {\n                table: 'Insérer un tableau',\n                tableAddRow: 'Ajouter des lignes',\n                tableAddColumn: 'Ajouter des colonnes',\n                rows: 'Lignes',\n                columns: 'Colonnes',\n                styler: 'Classes CSS sur la table',\n                error: 'Erreur'\n            },\n            cs: {\n                table: 'Vytvořit příkaz Table',\n                tableAddRow: 'Přidat řádek',\n                tableAddColumn: 'Přidat sloupec',\n                rows: 'Řádky',\n                columns: 'Sloupce',\n                styler: 'Tabulku třída',\n                error: 'Chyba'\n            },\n            ru: {\n                table: 'Вставить таблицу',\n                tableAddRow: 'Добавить строки',\n                tableAddColumn: 'Добавить столбцы',\n                rows: 'Строки',\n                columns: 'Столбцы',\n                styler: 'Имя CSS класса для таблицы',\n                error: 'Ошибка'\n            },\n            ja: {\n                table: '表の挿入',\n                tableAddRow: '行の追加',\n                tableAddColumn: '列の追加',\n                rows: '行',\n                columns: '列',\n                styler: '表のクラス',\n                error: 'エラー'\n            }\n        },\n\n        plugins: {\n            table: {\n                init: function (trumbowyg) {\n                    trumbowyg.o.plugins.table = $.extend(true, {}, defaultOptions, trumbowyg.o.plugins.table || {});\n\n                    var tableBuild = {\n                        fn: function () {\n                            trumbowyg.saveRange();\n                            trumbowyg.openModalInsert(\n                                // Title\n                                trumbowyg.lang.table,\n\n                                // Fields\n                                {\n                                    rows: {\n                                        type: 'number',\n                                        required: true\n                                    },\n                                    columns: {\n                                        type: 'number',\n                                        required: true\n                                    },\n                                    styler: {\n                                        label: trumbowyg.lang.styler,\n                                        type: 'text'\n                                    }\n                                },\n                                function (v) { // v is value\n                                    var tabler = $('<table></table>');\n                                    if (v.styler.length !== 0) {\n                                        tabler.addClass(v.styler);\n                                    }\n\n                                    for (var i = 0; i < v.rows; i += 1) {\n                                        var row = $('<tr></tr>').appendTo(tabler);\n                                        for (var j = 0; j < v.columns; j += 1) {\n                                            $('<td></td>').appendTo(row);\n                                        }\n                                    }\n\n                                    trumbowyg.range.deleteContents();\n                                    trumbowyg.range.insertNode(tabler[0]);\n                                    return true;\n                                });\n                        }\n                    };\n\n                    var addRow = {\n                        fn: function () {\n                            trumbowyg.saveRange();\n                            var rower = $('<tr></tr>');\n                            trumbowyg.range.deleteContents();\n                            trumbowyg.range.insertNode(rower[0]);\n                            return true;\n\n                        }\n                    };\n\n                    var addColumn = {\n                        fn: function () {\n                            trumbowyg.saveRange();\n                            var columner = $('<td></td>');\n                            trumbowyg.range.deleteContents();\n                            trumbowyg.range.insertNode(columner[0]);\n                            return true;\n\n                        }\n                    };\n\n                    trumbowyg.addBtnDef('table', tableBuild);\n                    trumbowyg.addBtnDef('tableAddRow', addRow);\n                    trumbowyg.addBtnDef('tableAddColumn', addColumn);\n                }\n            }\n        }\n    });\n})(jQuery);"
  },
  {
    "path": "pubnot/trumbowyg/plugins/template/trumbowyg.template.js",
    "content": "(function($) {\n    'use strict';\n\n    // Adds the language variables\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            en: {\n                template: 'Template'\n            },\n            nl: {\n                template: 'Sjabloon'\n            },\n            ru: {\n                template: 'Шаблон'\n            },\n            ja: {\n                template: 'テンプレート'\n            }\n        }\n    });\n\n    // Adds the extra button definition\n    $.extend(true, $.trumbowyg, {\n        plugins: {\n            template: {\n                shouldInit: function(trumbowyg) {\n                    return trumbowyg.o.plugins.hasOwnProperty('templates');\n                },\n                init: function(trumbowyg) {\n                    trumbowyg.addBtnDef('template', {\n                        dropdown: templateSelector(trumbowyg),\n                        hasIcon: false,\n                        text: trumbowyg.lang.template\n                    });\n                }\n            }\n        }\n    });\n\n    // Creates the template-selector dropdown.\n    function templateSelector(trumbowyg) {\n        var available = trumbowyg.o.plugins.templates;\n        var templates = [];\n\n        $.each(available, function(index, template) {\n            trumbowyg.addBtnDef('template_' + index, {\n                fn: function(){\n                    trumbowyg.html(template.html);\n                },\n                hasIcon: false,\n                title: template.name\n            });\n            templates.push('template_' + index);\n        });\n\n        return templates;\n    }\n})(jQuery);"
  },
  {
    "path": "pubnot/trumbowyg/plugins/upload/trumbowyg.upload.js",
    "content": "/* ===========================================================\n * trumbowyg.upload.js v1.2\n * Upload plugin for Trumbowyg\n * http://alex-d.github.com/Trumbowyg\n * ===========================================================\n * Author : Alexandre Demode (Alex-D)\n *          Twitter : @AlexandreDemode\n *          Website : alex-d.fr\n * Mod by : Aleksandr-ru\n *          Twitter : @Aleksandr_ru\n *          Website : aleksandr.ru\n */\n\n(function ($) {\n    'use strict';\n\n    var defaultOptions = {\n        serverPath: '',\n        fileFieldName: 'fileToUpload',\n        data: [],                       // Additional data for ajax [{name: 'key', value: 'value'}]\n        headers: {},                    // Additional headers\n        xhrFields: {},                  // Additional fields\n        urlPropertyName: 'file',        // How to get url from the json response (for instance 'url' for {url: ....})\n        statusPropertyName: 'success',  // How to get status from the json response \n        success: undefined,             // Success callback: function (data, trumbowyg, $modal, values) {}\n        error: undefined                // Error callback: function () {}\n    };\n\n    function getDeep(object, propertyParts) {\n        var mainProperty = propertyParts.shift(),\n            otherProperties = propertyParts;\n\n        if (object !== null) {\n            if (otherProperties.length === 0) {\n                return object[mainProperty];\n            }\n\n            if (typeof object === 'object') {\n                return getDeep(object[mainProperty], otherProperties);\n            }\n        }\n        return object;\n    }\n\n    addXhrProgressEvent();\n\n    $.extend(true, $.trumbowyg, {\n        langs: {\n            // jshint camelcase:false\n            en: {\n                upload: 'Upload',\n                file: 'File',\n                uploadError: 'Error'\n            },\n            sk: {\n                upload: 'Nahrať',\n                file: 'Súbor',\n                uploadError: 'Chyba'\n            },\n            fr: {\n                upload: 'Envoi',\n                file: 'Fichier',\n                uploadError: 'Erreur'\n            },\n            cs: {\n                upload: 'Nahrát obrázek',\n                file: 'Soubor',\n                uploadError: 'Chyba'\n            },\n            zh_cn: {\n                upload: '上传',\n                file: '文件',\n                uploadError: '错误'\n            },\n            zh_tw: {\n                upload: '上傳',\n                file: '文件',\n                uploadError: '錯誤'\n            },            \n            ru: {\n                upload: 'Загрузка',\n                file: 'Файл',\n                uploadError: 'Ошибка'\n            },\n            ja: {\n                upload: 'アップロード',\n                file: 'ファイル',\n                uploadError: 'エラー'\n            },\n            pt_br: {\n                upload: 'Enviar do local',\n                file: 'Arquivo',\n                uploadError: 'Erro'\n            },\n        },\n        // jshint camelcase:true\n\n        plugins: {\n            upload: {\n                init: function (trumbowyg) {\n                    trumbowyg.o.plugins.upload = $.extend(true, {}, defaultOptions, trumbowyg.o.plugins.upload || {});\n                    var btnDef = {\n                        fn: function () {\n                            trumbowyg.saveRange();\n\n                            var file,\n                                prefix = trumbowyg.o.prefix;\n\n                            var $modal = trumbowyg.openModalInsert(\n                                // Title\n                                trumbowyg.lang.upload,\n\n                                // Fields\n                                {\n                                    file: {\n                                        type: 'file',\n                                        required: true,\n                                        attributes: {\n                                            accept: 'image/*'\n                                        }\n                                    },\n                                    alt: {\n                                        label: 'description',\n                                        value: trumbowyg.getRangeText()\n                                    }\n                                },\n\n                                // Callback\n                                function (values) {\n                                    var data = new FormData();\n                                    data.append(trumbowyg.o.plugins.upload.fileFieldName, file);\n\n                                    trumbowyg.o.plugins.upload.data.map(function (cur) {\n                                        data.append(cur.name, cur.value);\n                                    });\n                                    \n                                    $.map(values, function(curr, key){\n                                        if(key !== 'file') { \n                                            data.append(key, curr);\n                                        }\n                                    });\n\n                                    if ($('.' + prefix + 'progress', $modal).length === 0) {\n                                        $('.' + prefix + 'modal-title', $modal)\n                                            .after(\n                                                $('<div/>', {\n                                                    'class': prefix + 'progress'\n                                                }).append(\n                                                    $('<div/>', {\n                                                        'class': prefix + 'progress-bar'\n                                                    })\n                                                )\n                                            );\n                                    }\n\n                                    $.ajax({\n                                        url: trumbowyg.o.plugins.upload.serverPath,\n                                        headers: trumbowyg.o.plugins.upload.headers,\n                                        xhrFields: trumbowyg.o.plugins.upload.xhrFields,\n                                        type: 'POST',\n                                        data: data,\n                                        cache: false,\n                                        dataType: 'json',\n                                        processData: false,\n                                        contentType: false,\n\n                                        progressUpload: function (e) {\n                                            $('.' + prefix + 'progress-bar').css('width', Math.round(e.loaded * 100 / e.total) + '%');\n                                        },\n\n                                        success: function (data) {\n                                            if (trumbowyg.o.plugins.upload.success) {\n                                                trumbowyg.o.plugins.upload.success(data, trumbowyg, $modal, values);\n                                            } else {\n                                                if (!!getDeep(data, trumbowyg.o.plugins.upload.statusPropertyName.split('.'))) {\n                                                    var url = getDeep(data, trumbowyg.o.plugins.upload.urlPropertyName.split('.'));\n                                                    trumbowyg.execCmd('insertImage', url);\n                                                    $('img[src=\"' + url + '\"]:not([alt])', trumbowyg.$box).attr('alt', values.alt);\n                                                    setTimeout(function () {\n                                                        trumbowyg.closeModal();\n                                                    }, 250);\n                                                    trumbowyg.$c.trigger('tbwuploadsuccess', [trumbowyg, data, url]);\n                                                } else {\n                                                    trumbowyg.addErrorOnModalField(\n                                                        $('input[type=file]', $modal),\n                                                        trumbowyg.lang[data.message]\n                                                    );\n                                                    trumbowyg.$c.trigger('tbwuploaderror', [trumbowyg, data]);\n                                                }\n                                            }\n                                        },\n\n                                        error: trumbowyg.o.plugins.upload.error || function () {\n                                            trumbowyg.addErrorOnModalField(\n                                                $('input[type=file]', $modal),\n                                                trumbowyg.lang.uploadError\n                                            );\n                                            trumbowyg.$c.trigger('tbwuploaderror', [trumbowyg]);\n                                        }\n                                    });\n                                }\n                            );\n\n                            $('input[type=file]').on('change', function (e) {\n                                try {\n                                    // If multiple files allowed, we just get the first.\n                                    file = e.target.files[0];\n                                } catch (err) {\n                                    // In IE8, multiple files not allowed\n                                    file = e.target.value;\n                                }\n                            });\n                        }\n                    };\n\n                    trumbowyg.addBtnDef('upload', btnDef);\n                }\n            }\n        }\n    });\n\n\n    function addXhrProgressEvent() {\n        if (!$.trumbowyg.addedXhrProgressEvent) {   // Avoid adding progress event multiple times\n            var originalXhr = $.ajaxSettings.xhr;\n            $.ajaxSetup({\n                xhr: function () {\n                    var req = originalXhr(),\n                        that = this;\n                    if (req && typeof req.upload === 'object' && that.progressUpload !== undefined) {\n                        req.upload.addEventListener('progress', function (e) {\n                            that.progressUpload(e);\n                        }, false);\n                    }\n\n                    return req;\n                }\n            });\n            $.trumbowyg.addedXhrProgressEvent = true;\n        }\n    }\n})(jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/trumbowyg.js",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Trumbowyg core file\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\njQuery.trumbowyg = {\n    langs: {\n        en: {\n            viewHTML: 'View HTML',\n\n            undo: 'Undo',\n            redo: 'Redo',\n\n            formatting: 'Formatting',\n            p: 'Paragraph',\n            blockquote: 'Quote',\n            code: 'Code',\n            header: 'Header',\n\n            bold: 'Bold',\n            italic: 'Italic',\n            strikethrough: 'Stroke',\n            underline: 'Underline',\n\n            strong: 'Strong',\n            em: 'Emphasis',\n            del: 'Deleted',\n\n            superscript: 'Superscript',\n            subscript: 'Subscript',\n\n            unorderedList: 'Unordered list',\n            orderedList: 'Ordered list',\n\n            insertImage: 'Insert Image',\n            link: 'Link',\n            createLink: 'Insert link',\n            unlink: 'Remove link',\n\n            justifyLeft: 'Align Left',\n            justifyCenter: 'Align Center',\n            justifyRight: 'Align Right',\n            justifyFull: 'Align Justify',\n\n            horizontalRule: 'Insert horizontal rule',\n            removeformat: 'Remove format',\n\n            fullscreen: 'Fullscreen',\n\n            close: 'Close',\n\n            submit: 'Confirm',\n            reset: 'Cancel',\n\n            required: 'Required',\n            description: 'Description',\n            title: 'Title',\n            text: 'Text',\n            target: 'Target'\n        }\n    },\n\n    // Plugins\n    plugins: {},\n\n    // SVG Path globally\n    svgPath: null,\n\n    hideButtonTexts: null\n};\n\n// Makes default options read-only\nObject.defineProperty(jQuery.trumbowyg, 'defaultOptions', {\n    value: {\n        lang: 'en',\n\n        fixedBtnPane: false,\n        fixedFullWidth: false,\n        autogrow: false,\n        autogrowOnEnter: false,\n\n        prefix: 'trumbowyg-',\n\n        semantic: true,\n        resetCss: false,\n        removeformatPasted: false,\n        tagsToRemove: [],\n        btns: [\n            ['viewHTML'],\n            ['undo', 'redo'], // Only supported in Blink browsers\n            ['formatting'],\n            ['strong', 'em', 'del'],\n            ['superscript', 'subscript'],\n            ['link'],\n            ['insertImage'],\n            ['justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull'],\n            ['unorderedList', 'orderedList'],\n            ['horizontalRule'],\n            ['removeformat'],\n            ['fullscreen']\n        ],\n        // For custom button definitions\n        btnsDef: {},\n\n        inlineElementsSelector: 'a,abbr,acronym,b,caption,cite,code,col,dfn,dir,dt,dd,em,font,hr,i,kbd,li,q,span,strikeout,strong,sub,sup,u',\n\n        pasteHandlers: [],\n\n        // imgDblClickHandler: default is defined in constructor\n\n        plugins: {}\n    },\n    writable: false,\n    enumerable: true,\n    configurable: false\n});\n\n\n(function (navigator, window, document, $) {\n    'use strict';\n\n    var CONFIRM_EVENT = 'tbwconfirm',\n        CANCEL_EVENT = 'tbwcancel';\n\n    $.fn.trumbowyg = function (options, params) {\n        var trumbowygDataName = 'trumbowyg';\n        if (options === Object(options) || !options) {\n            return this.each(function () {\n                if (!$(this).data(trumbowygDataName)) {\n                    $(this).data(trumbowygDataName, new Trumbowyg(this, options));\n                }\n            });\n        }\n        if (this.length === 1) {\n            try {\n                var t = $(this).data(trumbowygDataName);\n                switch (options) {\n                    // Exec command\n                    case 'execCmd':\n                        return t.execCmd(params.cmd, params.param, params.forceCss);\n\n                    // Modal box\n                    case 'openModal':\n                        return t.openModal(params.title, params.content);\n                    case 'closeModal':\n                        return t.closeModal();\n                    case 'openModalInsert':\n                        return t.openModalInsert(params.title, params.fields, params.callback);\n\n                    // Range\n                    case 'saveRange':\n                        return t.saveRange();\n                    case 'getRange':\n                        return t.range;\n                    case 'getRangeText':\n                        return t.getRangeText();\n                    case 'restoreRange':\n                        return t.restoreRange();\n\n                    // Enable/disable\n                    case 'enable':\n                        return t.setDisabled(false);\n                    case 'disable':\n                        return t.setDisabled(true);\n\n                    // Destroy\n                    case 'destroy':\n                        return t.destroy();\n\n                    // Empty\n                    case 'empty':\n                        return t.empty();\n\n                    // HTML\n                    case 'html':\n                        return t.html(params);\n                }\n            } catch (c) {\n            }\n        }\n\n        return false;\n    };\n\n    // @param: editorElem is the DOM element\n    var Trumbowyg = function (editorElem, options) {\n        var t = this,\n            trumbowygIconsId = 'trumbowyg-icons',\n            $trumbowyg = $.trumbowyg;\n\n        // Get the document of the element. It use to makes the plugin\n        // compatible on iframes.\n        t.doc = editorElem.ownerDocument || document;\n\n        // jQuery object of the editor\n        t.$ta = $(editorElem); // $ta : Textarea\n        t.$c = $(editorElem); // $c : creator\n\n        options = options || {};\n\n        // Localization management\n        if (options.lang != null || $trumbowyg.langs[options.lang] != null) {\n            t.lang = $.extend(true, {}, $trumbowyg.langs.en, $trumbowyg.langs[options.lang]);\n        } else {\n            t.lang = $trumbowyg.langs.en;\n        }\n\n        t.hideButtonTexts = $trumbowyg.hideButtonTexts != null ? $trumbowyg.hideButtonTexts : options.hideButtonTexts;\n\n        // SVG path\n        var svgPathOption = $trumbowyg.svgPath != null ? $trumbowyg.svgPath : options.svgPath;\n        t.hasSvg = svgPathOption !== false;\n        t.svgPath = !!t.doc.querySelector('base') ? window.location.href.split('#')[0] : '';\n        if ($('#' + trumbowygIconsId, t.doc).length === 0 && svgPathOption !== false) {\n            if (svgPathOption == null) {\n                // Hack to get svgPathOption based on trumbowyg.js path\n                try {\n                    throw new Error();\n                } catch (e) {\n                    if (!e.hasOwnProperty('stack')) {\n                        console.warn('You must define svgPath: https://goo.gl/CfTY9U'); // jshint ignore:line\n                    } else {\n                        var stackLines = e.stack.split('\\n');\n\n                        for (var i in stackLines) {\n                            if (!stackLines[i].match(/https?:\\/\\//)) {\n                                continue;\n                            }\n                            svgPathOption = stackLines[Number(i)].match(/((https?:\\/\\/.+\\/)([^\\/]+\\.js))(\\?.*)?:/)[1].split('/');\n                            svgPathOption.pop();\n                            svgPathOption = svgPathOption.join('/') + '/ui/icons.svg';\n                            break;\n                        }\n                    }\n                }\n            }\n\n            var div = t.doc.createElement('div');\n            div.id = trumbowygIconsId;\n            t.doc.body.insertBefore(div, t.doc.body.childNodes[0]);\n            $.ajax({\n                async: true,\n                type: 'GET',\n                contentType: 'application/x-www-form-urlencoded; charset=UTF-8',\n                dataType: 'xml',\n                crossDomain: true,\n                url: svgPathOption,\n                data: null,\n                beforeSend: null,\n                complete: null,\n                success: function (data) {\n                    div.innerHTML = new XMLSerializer().serializeToString(data.documentElement);\n                }\n            });\n        }\n\n\n        /**\n         * When the button is associated to a empty object\n         * fn and title attributs are defined from the button key value\n         *\n         * For example\n         *      foo: {}\n         * is equivalent to :\n         *      foo: {\n         *          fn: 'foo',\n         *          title: this.lang.foo\n         *      }\n         */\n        var h = t.lang.header, // Header translation\n            isBlinkFunction = function () {\n                return (window.chrome || (window.Intl && Intl.v8BreakIterator)) && 'CSS' in window;\n            };\n        t.btnsDef = {\n            viewHTML: {\n                fn: 'toggle'\n            },\n\n            undo: {\n                isSupported: isBlinkFunction,\n                key: 'Z'\n            },\n            redo: {\n                isSupported: isBlinkFunction,\n                key: 'Y'\n            },\n\n            p: {\n                fn: 'formatBlock'\n            },\n            blockquote: {\n                fn: 'formatBlock'\n            },\n            h1: {\n                fn: 'formatBlock',\n                title: h + ' 1'\n            },\n            h2: {\n                fn: 'formatBlock',\n                title: h + ' 2'\n            },\n            h3: {\n                fn: 'formatBlock',\n                title: h + ' 3'\n            },\n            h4: {\n                fn: 'formatBlock',\n                title: h + ' 4'\n            },\n            subscript: {\n                tag: 'sub'\n            },\n            superscript: {\n                tag: 'sup'\n            },\n\n            bold: {\n                key: 'B',\n                tag: 'b'\n            },\n            italic: {\n                key: 'I',\n                tag: 'i'\n            },\n            underline: {\n                tag: 'u'\n            },\n            strikethrough: {\n                tag: 'strike'\n            },\n\n            strong: {\n                fn: 'bold',\n                key: 'B'\n            },\n            em: {\n                fn: 'italic',\n                key: 'I'\n            },\n            del: {\n                fn: 'strikethrough'\n            },\n\n            createLink: {\n                key: 'K',\n                tag: 'a'\n            },\n            unlink: {},\n\n            insertImage: {},\n\n            justifyLeft: {\n                tag: 'left',\n                forceCss: true\n            },\n            justifyCenter: {\n                tag: 'center',\n                forceCss: true\n            },\n            justifyRight: {\n                tag: 'right',\n                forceCss: true\n            },\n            justifyFull: {\n                tag: 'justify',\n                forceCss: true\n            },\n\n            unorderedList: {\n                fn: 'insertUnorderedList',\n                tag: 'ul'\n            },\n            orderedList: {\n                fn: 'insertOrderedList',\n                tag: 'ol'\n            },\n\n            horizontalRule: {\n                fn: 'insertHorizontalRule'\n            },\n\n            removeformat: {},\n\n            fullscreen: {\n                class: 'trumbowyg-not-disable'\n            },\n            close: {\n                fn: 'destroy',\n                class: 'trumbowyg-not-disable'\n            },\n\n            // Dropdowns\n            formatting: {\n                dropdown: ['p', 'blockquote', 'h1', 'h2', 'h3', 'h4'],\n                ico: 'p'\n            },\n            link: {\n                dropdown: ['createLink', 'unlink']\n            }\n        };\n\n        // Defaults Options\n        t.o = $.extend(true, {}, $trumbowyg.defaultOptions, options);\n        if (!t.o.hasOwnProperty('imgDblClickHandler')) {\n            t.o.imgDblClickHandler = t.getDefaultImgDblClickHandler();\n        }\n\n        t.disabled = t.o.disabled || (editorElem.nodeName === 'TEXTAREA' && editorElem.disabled);\n\n        if (options.btns) {\n            t.o.btns = options.btns;\n        } else if (!t.o.semantic) {\n            t.o.btns[3] = ['bold', 'italic', 'underline', 'strikethrough'];\n        }\n\n        $.each(t.o.btnsDef, function (btnName, btnDef) {\n            t.addBtnDef(btnName, btnDef);\n        });\n\n        // put this here in the event it would be merged in with options\n        t.eventNamespace = 'trumbowyg-event';\n\n        // Keyboard shortcuts are load in this array\n        t.keys = [];\n\n        // Tag to button dynamically hydrated\n        t.tagToButton = {};\n        t.tagHandlers = [];\n\n        // Admit multiple paste handlers\n        t.pasteHandlers = [].concat(t.o.pasteHandlers);\n\n        // Check if browser is IE\n        t.isIE = (navigator.userAgent.indexOf('MSIE') !== -1 || navigator.appVersion.indexOf('Trident/') !== -1);\n\n        t.init();\n    };\n\n    Trumbowyg.prototype = {\n        init: function () {\n            var t = this;\n            t.height = t.$ta.height();\n\n            t.initPlugins();\n\n            try {\n                // Disable image resize, try-catch for old IE\n                t.doc.execCommand('enableObjectResizing', false, false);\n                t.doc.execCommand('defaultParagraphSeparator', false, 'p');\n            } catch (e) {\n            }\n\n            t.buildEditor();\n            t.buildBtnPane();\n\n            t.fixedBtnPaneEvents();\n\n            t.buildOverlay();\n\n            setTimeout(function () {\n                if (t.disabled) {\n                    t.setDisabled(true);\n                }\n                t.$c.trigger('tbwinit');\n            });\n        },\n\n        addBtnDef: function (btnName, btnDef) {\n            this.btnsDef[btnName] = btnDef;\n        },\n\n        buildEditor: function () {\n            var t = this,\n                prefix = t.o.prefix,\n                html = '';\n\n            t.$box = $('<div/>', {\n                class: prefix + 'box ' + prefix + 'editor-visible ' + prefix + t.o.lang + ' trumbowyg'\n            });\n\n            // $ta = Textarea\n            // $ed = Editor\n            t.isTextarea = t.$ta.is('textarea');\n            if (t.isTextarea) {\n                html = t.$ta.val();\n                t.$ed = $('<div/>');\n                t.$box\n                    .insertAfter(t.$ta)\n                    .append(t.$ed, t.$ta);\n            } else {\n                t.$ed = t.$ta;\n                html = t.$ed.html();\n\n                t.$ta = $('<textarea/>', {\n                    name: t.$ta.attr('id'),\n                    height: t.height\n                }).val(html);\n\n                t.$box\n                    .insertAfter(t.$ed)\n                    .append(t.$ta, t.$ed);\n                t.syncCode();\n            }\n\n            t.$ta\n                .addClass(prefix + 'textarea')\n                .attr('tabindex', -1)\n            ;\n\n            t.$ed\n                .addClass(prefix + 'editor')\n                .attr({\n                    contenteditable: true,\n                    dir: t.lang._dir || 'ltr'\n                })\n                .html(html)\n            ;\n\n            if (t.o.tabindex) {\n                t.$ed.attr('tabindex', t.o.tabindex);\n            }\n\n            if (t.$c.is('[placeholder]')) {\n                t.$ed.attr('placeholder', t.$c.attr('placeholder'));\n            }\n\n            if (t.$c.is('[spellcheck]')) {\n                t.$ed.attr('spellcheck', t.$c.attr('spellcheck'));\n            }\n\n            if (t.o.resetCss) {\n                t.$ed.addClass(prefix + 'reset-css');\n            }\n\n            if (!t.o.autogrow) {\n                t.$ta.add(t.$ed).css({\n                    height: t.height\n                });\n            }\n\n            t.semanticCode();\n\n            if (t.o.autogrowOnEnter) {\n                t.$ed.addClass(prefix + 'autogrow-on-enter');\n            }\n\n            var ctrl = false,\n                composition = false,\n                debounceButtonPaneStatus,\n                updateEventName = t.isIE ? 'keyup' : 'input';\n\n            t.$ed\n                .on('dblclick', 'img', t.o.imgDblClickHandler)\n                .on('keydown', function (e) {\n                    if ((e.ctrlKey || e.metaKey) && !e.altKey) {\n                        ctrl = true;\n                        var key = t.keys[String.fromCharCode(e.which).toUpperCase()];\n\n                        try {\n                            t.execCmd(key.fn, key.param);\n                            return false;\n                        } catch (c) {\n                        }\n                    }\n                })\n                .on('compositionstart compositionupdate', function () {\n                    composition = true;\n                })\n                .on(updateEventName + ' compositionend', function (e) {\n                    if (e.type === 'compositionend') {\n                        composition = false;\n                    } else if (composition) {\n                        return;\n                    }\n\n                    var keyCode = e.which;\n\n                    if (keyCode >= 37 && keyCode <= 40) {\n                        return;\n                    }\n\n                    if ((e.ctrlKey || e.metaKey) && (keyCode === 89 || keyCode === 90)) {\n                        t.$c.trigger('tbwchange');\n                    } else if (!ctrl && keyCode !== 17) {\n                        t.semanticCode(false, e.type === 'compositionend' && keyCode === 13);\n                        t.$c.trigger('tbwchange');\n                    } else if (typeof e.which === 'undefined') {\n                        t.semanticCode(false, false, true);\n                    }\n\n                    setTimeout(function () {\n                        ctrl = false;\n                    }, 200);\n                })\n                .on('mouseup keydown keyup', function () {\n                    clearTimeout(debounceButtonPaneStatus);\n                    debounceButtonPaneStatus = setTimeout(function () {\n                        t.updateButtonPaneStatus();\n                    }, 50);\n                })\n                .on('focus blur', function (e) {\n                    t.$c.trigger('tbw' + e.type);\n                    if (e.type === 'blur') {\n                        $('.' + prefix + 'active-button', t.$btnPane).removeClass(prefix + 'active-button ' + prefix + 'active');\n                    }\n                    if (t.o.autogrowOnEnter) {\n                        if (t.autogrowOnEnterDontClose) {\n                            return;\n                        }\n                        if (e.type === 'focus') {\n                            t.autogrowOnEnterWasFocused = true;\n                            t.autogrowEditorOnEnter();\n                        }\n                        else if (!t.o.autogrow) {\n                            t.$ed.css({height: t.$ed.css('min-height')});\n                            t.$c.trigger('tbwresize');\n                        }\n                    }\n                })\n                .on('cut', function () {\n                    setTimeout(function () {\n                        t.semanticCode(false, true);\n                        t.$c.trigger('tbwchange');\n                    }, 0);\n                })\n                .on('paste', function (e) {\n                    if (t.o.removeformatPasted) {\n                        e.preventDefault();\n\n                        if (window.getSelection && window.getSelection().deleteFromDocument) {\n                            window.getSelection().deleteFromDocument();\n                        }\n\n                        try {\n                            // IE\n                            var text = window.clipboardData.getData('Text');\n\n                            try {\n                                // <= IE10\n                                t.doc.selection.createRange().pasteHTML(text);\n                            } catch (c) {\n                                // IE 11\n                                t.doc.getSelection().getRangeAt(0).insertNode(t.doc.createTextNode(text));\n                            }\n                            t.$c.trigger('tbwchange', e);\n                        } catch (d) {\n                            // Not IE\n                            t.execCmd('insertText', (e.originalEvent || e).clipboardData.getData('text/plain'));\n                        }\n                    }\n\n                    // Call pasteHandlers\n                    $.each(t.pasteHandlers, function (i, pasteHandler) {\n                        pasteHandler(e);\n                    });\n\n                    setTimeout(function () {\n                        t.semanticCode(false, true);\n                        t.$c.trigger('tbwpaste', e);\n                    }, 0);\n                });\n\n            t.$ta\n                .on('keyup', function () {\n                    t.$c.trigger('tbwchange');\n                })\n                .on('paste', function () {\n                    setTimeout(function () {\n                        t.$c.trigger('tbwchange');\n                    }, 0);\n                });\n\n            t.$box.on('keydown', function (e) {\n                if (e.which === 27 && $('.' + prefix + 'modal-box', t.$box).length === 1) {\n                    t.closeModal();\n                    return false;\n                }\n            });\n        },\n\n        //autogrow when entering logic\n        autogrowEditorOnEnter: function () {\n            var t = this;\n            t.$ed.removeClass('autogrow-on-enter');\n            var oldHeight = t.$ed[0].clientHeight;\n            t.$ed.height('auto');\n            var totalHeight = t.$ed[0].scrollHeight;\n            t.$ed.addClass('autogrow-on-enter');\n            if (oldHeight !== totalHeight) {\n                t.$ed.height(oldHeight);\n                setTimeout(function () {\n                    t.$ed.css({height: totalHeight});\n                    t.$c.trigger('tbwresize');\n                }, 0);\n            }\n        },\n\n\n        // Build button pane, use o.btns option\n        buildBtnPane: function () {\n            var t = this,\n                prefix = t.o.prefix;\n\n            var $btnPane = t.$btnPane = $('<div/>', {\n                class: prefix + 'button-pane'\n            });\n\n            $.each(t.o.btns, function (i, btnGrp) {\n                if (!$.isArray(btnGrp)) {\n                    btnGrp = [btnGrp];\n                }\n\n                var $btnGroup = $('<div/>', {\n                    class: prefix + 'button-group ' + ((btnGrp.indexOf('fullscreen') >= 0) ? prefix + 'right' : '')\n                });\n                $.each(btnGrp, function (i, btn) {\n                    try { // Prevent buildBtn error\n                        if (t.isSupportedBtn(btn)) { // It's a supported button\n                            $btnGroup.append(t.buildBtn(btn));\n                        }\n                    } catch (c) {\n                    }\n                });\n                $btnPane.append($btnGroup);\n            });\n\n            t.$box.prepend($btnPane);\n        },\n\n\n        // Build a button and his action\n        buildBtn: function (btnName) { // btnName is name of the button\n            var t = this,\n                prefix = t.o.prefix,\n                btn = t.btnsDef[btnName],\n                isDropdown = btn.dropdown,\n                hasIcon = btn.hasIcon != null ? btn.hasIcon : true,\n                textDef = t.lang[btnName] || btnName,\n\n                $btn = $('<button/>', {\n                    type: 'button',\n                    class: prefix + btnName + '-button ' + (btn.class || '') + (!hasIcon ? ' ' + prefix + 'textual-button' : ''),\n                    html: t.hasSvg && hasIcon ?\n                        '<svg><use xlink:href=\"' + t.svgPath + '#' + prefix + (btn.ico || btnName).replace(/([A-Z]+)/g, '-$1').toLowerCase() + '\"/></svg>' :\n                        t.hideButtonTexts ? '' : (btn.text || btn.title || t.lang[btnName] || btnName),\n                    title: (btn.title || btn.text || textDef) + ((btn.key) ? ' (Ctrl + ' + btn.key + ')' : ''),\n                    tabindex: -1,\n                    mousedown: function () {\n                        if (!isDropdown || $('.' + btnName + '-' + prefix + 'dropdown', t.$box).is(':hidden')) {\n                            $('body', t.doc).trigger('mousedown');\n                        }\n\n                        if (t.$btnPane.hasClass(prefix + 'disable') && !$(this).hasClass(prefix + 'active') && !$(this).hasClass(prefix + 'not-disable')) {\n                            return false;\n                        }\n\n                        t.execCmd((isDropdown ? 'dropdown' : false) || btn.fn || btnName, btn.param || btnName, btn.forceCss);\n\n                        return false;\n                    }\n                });\n\n            if (isDropdown) {\n                $btn.addClass(prefix + 'open-dropdown');\n                var dropdownPrefix = prefix + 'dropdown',\n                    $dropdown = $('<div/>', { // the dropdown\n                        class: dropdownPrefix + '-' + btnName + ' ' + dropdownPrefix + ' ' + prefix + 'fixed-top',\n                        'data-dropdown': btnName\n                    });\n                $.each(isDropdown, function (i, def) {\n                    if (t.btnsDef[def] && t.isSupportedBtn(def)) {\n                        $dropdown.append(t.buildSubBtn(def));\n                    }\n                });\n                t.$box.append($dropdown.hide());\n            } else if (btn.key) {\n                t.keys[btn.key] = {\n                    fn: btn.fn || btnName,\n                    param: btn.param || btnName\n                };\n            }\n\n            if (!isDropdown) {\n                t.tagToButton[(btn.tag || btnName).toLowerCase()] = btnName;\n            }\n\n            return $btn;\n        },\n        // Build a button for dropdown menu\n        // @param n : name of the subbutton\n        buildSubBtn: function (btnName) {\n            var t = this,\n                prefix = t.o.prefix,\n                btn = t.btnsDef[btnName],\n                hasIcon = btn.hasIcon != null ? btn.hasIcon : true;\n\n            if (btn.key) {\n                t.keys[btn.key] = {\n                    fn: btn.fn || btnName,\n                    param: btn.param || btnName\n                };\n            }\n\n            t.tagToButton[(btn.tag || btnName).toLowerCase()] = btnName;\n\n            return $('<button/>', {\n                type: 'button',\n                class: prefix + btnName + '-dropdown-button' + (btn.ico ? ' ' + prefix + btn.ico + '-button' : ''),\n                html: t.hasSvg && hasIcon ? '<svg><use xlink:href=\"' + t.svgPath + '#' + prefix + (btn.ico || btnName).replace(/([A-Z]+)/g, '-$1').toLowerCase() + '\"/></svg>' + (btn.text || btn.title || t.lang[btnName] || btnName) : (btn.text || btn.title || t.lang[btnName] || btnName),\n                title: ((btn.key) ? ' (Ctrl + ' + btn.key + ')' : null),\n                style: btn.style || null,\n                mousedown: function () {\n                    $('body', t.doc).trigger('mousedown');\n\n                    t.execCmd(btn.fn || btnName, btn.param || btnName, btn.forceCss);\n\n                    return false;\n                }\n            });\n        },\n        // Check if button is supported\n        isSupportedBtn: function (b) {\n            try {\n                return this.btnsDef[b].isSupported();\n            } catch (c) {\n            }\n            return true;\n        },\n\n        // Build overlay for modal box\n        buildOverlay: function () {\n            var t = this;\n            t.$overlay = $('<div/>', {\n                class: t.o.prefix + 'overlay'\n            }).appendTo(t.$box);\n            return t.$overlay;\n        },\n        showOverlay: function () {\n            var t = this;\n            $(window).trigger('scroll');\n            t.$overlay.fadeIn(200);\n            t.$box.addClass(t.o.prefix + 'box-blur');\n        },\n        hideOverlay: function () {\n            var t = this;\n            t.$overlay.fadeOut(50);\n            t.$box.removeClass(t.o.prefix + 'box-blur');\n        },\n\n        // Management of fixed button pane\n        fixedBtnPaneEvents: function () {\n            var t = this,\n                fixedFullWidth = t.o.fixedFullWidth,\n                $box = t.$box;\n\n            if (!t.o.fixedBtnPane) {\n                return;\n            }\n\n            t.isFixed = false;\n\n            $(window)\n                .on('scroll.' + t.eventNamespace + ' resize.' + t.eventNamespace, function () {\n                    if (!$box) {\n                        return;\n                    }\n\n                    t.syncCode();\n\n                    var scrollTop = $(window).scrollTop(),\n                        offset = $box.offset().top + 1,\n                        bp = t.$btnPane,\n                        oh = bp.outerHeight() - 2;\n\n                    if ((scrollTop - offset > 0) && ((scrollTop - offset - t.height) < 0)) {\n                        if (!t.isFixed) {\n                            t.isFixed = true;\n                            bp.css({\n                                position: 'fixed',\n                                top: 0,\n                                left: fixedFullWidth ? '0' : 'auto',\n                                zIndex: 7\n                            });\n                            $([t.$ta, t.$ed]).css({marginTop: bp.height()});\n                        }\n                        bp.css({\n                            width: fixedFullWidth ? '100%' : (($box.width() - 1) + 'px')\n                        });\n\n                        $('.' + t.o.prefix + 'fixed-top', $box).css({\n                            position: fixedFullWidth ? 'fixed' : 'absolute',\n                            top: fixedFullWidth ? oh : oh + (scrollTop - offset) + 'px',\n                            zIndex: 15\n                        });\n                    } else if (t.isFixed) {\n                        t.isFixed = false;\n                        bp.removeAttr('style');\n                        $([t.$ta, t.$ed]).css({marginTop: 0});\n                        $('.' + t.o.prefix + 'fixed-top', $box).css({\n                            position: 'absolute',\n                            top: oh\n                        });\n                    }\n                });\n        },\n\n        // Disable editor\n        setDisabled: function (disable) {\n            var t = this,\n                prefix = t.o.prefix;\n\n            t.disabled = disable;\n\n            if (disable) {\n                t.$ta.attr('disabled', true);\n            } else {\n                t.$ta.removeAttr('disabled');\n            }\n            t.$box.toggleClass(prefix + 'disabled', disable);\n            t.$ed.attr('contenteditable', !disable);\n        },\n\n        // Destroy the editor\n        destroy: function () {\n            var t = this,\n                prefix = t.o.prefix;\n\n            if (t.isTextarea) {\n                t.$box.after(\n                    t.$ta\n                        .css({height: ''})\n                        .val(t.html())\n                        .removeClass(prefix + 'textarea')\n                        .show()\n                );\n            } else {\n                t.$box.after(\n                    t.$ed\n                        .css({height: ''})\n                        .removeClass(prefix + 'editor')\n                        .removeAttr('contenteditable')\n                        .removeAttr('dir')\n                        .html(t.html())\n                        .show()\n                );\n            }\n\n            t.$ed.off('dblclick', 'img');\n\n            t.destroyPlugins();\n\n            t.$box.remove();\n            t.$c.removeData('trumbowyg');\n            $('body').removeClass(prefix + 'body-fullscreen');\n            t.$c.trigger('tbwclose');\n            $(window).off('scroll.' + t.eventNamespace + ' resize.' + t.eventNamespace);\n        },\n\n\n        // Empty the editor\n        empty: function () {\n            this.$ta.val('');\n            this.syncCode(true);\n        },\n\n\n        // Function call when click on viewHTML button\n        toggle: function () {\n            var t = this,\n                prefix = t.o.prefix;\n\n            if (t.o.autogrowOnEnter) {\n                t.autogrowOnEnterDontClose = !t.$box.hasClass(prefix + 'editor-hidden');\n            }\n\n            t.semanticCode(false, true);\n\n            setTimeout(function () {\n                t.doc.activeElement.blur();\n                t.$box.toggleClass(prefix + 'editor-hidden ' + prefix + 'editor-visible');\n                t.$btnPane.toggleClass(prefix + 'disable');\n                $('.' + prefix + 'viewHTML-button', t.$btnPane).toggleClass(prefix + 'active');\n                if (t.$box.hasClass(prefix + 'editor-visible')) {\n                    t.$ta.attr('tabindex', -1);\n                } else {\n                    t.$ta.removeAttr('tabindex');\n                }\n\n                if (t.o.autogrowOnEnter && !t.autogrowOnEnterDontClose) {\n                    t.autogrowEditorOnEnter();\n                }\n            }, 0);\n        },\n\n        // Open dropdown when click on a button which open that\n        dropdown: function (name) {\n            var t = this,\n                d = t.doc,\n                prefix = t.o.prefix,\n                $dropdown = $('[data-dropdown=' + name + ']', t.$box),\n                $btn = $('.' + prefix + name + '-button', t.$btnPane),\n                show = $dropdown.is(':hidden');\n\n            $('body', d).trigger('mousedown');\n\n            if (show) {\n                var o = $btn.offset().left;\n                $btn.addClass(prefix + 'active');\n\n                $dropdown.css({\n                    position: 'absolute',\n                    top: $btn.offset().top - t.$btnPane.offset().top + $btn.outerHeight(),\n                    left: (t.o.fixedFullWidth && t.isFixed) ? o + 'px' : (o - t.$btnPane.offset().left) + 'px'\n                }).show();\n\n                $(window).trigger('scroll');\n\n                $('body', d).on('mousedown.' + t.eventNamespace, function (e) {\n                    if (!$dropdown.is(e.target)) {\n                        $('.' + prefix + 'dropdown', d).hide();\n                        $('.' + prefix + 'active', d).removeClass(prefix + 'active');\n                        $('body', d).off('mousedown.' + t.eventNamespace);\n                    }\n                });\n            }\n        },\n\n\n        // HTML Code management\n        html: function (html) {\n            var t = this;\n            if (html != null) {\n                t.$ta.val(html);\n                t.syncCode(true);\n                return t;\n            }\n            return t.$ta.val();\n        },\n        syncTextarea: function () {\n            var t = this;\n            t.$ta.val(t.$ed.text().trim().length > 0 || t.$ed.find('hr,img,embed,iframe,input').length > 0 ? t.$ed.html() : '');\n        },\n        syncCode: function (force) {\n            var t = this;\n            if (!force && t.$ed.is(':visible')) {\n                t.syncTextarea();\n            } else {\n                // wrap the content in a div it's easier to get the innerhtml\n                var html = $('<div>').html(t.$ta.val());\n                //scrub the html before loading into the doc\n                var safe = $('<div>').append(html);\n                $(t.o.tagsToRemove.join(','), safe).remove();\n                t.$ed.html(safe.contents().html());\n            }\n\n            if (t.o.autogrow) {\n                t.height = t.$ed.height();\n                if (t.height !== t.$ta.css('height')) {\n                    t.$ta.css({height: t.height});\n                    t.$c.trigger('tbwresize');\n                }\n            }\n            if (t.o.autogrowOnEnter) {\n                // t.autogrowEditorOnEnter();\n                t.$ed.height('auto');\n                var totalheight = t.autogrowOnEnterWasFocused ? t.$ed[0].scrollHeight : t.$ed.css('min-height');\n                if (totalheight !== t.$ta.css('height')) {\n                    t.$ed.css({height: totalheight});\n                    t.$c.trigger('tbwresize');\n                }\n            }\n        },\n\n        // Analyse and update to semantic code\n        // @param force : force to sync code from textarea\n        // @param full  : wrap text nodes in <p>\n        // @param keepRange  : leave selection range as it is\n        semanticCode: function (force, full, keepRange) {\n            var t = this;\n            t.saveRange();\n            t.syncCode(force);\n\n            if (t.o.semantic) {\n                t.semanticTag('b', 'strong');\n                t.semanticTag('i', 'em');\n                t.semanticTag('strike', 'del');\n\n                if (full) {\n                    var inlineElementsSelector = t.o.inlineElementsSelector,\n                        blockElementsSelector = ':not(' + inlineElementsSelector + ')';\n\n                    // Wrap text nodes in span for easier processing\n                    t.$ed.contents().filter(function () {\n                        return this.nodeType === 3 && this.nodeValue.trim().length > 0;\n                    }).wrap('<span data-tbw/>');\n\n                    // Wrap groups of inline elements in paragraphs (recursive)\n                    var wrapInlinesInParagraphsFrom = function ($from) {\n                        if ($from.length !== 0) {\n                            var $finalParagraph = $from.nextUntil(blockElementsSelector).addBack().wrapAll('<p/>').parent(),\n                                $nextElement = $finalParagraph.nextAll(inlineElementsSelector).first();\n                            $finalParagraph.next('br').remove();\n                            wrapInlinesInParagraphsFrom($nextElement);\n                        }\n                    };\n                    wrapInlinesInParagraphsFrom(t.$ed.children(inlineElementsSelector).first());\n\n                    t.semanticTag('div', 'p', true);\n\n                    // Unwrap paragraphs content, containing nothing usefull\n                    t.$ed.find('p').filter(function () {\n                        // Don't remove currently being edited element\n                        if (t.range && this === t.range.startContainer) {\n                            return false;\n                        }\n                        return $(this).text().trim().length === 0 && $(this).children().not('br,span').length === 0;\n                    }).contents().unwrap();\n\n                    // Get rid of temporial span's\n                    $('[data-tbw]', t.$ed).contents().unwrap();\n\n                    // Remove empty <p>\n                    t.$ed.find('p:empty').remove();\n                }\n\n                if (!keepRange) {\n                    t.restoreRange();\n                }\n\n                t.syncTextarea();\n            }\n        },\n\n        semanticTag: function (oldTag, newTag, copyAttributes) {\n            $(oldTag, this.$ed).each(function () {\n                var $oldTag = $(this);\n                $oldTag.wrap('<' + newTag + '/>');\n                if (copyAttributes) {\n                    $.each($oldTag.prop('attributes'), function () {\n                        $oldTag.parent().attr(this.name, this.value);\n                    });\n                }\n                $oldTag.contents().unwrap();\n            });\n        },\n\n        // Function call when user click on \"Insert Link\"\n        createLink: function () {\n            var t = this,\n                documentSelection = t.doc.getSelection(),\n                node = documentSelection.focusNode,\n                url,\n                title,\n                target;\n\n            while (['A', 'DIV'].indexOf(node.nodeName) < 0) {\n                node = node.parentNode;\n            }\n\n            if (node && node.nodeName === 'A') {\n                var $a = $(node);\n                url = $a.attr('href');\n                title = $a.attr('title');\n                target = $a.attr('target');\n                var range = t.doc.createRange();\n                range.selectNode(node);\n                documentSelection.removeAllRanges();\n                documentSelection.addRange(range);\n            }\n\n            t.saveRange();\n\n            t.openModalInsert(t.lang.createLink, {\n                url: {\n                    label: 'URL',\n                    required: true,\n                    value: url\n                },\n                title: {\n                    label: t.lang.title,\n                    value: title\n                },\n                text: {\n                    label: t.lang.text,\n                    value: t.getRangeText()\n                },\n                target: {\n                    label: t.lang.target,\n                    value: target\n                }\n            }, function (v) { // v is value\n                var link = $(['<a href=\"', v.url, '\">', v.text, '</a>'].join(''));\n                if (v.title.length > 0) {\n                    link.attr('title', v.title);\n                }\n                if (v.target.length > 0) {\n                    link.attr('target', v.target);\n                }\n                t.range.deleteContents();\n                t.range.insertNode(link[0]);\n                return true;\n            });\n        },\n        unlink: function () {\n            var t = this,\n                documentSelection = t.doc.getSelection(),\n                node = documentSelection.focusNode;\n\n            if (documentSelection.isCollapsed) {\n                while (['A', 'DIV'].indexOf(node.nodeName) < 0) {\n                    node = node.parentNode;\n                }\n\n                if (node && node.nodeName === 'A') {\n                    var range = t.doc.createRange();\n                    range.selectNode(node);\n                    documentSelection.removeAllRanges();\n                    documentSelection.addRange(range);\n                }\n            }\n            t.execCmd('unlink', undefined, undefined, true);\n        },\n        insertImage: function () {\n            var t = this;\n            t.saveRange();\n            t.openModalInsert(t.lang.insertImage, {\n                url: {\n                    label: 'URL',\n                    required: true\n                },\n                alt: {\n                    label: t.lang.description,\n                    value: t.getRangeText()\n                }\n            }, function (v) { // v are values\n                t.execCmd('insertImage', v.url);\n                $('img[src=\"' + v.url + '\"]:not([alt])', t.$box).attr('alt', v.alt);\n                return true;\n            });\n        },\n        fullscreen: function () {\n            var t = this,\n                prefix = t.o.prefix,\n                fullscreenCssClass = prefix + 'fullscreen',\n                isFullscreen;\n\n            t.$box.toggleClass(fullscreenCssClass);\n            isFullscreen = t.$box.hasClass(fullscreenCssClass);\n            $('body').toggleClass(prefix + 'body-fullscreen', isFullscreen);\n            $(window).trigger('scroll');\n            t.$c.trigger('tbw' + (isFullscreen ? 'open' : 'close') + 'fullscreen');\n        },\n\n\n        /*\n         * Call method of trumbowyg if exist\n         * else try to call anonymous function\n         * and finaly native execCommand\n         */\n        execCmd: function (cmd, param, forceCss, skipTrumbowyg) {\n            var t = this;\n            skipTrumbowyg = !!skipTrumbowyg || '';\n\n            if (cmd !== 'dropdown') {\n                t.$ed.focus();\n            }\n\n            try {\n                t.doc.execCommand('styleWithCSS', false, forceCss || false);\n            } catch (c) {\n            }\n\n            try {\n                t[cmd + skipTrumbowyg](param);\n            } catch (c) {\n                try {\n                    cmd(param);\n                } catch (e2) {\n                    if (cmd === 'insertHorizontalRule') {\n                        param = undefined;\n                    } else if (cmd === 'formatBlock' && t.isIE) {\n                        param = '<' + param + '>';\n                    }\n\n                    t.doc.execCommand(cmd, false, param);\n\n                    t.syncCode();\n                    t.semanticCode(false, true);\n                }\n\n                if (cmd !== 'dropdown') {\n                    t.updateButtonPaneStatus();\n                    t.$c.trigger('tbwchange');\n                }\n            }\n        },\n\n\n        // Open a modal box\n        openModal: function (title, content) {\n            var t = this,\n                prefix = t.o.prefix;\n\n            // No open a modal box when exist other modal box\n            if ($('.' + prefix + 'modal-box', t.$box).length > 0) {\n                return false;\n            }\n            if (t.o.autogrowOnEnter) {\n                t.autogrowOnEnterDontClose = true;\n            }\n\n            t.saveRange();\n            t.showOverlay();\n\n            // Disable all btnPane btns\n            t.$btnPane.addClass(prefix + 'disable');\n\n            // Build out of ModalBox, it's the mask for animations\n            var $modal = $('<div/>', {\n                class: prefix + 'modal ' + prefix + 'fixed-top'\n            }).css({\n                top: t.$btnPane.height()\n            }).appendTo(t.$box);\n\n            // Click on overlay close modal by cancelling them\n            t.$overlay.one('click', function () {\n                $modal.trigger(CANCEL_EVENT);\n                return false;\n            });\n\n            // Build the form\n            var $form = $('<form/>', {\n                action: '',\n                html: content\n            })\n                .on('submit', function () {\n                    $modal.trigger(CONFIRM_EVENT);\n                    return false;\n                })\n                .on('reset', function () {\n                    $modal.trigger(CANCEL_EVENT);\n                    return false;\n                })\n                .on('submit reset', function () {\n                    if (t.o.autogrowOnEnter) {\n                        t.autogrowOnEnterDontClose = false;\n                    }\n                });\n\n\n            // Build ModalBox and animate to show them\n            var $box = $('<div/>', {\n                class: prefix + 'modal-box',\n                html: $form\n            })\n                .css({\n                    top: '-' + t.$btnPane.outerHeight() + 'px',\n                    opacity: 0\n                })\n                .appendTo($modal)\n                .animate({\n                    top: 0,\n                    opacity: 1\n                }, 100);\n\n\n            // Append title\n            $('<span/>', {\n                text: title,\n                class: prefix + 'modal-title'\n            }).prependTo($box);\n\n            $modal.height($box.outerHeight() + 10);\n\n\n            // Focus in modal box\n            $('input:first', $box).focus();\n\n\n            // Append Confirm and Cancel buttons\n            t.buildModalBtn('submit', $box);\n            t.buildModalBtn('reset', $box);\n\n\n            $(window).trigger('scroll');\n\n            return $modal;\n        },\n        // @param n is name of modal\n        buildModalBtn: function (n, $modal) {\n            var t = this,\n                prefix = t.o.prefix;\n\n            return $('<button/>', {\n                class: prefix + 'modal-button ' + prefix + 'modal-' + n,\n                type: n,\n                text: t.lang[n] || n\n            }).appendTo($('form', $modal));\n        },\n        // close current modal box\n        closeModal: function () {\n            var t = this,\n                prefix = t.o.prefix;\n\n            t.$btnPane.removeClass(prefix + 'disable');\n            t.$overlay.off();\n\n            // Find the modal box\n            var $modalBox = $('.' + prefix + 'modal-box', t.$box);\n\n            $modalBox.animate({\n                top: '-' + $modalBox.height()\n            }, 100, function () {\n                $modalBox.parent().remove();\n                t.hideOverlay();\n            });\n\n            t.restoreRange();\n        },\n        // Preformated build and management modal\n        openModalInsert: function (title, fields, cmd) {\n            var t = this,\n                prefix = t.o.prefix,\n                lg = t.lang,\n                html = '';\n\n            $.each(fields, function (fieldName, field) {\n                var l = field.label,\n                    n = field.name || fieldName,\n                    a = field.attributes || {};\n\n                var attr = Object.keys(a).map(function (prop) {\n                    return prop + '=\"' + a[prop] + '\"';\n                }).join(' ');\n\n                html += '<label><input type=\"' + (field.type || 'text') + '\" name=\"' + n + '\" value=\"' + (field.value || '').replace(/\"/g, '&quot;') + '\"' + attr + '><span class=\"' + prefix + 'input-infos\"><span>' +\n                    ((!l) ? (lg[fieldName] ? lg[fieldName] : fieldName) : (lg[l] ? lg[l] : l)) +\n                    '</span></span></label>';\n            });\n\n            return t.openModal(title, html)\n                .on(CONFIRM_EVENT, function () {\n                    var $form = $('form', $(this)),\n                        valid = true,\n                        values = {};\n\n                    $.each(fields, function (fieldName, field) {\n                        var $field = $('input[name=\"' + fieldName + '\"]', $form),\n                            inputType = $field.attr('type');\n\n                        if (inputType.toLowerCase() === 'checkbox') {\n                            values[fieldName] = $field.is(':checked');\n                        } else {\n                            values[fieldName] = $.trim($field.val());\n                        }\n                        // Validate value\n                        if (field.required && values[fieldName] === '') {\n                            valid = false;\n                            t.addErrorOnModalField($field, t.lang.required);\n                        } else if (field.pattern && !field.pattern.test(values[fieldName])) {\n                            valid = false;\n                            t.addErrorOnModalField($field, field.patternError);\n                        }\n                    });\n\n                    if (valid) {\n                        t.restoreRange();\n\n                        if (cmd(values, fields)) {\n                            t.syncCode();\n                            t.$c.trigger('tbwchange');\n                            t.closeModal();\n                            $(this).off(CONFIRM_EVENT);\n                        }\n                    }\n                })\n                .one(CANCEL_EVENT, function () {\n                    $(this).off(CONFIRM_EVENT);\n                    t.closeModal();\n                });\n        },\n        addErrorOnModalField: function ($field, err) {\n            var prefix = this.o.prefix,\n                $label = $field.parent();\n\n            $field\n                .on('change keyup', function () {\n                    $label.removeClass(prefix + 'input-error');\n                });\n\n            $label\n                .addClass(prefix + 'input-error')\n                .find('input+span')\n                .append(\n                    $('<span/>', {\n                        class: prefix + 'msg-error',\n                        text: err\n                    })\n                );\n        },\n\n        getDefaultImgDblClickHandler: function () {\n            var t = this;\n\n            return function () {\n                var $img = $(this),\n                    src = $img.attr('src'),\n                    base64 = '(Base64)';\n\n                if (src.indexOf('data:image') === 0) {\n                    src = base64;\n                }\n\n                t.openModalInsert(t.lang.insertImage, {\n                    url: {\n                        label: 'URL',\n                        value: src,\n                        required: true\n                    },\n                    alt: {\n                        label: t.lang.description,\n                        value: $img.attr('alt')\n                    }\n                }, function (v) {\n                    if (v.src !== base64) {\n                        $img.attr({\n                            src: v.src\n                        });\n                    }\n                    $img.attr({\n                        alt: v.alt\n                    });\n                    return true;\n                });\n                return false;\n            };\n        },\n\n        // Range management\n        saveRange: function () {\n            var t = this,\n                documentSelection = t.doc.getSelection();\n\n            t.range = null;\n\n            if (documentSelection.rangeCount) {\n                var savedRange = t.range = documentSelection.getRangeAt(0),\n                    range = t.doc.createRange(),\n                    rangeStart;\n                range.selectNodeContents(t.$ed[0]);\n                range.setEnd(savedRange.startContainer, savedRange.startOffset);\n                rangeStart = (range + '').length;\n                t.metaRange = {\n                    start: rangeStart,\n                    end: rangeStart + (savedRange + '').length\n                };\n            }\n        },\n        restoreRange: function () {\n            var t = this,\n                metaRange = t.metaRange,\n                savedRange = t.range,\n                documentSelection = t.doc.getSelection(),\n                range;\n\n            if (!savedRange) {\n                return;\n            }\n\n            if (metaRange && metaRange.start !== metaRange.end) { // Algorithm from http://jsfiddle.net/WeWy7/3/\n                var charIndex = 0,\n                    nodeStack = [t.$ed[0]],\n                    node,\n                    foundStart = false,\n                    stop = false;\n\n                range = t.doc.createRange();\n\n                while (!stop && (node = nodeStack.pop())) {\n                    if (node.nodeType === 3) {\n                        var nextCharIndex = charIndex + node.length;\n                        if (!foundStart && metaRange.start >= charIndex && metaRange.start <= nextCharIndex) {\n                            range.setStart(node, metaRange.start - charIndex);\n                            foundStart = true;\n                        }\n                        if (foundStart && metaRange.end >= charIndex && metaRange.end <= nextCharIndex) {\n                            range.setEnd(node, metaRange.end - charIndex);\n                            stop = true;\n                        }\n                        charIndex = nextCharIndex;\n                    } else {\n                        var cn = node.childNodes,\n                            i = cn.length;\n\n                        while (i > 0) {\n                            i -= 1;\n                            nodeStack.push(cn[i]);\n                        }\n                    }\n                }\n            }\n\n            documentSelection.removeAllRanges();\n            documentSelection.addRange(range || savedRange);\n        },\n        getRangeText: function () {\n            return this.range + '';\n        },\n\n        updateButtonPaneStatus: function () {\n            var t = this,\n                prefix = t.o.prefix,\n                tags = t.getTagsRecursive(t.doc.getSelection().focusNode),\n                activeClasses = prefix + 'active-button ' + prefix + 'active';\n\n            $('.' + prefix + 'active-button', t.$btnPane).removeClass(activeClasses);\n            $.each(tags, function (i, tag) {\n                var btnName = t.tagToButton[tag.toLowerCase()],\n                    $btn = $('.' + prefix + btnName + '-button', t.$btnPane);\n\n                if ($btn.length > 0) {\n                    $btn.addClass(activeClasses);\n                } else {\n                    try {\n                        $btn = $('.' + prefix + 'dropdown .' + prefix + btnName + '-dropdown-button', t.$box);\n                        var dropdownBtnName = $btn.parent().data('dropdown');\n                        $('.' + prefix + dropdownBtnName + '-button', t.$box).addClass(activeClasses);\n                    } catch (e) {\n                    }\n                }\n            });\n        },\n        getTagsRecursive: function (element, tags) {\n            var t = this;\n            tags = tags || (element && element.tagName ? [element.tagName] : []);\n\n            if (element && element.parentNode) {\n                element = element.parentNode;\n            } else {\n                return tags;\n            }\n\n            var tag = element.tagName;\n            if (tag === 'DIV') {\n                return tags;\n            }\n            if (tag === 'P' && element.style.textAlign !== '') {\n                tags.push(element.style.textAlign);\n            }\n\n            $.each(t.tagHandlers, function (i, tagHandler) {\n                tags = tags.concat(tagHandler(element, t));\n            });\n\n            tags.push(tag);\n\n            return t.getTagsRecursive(element, tags).filter(function(tag) { return tag != null; });\n        },\n\n        // Plugins\n        initPlugins: function () {\n            var t = this;\n            t.loadedPlugins = [];\n            $.each($.trumbowyg.plugins, function (name, plugin) {\n                if (!plugin.shouldInit || plugin.shouldInit(t)) {\n                    plugin.init(t);\n                    if (plugin.tagHandler) {\n                        t.tagHandlers.push(plugin.tagHandler);\n                    }\n                    t.loadedPlugins.push(plugin);\n                }\n            });\n        },\n        destroyPlugins: function () {\n            $.each(this.loadedPlugins, function (i, plugin) {\n                if (plugin.destroy) {\n                    plugin.destroy();\n                }\n            });\n        }\n    };\n})(navigator, window, document, jQuery);\n"
  },
  {
    "path": "pubnot/trumbowyg/ui/sass/trumbowyg.scss",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n$light-color: #ecf0f1 !default;\n$dark-color: #222 !default;\n\n$modal-submit-color: #2ecc71 !default;\n$modal-reset-color: #EEE !default;\n\n$transition-duration: 150ms !default;\n$slow-transition-duration: 300ms !default;\n\n#trumbowyg-icons {\n    overflow: hidden;\n    visibility: hidden;\n    height: 0;\n    width: 0;\n\n    svg {\n        height: 0;\n        width: 0;\n    }\n}\n\n.trumbowyg-box {\n    *,\n    *::before,\n    *::after {\n        box-sizing: border-box;\n    }\n\n    svg {\n        width: 17px;\n        height: 100%;\n        fill: $dark-color;\n    }\n}\n\n.trumbowyg-box,\n.trumbowyg-editor {\n    display: block;\n    position: relative;\n    border: 1px solid #DDD;\n    width: 100%;\n    min-height: 300px;\n    margin: 17px auto;\n}\n\n.trumbowyg-box .trumbowyg-editor {\n    margin: 0 auto;\n}\n\n.trumbowyg-box.trumbowyg-fullscreen {\n    background: #FEFEFE;\n    border: none !important;\n}\n\n.trumbowyg-editor,\n.trumbowyg-textarea {\n    position: relative;\n    box-sizing: border-box;\n    padding: 20px;\n    min-height: 300px;\n    width: 100%;\n    border-style: none;\n    resize: none;\n    outline: none;\n    overflow: auto;\n\n    &.trumbowyg-autogrow-on-enter {\n        transition: height $slow-transition-duration ease-out;\n    }\n}\n\n.trumbowyg-box-blur .trumbowyg-editor {\n    *,\n    &::before {\n        color: transparent !important;\n        text-shadow: 0 0 7px #333;\n\n        @media screen and (min-width: 0 \\0) {\n            color: rgba(200, 200, 200, 0.6) !important;\n        }\n        @supports (-ms-accelerator:true) {\n            color: rgba(200, 200, 200, 0.6) !important;\n        }\n    }\n    img,\n    hr {\n        opacity: 0.2;\n    }\n}\n\n.trumbowyg-textarea {\n    position: relative;\n    display: block;\n    overflow: auto;\n    border: none;\n    white-space: normal;\n    font-size: 14px;\n    font-family: \"Inconsolata\", \"Consolas\", \"Courier\", \"Courier New\", sans-serif;\n    line-height: 18px;\n}\n\n.trumbowyg-box.trumbowyg-editor-visible {\n    .trumbowyg-textarea {\n        height: 1px !important;\n        width: 25%;\n        min-height: 0 !important;\n        padding: 0 !important;\n        background: none;\n        opacity: 0 !important;\n    }\n}\n\n.trumbowyg-box.trumbowyg-editor-hidden {\n    .trumbowyg-textarea {\n        display: block;\n    }\n    .trumbowyg-editor {\n        display: none;\n    }\n}\n\n.trumbowyg-box.trumbowyg-disabled {\n    .trumbowyg-textarea {\n        opacity: 0.8;\n        background: none;\n    }\n}\n\n.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n    content: attr(placeholder);\n    color: #999;\n    pointer-events: none;\n}\n\n.trumbowyg-button-pane {\n    width: 100%;\n    min-height: 36px;\n    background: $light-color;\n    border-bottom: 1px solid darken($light-color, 7%);\n    margin: 0;\n    padding: 0 5px;\n    position: relative;\n    list-style-type: none;\n    line-height: 10px;\n    backface-visibility: hidden;\n    z-index: 11;\n\n    &::after {\n        content: \" \";\n        display: block;\n        position: absolute;\n        top: 36px;\n        left: 0;\n        right: 0;\n        width: 100%;\n        height: 1px;\n        background: darken($light-color, 7%);\n    }\n\n    .trumbowyg-button-group {\n        display: inline-block;\n\n        .trumbowyg-fullscreen-button svg {\n            color: transparent;\n        }\n\n        &:not(:empty) + .trumbowyg-button-group::before {\n            content: \" \";\n            display: inline-block;\n            width: 1px;\n            background: darken($light-color, 7%);\n            margin: 0 5px;\n            height: 35px;\n            vertical-align: top;\n        }\n    }\n\n    button {\n        display: inline-block;\n        position: relative;\n        width: 35px;\n        height: 35px;\n        padding: 1px 6px !important;\n        margin-bottom: 1px;\n        overflow: hidden;\n        border: none;\n        cursor: pointer;\n        background: none;\n        vertical-align: middle;\n        transition: background-color $transition-duration, opacity $transition-duration;\n\n        &.trumbowyg-textual-button {\n            width: auto;\n            line-height: 35px;\n            user-select: none;\n        }\n    }\n\n    &.trumbowyg-disable button:not(.trumbowyg-not-disable):not(.trumbowyg-active),\n    .trumbowyg-disabled & button:not(.trumbowyg-not-disable):not(.trumbowyg-viewHTML-button) {\n        opacity: 0.2;\n        cursor: default;\n    }\n    &.trumbowyg-disable,\n    .trumbowyg-disabled & {\n        .trumbowyg-button-group::before {\n            background: darken($light-color, 3%);\n        }\n    }\n\n    button:not(.trumbowyg-disable):hover,\n    button:not(.trumbowyg-disable):focus,\n    button.trumbowyg-active {\n        background-color: #FFF;\n        outline: none;\n    }\n\n    .trumbowyg-open-dropdown {\n        &::after {\n            display: block;\n            content: \" \";\n            position: absolute;\n            top: 25px;\n            right: 3px;\n            height: 0;\n            width: 0;\n            border: 3px solid transparent;\n            border-top-color: #555;\n        }\n\n        &.trumbowyg-textual-button {\n            padding-left: 10px !important;\n            padding-right: 18px !important;\n\n            &::after {\n                top: 17px;\n                right: 7px;\n            }\n        }\n    }\n\n    .trumbowyg-right {\n        float: right;\n\n        &::before {\n            display: none !important;\n        }\n    }\n}\n\n.trumbowyg-dropdown {\n    width: 200px;\n    border: 1px solid $light-color;\n    padding: 5px 0;\n    border-top: none;\n    background: #FFF;\n    margin-left: -1px;\n    box-shadow: rgba(0, 0, 0, .1) 0 2px 3px;\n    z-index: 11;\n\n    button {\n        display: block;\n        width: 100%;\n        height: 35px;\n        line-height: 35px;\n        text-decoration: none;\n        background: #FFF;\n        padding: 0 10px;\n        color: #333 !important;\n        border: none;\n        cursor: pointer;\n        text-align: left;\n        font-size: 15px;\n        transition: all $transition-duration;\n\n        &:hover,\n        &:focus {\n            background: $light-color;\n        }\n\n        svg {\n            float: left;\n            margin-right: 14px;\n        }\n    }\n}\n\n/* Modal box */\n.trumbowyg-modal {\n    position: absolute;\n    top: 0;\n    left: 50%;\n    transform: translateX(-50%);\n    max-width: 520px;\n    width: 100%;\n    height: 350px;\n    z-index: 11;\n    overflow: hidden;\n    backface-visibility: hidden;\n}\n\n.trumbowyg-modal-box {\n    position: absolute;\n    top: 0;\n    left: 50%;\n    transform: translateX(-50%);\n    max-width: 500px;\n    width: calc(100% - 20px);\n    padding-bottom: 45px;\n    z-index: 1;\n    background-color: #FFF;\n    text-align: center;\n    font-size: 14px;\n    box-shadow: rgba(0, 0, 0, .2) 0 2px 3px;\n    backface-visibility: hidden;\n\n    .trumbowyg-modal-title {\n        font-size: 24px;\n        font-weight: bold;\n        margin: 0 0 20px;\n        padding: 15px 0 13px;\n        display: block;\n        border-bottom: 1px solid #EEE;\n        color: #333;\n        background: lighten($light-color, 5%);\n    }\n\n    .trumbowyg-progress {\n        width: 100%;\n        height: 3px;\n        position: absolute;\n        top: 58px;\n\n        .trumbowyg-progress-bar {\n            background: #2BC06A;\n            width: 0;\n            height: 100%;\n            transition: width $transition-duration linear;\n        }\n    }\n\n    label {\n        display: block;\n        position: relative;\n        margin: 15px 12px;\n        height: 29px;\n        line-height: 29px;\n        overflow: hidden;\n\n        .trumbowyg-input-infos {\n            display: block;\n            text-align: left;\n            height: 25px;\n            line-height: 25px;\n            transition: all 150ms;\n\n            span {\n                display: block;\n                color: darken($light-color, 45%);\n                background-color: lighten($light-color, 5%);\n                border: 1px solid #DEDEDE;\n                padding: 0 7px;\n                width: 150px;\n            }\n            span.trumbowyg-msg-error {\n                color: #e74c3c;\n            }\n        }\n\n        &.trumbowyg-input-error {\n            input,\n            textarea {\n                border: 1px solid #e74c3c;\n            }\n\n            .trumbowyg-input-infos {\n                margin-top: -27px;\n            }\n        }\n\n        input {\n            position: absolute;\n            top: 0;\n            right: 0;\n            height: 27px;\n            line-height: 27px;\n            border: 1px solid #DEDEDE;\n            background: #fff;\n            font-size: 14px;\n            max-width: 330px;\n            width: 70%;\n            padding: 0 7px;\n            transition: all $transition-duration;\n\n            &:hover,\n            &:focus {\n                outline: none;\n                border: 1px solid #95a5a6;\n            }\n            &:focus {\n                background: lighten($light-color, 5%);\n            }\n        }\n    }\n\n    .error {\n        margin-top: 25px;\n        display: block;\n        color: red;\n    }\n\n    .trumbowyg-modal-button {\n        position: absolute;\n        bottom: 10px;\n        right: 0;\n        text-decoration: none;\n        color: #FFF;\n        display: block;\n        width: 100px;\n        height: 35px;\n        line-height: 33px;\n        margin: 0 10px;\n        background-color: #333;\n        border: none;\n        cursor: pointer;\n        font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif;\n        font-size: 16px;\n        transition: all $transition-duration;\n\n        &.trumbowyg-modal-submit {\n            right: 110px;\n            background: darken($modal-submit-color, 3%);\n\n            &:hover,\n            &:focus {\n                background: lighten($modal-submit-color, 5%);\n                outline: none;\n            }\n            &:active {\n                background: darken($modal-submit-color, 10%);\n            }\n        }\n\n        &.trumbowyg-modal-reset {\n            color: #555;\n            background: darken($modal-reset-color, 3%);\n\n            &:hover,\n            &:focus {\n                background: lighten($modal-reset-color, 5%);\n                outline: none;\n            }\n            &:active {\n                background: darken($modal-reset-color, 10%);\n            }\n        }\n    }\n}\n\n.trumbowyg-overlay {\n    position: absolute;\n    background-color: rgba(255, 255, 255, 0.5);\n    height: 100%;\n    width: 100%;\n    left: 0;\n    display: none;\n    top: 0;\n    z-index: 10;\n}\n\n/**\n * Fullscreen\n */\nbody.trumbowyg-body-fullscreen {\n    overflow: hidden;\n}\n\n.trumbowyg-fullscreen {\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    margin: 0;\n    padding: 0;\n    z-index: 99999;\n\n    &.trumbowyg-box,\n    .trumbowyg-editor {\n        border: none;\n    }\n    .trumbowyg-editor,\n    .trumbowyg-textarea {\n        height: calc(100% - 37px) !important;\n        overflow: auto;\n    }\n    .trumbowyg-overlay {\n        height: 100% !important;\n    }\n    .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n        color: $dark-color;\n        fill: transparent;\n    }\n}\n\n.trumbowyg-editor {\n    object,\n    embed,\n    video,\n    img {\n        max-width: 100%;\n    }\n    video,\n    img {\n        height: auto;\n    }\n    img {\n        cursor: move;\n    }\n\n    /*\n     * lset for resetCss option\n     */\n    &.trumbowyg-reset-css {\n        background: #FEFEFE !important;\n        font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n        font-size: 14px !important;\n        line-height: 1.45em !important;\n        white-space: normal !important;\n        color: #333;\n\n        a {\n            color: #15c !important;\n            text-decoration: underline !important;\n        }\n\n        div,\n        p,\n        ul,\n        ol,\n        blockquote {\n            box-shadow: none !important;\n            background: none !important;\n            margin: 0 !important;\n            margin-bottom: 15px !important;\n            line-height: 1.4em !important;\n            font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n            font-size: 14px !important;\n            border: none;\n        }\n        iframe,\n        object,\n        hr {\n            margin-bottom: 15px !important;\n        }\n        blockquote {\n            margin-left: 32px !important;\n            font-style: italic !important;\n            color: #555;\n        }\n        ul,\n        ol {\n            padding-left: 20px !important;\n        }\n        ul ul,\n        ol ol,\n        ul ol,\n        ol ul {\n            border: none;\n            margin: 2px !important;\n            padding: 0 !important;\n            padding-left: 24px !important;\n        }\n        hr {\n            display: block;\n            height: 1px;\n            border: none;\n            border-top: 1px solid #CCC;\n        }\n\n        h1,\n        h2,\n        h3,\n        h4 {\n            color: #111;\n            background: none;\n            margin: 0 !important;\n            padding: 0 !important;\n            font-weight: bold;\n        }\n\n        h1 {\n            font-size: 32px !important;\n            line-height: 38px !important;\n            margin-bottom: 20px !important;\n        }\n        h2 {\n            font-size: 26px !important;\n            line-height: 34px !important;\n            margin-bottom: 15px !important;\n        }\n        h3 {\n            font-size: 22px !important;\n            line-height: 28px !important;\n            margin-bottom: 7px !important;\n        }\n        h4 {\n            font-size: 16px !important;\n            line-height: 22px !important;\n            margin-bottom: 7px !important;\n        }\n    }\n}\n\n/*\n * Dark theme\n */\n.trumbowyg-dark {\n    .trumbowyg-textarea {\n        background: #111;\n        color: #ddd;\n    }\n    .trumbowyg-box {\n        border: 1px solid lighten($dark-color, 7%);\n\n        &.trumbowyg-fullscreen {\n            background: #111;\n        }\n        &.trumbowyg-box-blur .trumbowyg-editor {\n            *,\n            &::before {\n                text-shadow: 0 0 7px #ccc;\n\n                @media screen and (min-width: 0 \\0\n                ) {\n                    color: rgba(20, 20, 20, 0.6) !important;\n                }\n                @supports (-ms-accelerator:true) {\n                    color: rgba(20, 20, 20, 0.6) !important;\n                }\n            }\n        }\n\n        svg {\n            fill: $light-color;\n            color: $light-color;\n        }\n    }\n    .trumbowyg-button-pane {\n        background-color: $dark-color;\n        border-bottom-color: lighten($dark-color, 7%);\n\n        &::after {\n            background: lighten($dark-color, 7%);\n        }\n\n        .trumbowyg-button-group:not(:empty) {\n            &::before {\n                background-color: lighten($dark-color, 7%);\n            }\n            .trumbowyg-fullscreen-button svg {\n                color: transparent;\n            }\n        }\n\n        &.trumbowyg-disable {\n            .trumbowyg-button-group::before {\n                background-color: lighten($dark-color, 3%);\n            }\n        }\n\n        button:not(.trumbowyg-disable):hover,\n        button:not(.trumbowyg-disable):focus,\n        button.trumbowyg-active {\n            background-color: #333;\n        }\n\n        .trumbowyg-open-dropdown::after {\n            border-top-color: #fff;\n        }\n    }\n    .trumbowyg-fullscreen {\n        .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n            color: $light-color;\n            fill: transparent;\n        }\n    }\n\n    .trumbowyg-dropdown {\n        border-color: $dark-color;\n        background: #333;\n        box-shadow: rgba(0, 0, 0, .3) 0 2px 3px;\n\n        button {\n            background: #333;\n            color: #fff !important;\n\n            &:hover,\n            &:focus {\n                background: $dark-color;\n            }\n        }\n    }\n\n    // Modal box\n    .trumbowyg-modal-box {\n        background-color: $dark-color;\n\n        .trumbowyg-modal-title {\n            border-bottom: 1px solid #555;\n            color: #fff;\n            background: lighten($dark-color, 10%);\n        }\n\n        label {\n            display: block;\n            position: relative;\n            margin: 15px 12px;\n            height: 27px;\n            line-height: 27px;\n            overflow: hidden;\n\n            .trumbowyg-input-infos {\n                span {\n                    color: #eee;\n                    background-color: lighten($dark-color, 5%);\n                    border-color: $dark-color;\n                }\n                span.trumbowyg-msg-error {\n                    color: #e74c3c;\n                }\n            }\n\n            &.trumbowyg-input-error {\n                input,\n                textarea {\n                    border-color: #e74c3c;\n                }\n            }\n\n            input {\n                border-color: $dark-color;\n                color: #eee;\n                background: #333;\n\n                &:hover,\n                &:focus {\n                    border-color: lighten($dark-color, 25%);\n                }\n                &:focus {\n                    background-color: lighten($dark-color, 5%);\n                }\n            }\n        }\n\n        .trumbowyg-modal-button {\n            &.trumbowyg-modal-submit {\n                background: darken($modal-submit-color, 20%);\n\n                &:hover,\n                &:focus {\n                    background: darken($modal-submit-color, 10%);\n                }\n                &:active {\n                    background: darken($modal-submit-color, 25%);\n                }\n            }\n            &.trumbowyg-modal-reset {\n                background: #333;\n                color: #ccc;\n\n                &:hover,\n                &:focus {\n                    background: #444;\n                }\n                &:active {\n                    background: #111;\n                }\n            }\n        }\n    }\n    .trumbowyg-overlay {\n        background-color: rgba(15, 15, 15, 0.6);\n    }\n}\n"
  },
  {
    "path": "pubnot/trumbowyg/ui/trumbowyg.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n *         Twitter : @AlexandreDemode\n *         Website : alex-d.fr\n */\n\n#trumbowyg-icons {\n  overflow: hidden;\n  visibility: hidden;\n  height: 0;\n  width: 0; }\n  #trumbowyg-icons svg {\n    height: 0;\n    width: 0; }\n\n.trumbowyg-box *,\n.trumbowyg-box *::before,\n.trumbowyg-box *::after {\n  box-sizing: border-box; }\n\n.trumbowyg-box svg {\n  width: 17px;\n  height: 100%;\n  fill: #222; }\n\n.trumbowyg-box,\n.trumbowyg-editor {\n  display: block;\n  position: relative;\n  border: 1px solid #DDD;\n  width: 100%;\n  min-height: 300px;\n  margin: 17px auto; }\n\n.trumbowyg-box .trumbowyg-editor {\n  margin: 0 auto; }\n\n.trumbowyg-box.trumbowyg-fullscreen {\n  background: #FEFEFE;\n  border: none !important; }\n\n.trumbowyg-editor,\n.trumbowyg-textarea {\n  position: relative;\n  box-sizing: border-box;\n  padding: 20px;\n  min-height: 300px;\n  width: 100%;\n  border-style: none;\n  resize: none;\n  outline: none;\n  overflow: auto; }\n  .trumbowyg-editor.trumbowyg-autogrow-on-enter,\n  .trumbowyg-textarea.trumbowyg-autogrow-on-enter {\n    transition: height 300ms ease-out; }\n\n.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n  color: transparent !important;\n  text-shadow: 0 0 7px #333; }\n  @media screen and (min-width: 0 \\0) {\n    .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n      color: rgba(200, 200, 200, 0.6) !important; } }\n  @supports (-ms-accelerator: true) {\n    .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n      color: rgba(200, 200, 200, 0.6) !important; } }\n\n.trumbowyg-box-blur .trumbowyg-editor img,\n.trumbowyg-box-blur .trumbowyg-editor hr {\n  opacity: 0.2; }\n\n.trumbowyg-textarea {\n  position: relative;\n  display: block;\n  overflow: auto;\n  border: none;\n  white-space: normal;\n  font-size: 14px;\n  font-family: \"Inconsolata\", \"Consolas\", \"Courier\", \"Courier New\", sans-serif;\n  line-height: 18px; }\n\n.trumbowyg-box.trumbowyg-editor-visible .trumbowyg-textarea {\n  height: 1px !important;\n  width: 25%;\n  min-height: 0 !important;\n  padding: 0 !important;\n  background: none;\n  opacity: 0 !important; }\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-textarea {\n  display: block; }\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-editor {\n  display: none; }\n\n.trumbowyg-box.trumbowyg-disabled .trumbowyg-textarea {\n  opacity: 0.8;\n  background: none; }\n\n.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n  content: attr(placeholder);\n  color: #999;\n  pointer-events: none; }\n\n.trumbowyg-button-pane {\n  width: 100%;\n  min-height: 36px;\n  background: #ecf0f1;\n  border-bottom: 1px solid #d7e0e2;\n  margin: 0;\n  padding: 0 5px;\n  position: relative;\n  list-style-type: none;\n  line-height: 10px;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden;\n  z-index: 11; }\n  .trumbowyg-button-pane::after {\n    content: \" \";\n    display: block;\n    position: absolute;\n    top: 36px;\n    left: 0;\n    right: 0;\n    width: 100%;\n    height: 1px;\n    background: #d7e0e2; }\n  .trumbowyg-button-pane .trumbowyg-button-group {\n    display: inline-block; }\n    .trumbowyg-button-pane .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n      color: transparent; }\n    .trumbowyg-button-pane .trumbowyg-button-group:not(:empty) + .trumbowyg-button-group::before {\n      content: \" \";\n      display: inline-block;\n      width: 1px;\n      background: #d7e0e2;\n      margin: 0 5px;\n      height: 35px;\n      vertical-align: top; }\n  .trumbowyg-button-pane button {\n    display: inline-block;\n    position: relative;\n    width: 35px;\n    height: 35px;\n    padding: 1px 6px !important;\n    margin-bottom: 1px;\n    overflow: hidden;\n    border: none;\n    cursor: pointer;\n    background: none;\n    vertical-align: middle;\n    transition: background-color 150ms, opacity 150ms; }\n    .trumbowyg-button-pane button.trumbowyg-textual-button {\n      width: auto;\n      line-height: 35px;\n      -webkit-user-select: none;\n         -moz-user-select: none;\n          -ms-user-select: none;\n              user-select: none; }\n  .trumbowyg-button-pane.trumbowyg-disable button:not(.trumbowyg-not-disable):not(.trumbowyg-active),\n  .trumbowyg-disabled .trumbowyg-button-pane button:not(.trumbowyg-not-disable):not(.trumbowyg-viewHTML-button) {\n    opacity: 0.2;\n    cursor: default; }\n  .trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before,\n  .trumbowyg-disabled .trumbowyg-button-pane .trumbowyg-button-group::before {\n    background: #e3e9eb; }\n  .trumbowyg-button-pane button:not(.trumbowyg-disable):hover,\n  .trumbowyg-button-pane button:not(.trumbowyg-disable):focus,\n  .trumbowyg-button-pane button.trumbowyg-active {\n    background-color: #FFF;\n    outline: none; }\n  .trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n    display: block;\n    content: \" \";\n    position: absolute;\n    top: 25px;\n    right: 3px;\n    height: 0;\n    width: 0;\n    border: 3px solid transparent;\n    border-top-color: #555; }\n  .trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button {\n    padding-left: 10px !important;\n    padding-right: 18px !important; }\n    .trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button::after {\n      top: 17px;\n      right: 7px; }\n  .trumbowyg-button-pane .trumbowyg-right {\n    float: right; }\n    .trumbowyg-button-pane .trumbowyg-right::before {\n      display: none !important; }\n\n.trumbowyg-dropdown {\n  width: 200px;\n  border: 1px solid #ecf0f1;\n  padding: 5px 0;\n  border-top: none;\n  background: #FFF;\n  margin-left: -1px;\n  box-shadow: rgba(0, 0, 0, 0.1) 0 2px 3px;\n  z-index: 11; }\n  .trumbowyg-dropdown button {\n    display: block;\n    width: 100%;\n    height: 35px;\n    line-height: 35px;\n    text-decoration: none;\n    background: #FFF;\n    padding: 0 10px;\n    color: #333 !important;\n    border: none;\n    cursor: pointer;\n    text-align: left;\n    font-size: 15px;\n    transition: all 150ms; }\n    .trumbowyg-dropdown button:hover, .trumbowyg-dropdown button:focus {\n      background: #ecf0f1; }\n    .trumbowyg-dropdown button svg {\n      float: left;\n      margin-right: 14px; }\n\n/* Modal box */\n.trumbowyg-modal {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n          transform: translateX(-50%);\n  max-width: 520px;\n  width: 100%;\n  height: 350px;\n  z-index: 11;\n  overflow: hidden;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden; }\n\n.trumbowyg-modal-box {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n          transform: translateX(-50%);\n  max-width: 500px;\n  width: calc(100% - 20px);\n  padding-bottom: 45px;\n  z-index: 1;\n  background-color: #FFF;\n  text-align: center;\n  font-size: 14px;\n  box-shadow: rgba(0, 0, 0, 0.2) 0 2px 3px;\n  -webkit-backface-visibility: hidden;\n          backface-visibility: hidden; }\n  .trumbowyg-modal-box .trumbowyg-modal-title {\n    font-size: 24px;\n    font-weight: bold;\n    margin: 0 0 20px;\n    padding: 15px 0 13px;\n    display: block;\n    border-bottom: 1px solid #EEE;\n    color: #333;\n    background: #fbfcfc; }\n  .trumbowyg-modal-box .trumbowyg-progress {\n    width: 100%;\n    height: 3px;\n    position: absolute;\n    top: 58px; }\n    .trumbowyg-modal-box .trumbowyg-progress .trumbowyg-progress-bar {\n      background: #2BC06A;\n      width: 0;\n      height: 100%;\n      transition: width 150ms linear; }\n  .trumbowyg-modal-box label {\n    display: block;\n    position: relative;\n    margin: 15px 12px;\n    height: 29px;\n    line-height: 29px;\n    overflow: hidden; }\n    .trumbowyg-modal-box label .trumbowyg-input-infos {\n      display: block;\n      text-align: left;\n      height: 25px;\n      line-height: 25px;\n      transition: all 150ms; }\n      .trumbowyg-modal-box label .trumbowyg-input-infos span {\n        display: block;\n        color: #69878f;\n        background-color: #fbfcfc;\n        border: 1px solid #DEDEDE;\n        padding: 0 7px;\n        width: 150px; }\n      .trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n        color: #e74c3c; }\n    .trumbowyg-modal-box label.trumbowyg-input-error input,\n    .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n      border: 1px solid #e74c3c; }\n    .trumbowyg-modal-box label.trumbowyg-input-error .trumbowyg-input-infos {\n      margin-top: -27px; }\n    .trumbowyg-modal-box label input {\n      position: absolute;\n      top: 0;\n      right: 0;\n      height: 27px;\n      line-height: 27px;\n      border: 1px solid #DEDEDE;\n      background: #fff;\n      font-size: 14px;\n      max-width: 330px;\n      width: 70%;\n      padding: 0 7px;\n      transition: all 150ms; }\n      .trumbowyg-modal-box label input:hover, .trumbowyg-modal-box label input:focus {\n        outline: none;\n        border: 1px solid #95a5a6; }\n      .trumbowyg-modal-box label input:focus {\n        background: #fbfcfc; }\n  .trumbowyg-modal-box .error {\n    margin-top: 25px;\n    display: block;\n    color: red; }\n  .trumbowyg-modal-box .trumbowyg-modal-button {\n    position: absolute;\n    bottom: 10px;\n    right: 0;\n    text-decoration: none;\n    color: #FFF;\n    display: block;\n    width: 100px;\n    height: 35px;\n    line-height: 33px;\n    margin: 0 10px;\n    background-color: #333;\n    border: none;\n    cursor: pointer;\n    font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif;\n    font-size: 16px;\n    transition: all 150ms; }\n    .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n      right: 110px;\n      background: #2bc06a; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n        background: #40d47e;\n        outline: none; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n        background: #25a25a; }\n    .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n      color: #555;\n      background: #e6e6e6; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n        background: #fbfbfb;\n        outline: none; }\n      .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n        background: #d5d5d5; }\n\n.trumbowyg-overlay {\n  position: absolute;\n  background-color: rgba(255, 255, 255, 0.5);\n  height: 100%;\n  width: 100%;\n  left: 0;\n  display: none;\n  top: 0;\n  z-index: 10; }\n\n/**\n * Fullscreen\n */\nbody.trumbowyg-body-fullscreen {\n  overflow: hidden; }\n\n.trumbowyg-fullscreen {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  z-index: 99999; }\n  .trumbowyg-fullscreen.trumbowyg-box,\n  .trumbowyg-fullscreen .trumbowyg-editor {\n    border: none; }\n  .trumbowyg-fullscreen .trumbowyg-editor,\n  .trumbowyg-fullscreen .trumbowyg-textarea {\n    height: calc(100% - 37px) !important;\n    overflow: auto; }\n  .trumbowyg-fullscreen .trumbowyg-overlay {\n    height: 100% !important; }\n  .trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n    color: #222;\n    fill: transparent; }\n\n.trumbowyg-editor {\n  /*\n     * lset for resetCss option\n     */ }\n  .trumbowyg-editor object,\n  .trumbowyg-editor embed,\n  .trumbowyg-editor video,\n  .trumbowyg-editor img {\n    max-width: 100%; }\n  .trumbowyg-editor video,\n  .trumbowyg-editor img {\n    height: auto; }\n  .trumbowyg-editor img {\n    cursor: move; }\n  .trumbowyg-editor.trumbowyg-reset-css {\n    background: #FEFEFE !important;\n    font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n    font-size: 14px !important;\n    line-height: 1.45em !important;\n    white-space: normal !important;\n    color: #333; }\n    .trumbowyg-editor.trumbowyg-reset-css a {\n      color: #15c !important;\n      text-decoration: underline !important; }\n    .trumbowyg-editor.trumbowyg-reset-css div,\n    .trumbowyg-editor.trumbowyg-reset-css p,\n    .trumbowyg-editor.trumbowyg-reset-css ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol,\n    .trumbowyg-editor.trumbowyg-reset-css blockquote {\n      box-shadow: none !important;\n      background: none !important;\n      margin: 0 !important;\n      margin-bottom: 15px !important;\n      line-height: 1.4em !important;\n      font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n      font-size: 14px !important;\n      border: none; }\n    .trumbowyg-editor.trumbowyg-reset-css iframe,\n    .trumbowyg-editor.trumbowyg-reset-css object,\n    .trumbowyg-editor.trumbowyg-reset-css hr {\n      margin-bottom: 15px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css blockquote {\n      margin-left: 32px !important;\n      font-style: italic !important;\n      color: #555; }\n    .trumbowyg-editor.trumbowyg-reset-css ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol {\n      padding-left: 20px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css ul ul,\n    .trumbowyg-editor.trumbowyg-reset-css ol ol,\n    .trumbowyg-editor.trumbowyg-reset-css ul ol,\n    .trumbowyg-editor.trumbowyg-reset-css ol ul {\n      border: none;\n      margin: 2px !important;\n      padding: 0 !important;\n      padding-left: 24px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css hr {\n      display: block;\n      height: 1px;\n      border: none;\n      border-top: 1px solid #CCC; }\n    .trumbowyg-editor.trumbowyg-reset-css h1,\n    .trumbowyg-editor.trumbowyg-reset-css h2,\n    .trumbowyg-editor.trumbowyg-reset-css h3,\n    .trumbowyg-editor.trumbowyg-reset-css h4 {\n      color: #111;\n      background: none;\n      margin: 0 !important;\n      padding: 0 !important;\n      font-weight: bold; }\n    .trumbowyg-editor.trumbowyg-reset-css h1 {\n      font-size: 32px !important;\n      line-height: 38px !important;\n      margin-bottom: 20px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h2 {\n      font-size: 26px !important;\n      line-height: 34px !important;\n      margin-bottom: 15px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h3 {\n      font-size: 22px !important;\n      line-height: 28px !important;\n      margin-bottom: 7px !important; }\n    .trumbowyg-editor.trumbowyg-reset-css h4 {\n      font-size: 16px !important;\n      line-height: 22px !important;\n      margin-bottom: 7px !important; }\n\n/*\n * Dark theme\n */\n.trumbowyg-dark .trumbowyg-textarea {\n  background: #111;\n  color: #ddd; }\n\n.trumbowyg-dark .trumbowyg-box {\n  border: 1px solid #343434; }\n  .trumbowyg-dark .trumbowyg-box.trumbowyg-fullscreen {\n    background: #111; }\n  .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n    text-shadow: 0 0 7px #ccc; }\n    @media screen and (min-width: 0 \\0 ) {\n      .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n        color: rgba(20, 20, 20, 0.6) !important; } }\n    @supports (-ms-accelerator: true) {\n      .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-dark .trumbowyg-box.trumbowyg-box-blur .trumbowyg-editor::before {\n        color: rgba(20, 20, 20, 0.6) !important; } }\n  .trumbowyg-dark .trumbowyg-box svg {\n    fill: #ecf0f1;\n    color: #ecf0f1; }\n\n.trumbowyg-dark .trumbowyg-button-pane {\n  background-color: #222;\n  border-bottom-color: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane::after {\n    background: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-button-group:not(:empty)::before {\n    background-color: #343434; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-button-group:not(:empty) .trumbowyg-fullscreen-button svg {\n    color: transparent; }\n  .trumbowyg-dark .trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before {\n    background-color: #2a2a2a; }\n  .trumbowyg-dark .trumbowyg-button-pane button:not(.trumbowyg-disable):hover,\n  .trumbowyg-dark .trumbowyg-button-pane button:not(.trumbowyg-disable):focus,\n  .trumbowyg-dark .trumbowyg-button-pane button.trumbowyg-active {\n    background-color: #333; }\n  .trumbowyg-dark .trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n    border-top-color: #fff; }\n\n.trumbowyg-dark .trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: #ecf0f1;\n  fill: transparent; }\n\n.trumbowyg-dark .trumbowyg-dropdown {\n  border-color: #222;\n  background: #333;\n  box-shadow: rgba(0, 0, 0, 0.3) 0 2px 3px; }\n  .trumbowyg-dark .trumbowyg-dropdown button {\n    background: #333;\n    color: #fff !important; }\n    .trumbowyg-dark .trumbowyg-dropdown button:hover, .trumbowyg-dark .trumbowyg-dropdown button:focus {\n      background: #222; }\n\n.trumbowyg-dark .trumbowyg-modal-box {\n  background-color: #222; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-title {\n    border-bottom: 1px solid #555;\n    color: #fff;\n    background: #3c3c3c; }\n  .trumbowyg-dark .trumbowyg-modal-box label {\n    display: block;\n    position: relative;\n    margin: 15px 12px;\n    height: 27px;\n    line-height: 27px;\n    overflow: hidden; }\n    .trumbowyg-dark .trumbowyg-modal-box label .trumbowyg-input-infos span {\n      color: #eee;\n      background-color: #2f2f2f;\n      border-color: #222; }\n    .trumbowyg-dark .trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n      color: #e74c3c; }\n    .trumbowyg-dark .trumbowyg-modal-box label.trumbowyg-input-error input,\n    .trumbowyg-dark .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n      border-color: #e74c3c; }\n    .trumbowyg-dark .trumbowyg-modal-box label input {\n      border-color: #222;\n      color: #eee;\n      background: #333; }\n      .trumbowyg-dark .trumbowyg-modal-box label input:hover, .trumbowyg-dark .trumbowyg-modal-box label input:focus {\n        border-color: #626262; }\n      .trumbowyg-dark .trumbowyg-modal-box label input:focus {\n        background-color: #2f2f2f; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n    background: #1b7943; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n      background: #25a25a; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n      background: #176437; }\n  .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n    background: #333;\n    color: #ccc; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n      background: #444; }\n    .trumbowyg-dark .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n      background: #111; }\n\n.trumbowyg-dark .trumbowyg-overlay {\n  background-color: rgba(15, 15, 15, 0.6); }\n"
  },
  {
    "path": "pubnot/trumbowyg/ui/trumbowyg.custom.css",
    "content": "/**\n * Trumbowyg v2.8.1 - A lightweight WYSIWYG editor\n * Default stylesheet for Trumbowyg editor. Modified by Azareal.\n * ------------------------\n * @link http://alex-d.github.io/Trumbowyg & https://github.com/Azareal/Gosora\n * @license MIT\n * @author Alexandre Demode (Alex-D)\n * @author Azareal\n */\n\n#trumbowyg-icons {\n  overflow: hidden;\n  visibility: hidden;\n  height: 0;\n  width: 0;\n}\n#trumbowyg-icons svg {\n  height: 0;\n  width: 0;\n}\n\n.trumbowyg-box *, .trumbowyg-box *::before, .trumbowyg-box *::after {\n  box-sizing: border-box;\n}\n\n.trumbowyg-box svg {\n  width: 17px;\n  height: 100%;\n  fill: #222222;\n  color: #222222;\n  opacity: 0.5;\n}\n.trumbowyg-box svg:hover {\n  width: 17px;\n  height: 100%;\n  fill: #222222;\n  color: #222222;\n  opacity: 0.75;\n}\n\n.trumbowyg-box, .trumbowyg-editor {\n  display: block;\n  position: relative;\n  width: 100%;\n  min-height: 150px;\n  margin: 0;\n}\n\n.trumbowyg-box.trumbowyg-fullscreen {\n  background: #FEFEFE;\n  border: none !important;\n}\n\n.trumbowyg-editor, .trumbowyg-textarea {\n  position: relative;\n  box-sizing: border-box;\n  padding: 20px;\n  min-height: 150px;\n  width: 100%;\n  border-style: none;\n  resize: none;\n  outline: none;\n  border: 1px solid #DDD;\n  overflow: hidden;\n  word-wrap: break-word;\n  overflow-wrap: break-word;\n}\n.trumbowyg-editor.trumbowyg-autogrow-on-enter, .trumbowyg-textarea.trumbowyg-autogrow-on-enter {\n  transition: height 150ms ease-out;\n}\n\n.trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n  color: transparent !important;\n  text-shadow: 0 0 7px #333; }\n\n@media screen and (min-width: 0 \\0) {\n  .trumbowyg-box-blur .trumbowyg-editor *, .trumbowyg-box-blur .trumbowyg-editor::before {\n    color: rgba(200, 200, 200, 0.6) !important;\n  }\n}\n\n.trumbowyg-box-blur .trumbowyg-editor img, .trumbowyg-box-blur .trumbowyg-editor hr {\n  opacity: 0.2;\n}\n\n.trumbowyg-textarea {\n  position: relative;\n  display: block;\n  overflow: auto;\n  border: none;\n  white-space: normal;\n  font-size: 14px;\n  font-family: \"Inconsolata\", \"Consolas\", \"Courier\", \"Courier New\", sans-serif;\n  line-height: 18px;\n}\n\n.trumbowyg-box.trumbowyg-editor-visible .trumbowyg-textarea {\n  height: 1px !important;\n  width: 25%;\n  min-height: 0 !important;\n  padding: 0 !important;\n  background: none;\n  opacity: 0 !important;\n}\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-textarea {\n  display: block;\n}\n\n.trumbowyg-box.trumbowyg-editor-hidden .trumbowyg-editor {\n  display: none;\n}\n\n.trumbowyg-box.trumbowyg-disabled .trumbowyg-textarea {\n  opacity: 0.8;\n  background: none;\n}\n\n.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n  content: attr(placeholder);\n  color: #999;\n  pointer-events: none;\n}\n\n.trumbowyg-button-pane {\n  min-height: 36px;\n  margin: 0;\n  padding: 0px 5px;\n  position: relative;\n  list-style-type: none;\n  line-height: 10px;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n  z-index: 11;\n  display: flex;\n}\n.trumbowyg-button-pane::after {\n  content: \" \";\n  display: block;\n  position: absolute;\n  top: 36px;\n  left: 0;\n  right: 0;\n  width: 100%;\n  height: 1px;\n  background: #d7e0e2;\n}\n.trumbowyg-button-pane .trumbowyg-button-group {\n  display: inline-block;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:first-child {\n    margin-left: 10px;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:last-child {\n    margin-right: auto;\n}\n.trumbowyg-button-pane .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: transparent;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:after {\n  content: \"\";\n  display: inline-block;\n  width: 1px;\n  margin: 0 5px;\n  height: 20px;\n  vertical-align: top;\n  border-right: 1px solid #d7e0e2;\n  margin-top: 8px;\n}\n.trumbowyg-button-pane .trumbowyg-button-group:first-child:before {\n    content: \"\";\n    display: inline-block;\n    width: 1px;\n    margin: 0 5px;\n    margin-right: 5px;\n    margin-bottom: 0px;\n    margin-left: 5px;\n    height: 20px;\n    vertical-align: top;\n    border-right: 1px solid #d7e0e2;\n    margin-top: 8px;\n}\n.trumbowyg-button-pane button {\n  display: inline-block;\n  position: relative;\n  width: 35px;\n  height: 35px;\n  padding: 1px 6px !important;\n  margin-bottom: 1px;\n  overflow: hidden;\n  border: none;\n  cursor: pointer;\n  background: none;\n  vertical-align: middle;\n  transition: background-color 150ms, opacity 150ms;\n}\n.trumbowyg-button-pane button.trumbowyg-textual-button {\n  width: auto;\n  line-height: 35px;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n.trumbowyg-button-pane.trumbowyg-disable button:not(.trumbowyg-not-disable):not(.trumbowyg-active), .trumbowyg-disabled .trumbowyg-button-pane button:not(.trumbowyg-not-disable):not(.trumbowyg-viewHTML-button) {\n  opacity: 0.2;\n  cursor: default;\n}\n.trumbowyg-button-pane.trumbowyg-disable .trumbowyg-button-group::before, .trumbowyg-disabled .trumbowyg-button-pane .trumbowyg-button-group::before {\n  background: #e3e9eb;\n}\n.trumbowyg-button-pane button:not(.trumbowyg-disable):hover, .trumbowyg-button-pane button:not(.trumbowyg-disable):focus, .trumbowyg-button-pane button.trumbowyg-active {\n  background-color: #FFFFFF;\n  outline: none;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown::after {\n  display: block;\n  content: \" \";\n  position: absolute;\n  top: 25px;\n  right: 3px;\n  height: 0;\n  width: 0;\n  border: 3px solid transparent;\n  border-top-color: #555555;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button {\n  padding-left: 10px !important;\n  padding-right: 18px !important;\n}\n.trumbowyg-button-pane .trumbowyg-open-dropdown.trumbowyg-textual-button::after {\n  top: 17px;\n  right: 7px;\n}\n.trumbowyg-button-pane .trumbowyg-right {\n  float: right;\n}\n.trumbowyg-button-pane .trumbowyg-right::before {\n  display: none !important;\n}\n\n.trumbowyg-dropdown {\n  width: 200px;\n  border: 1px solid #ecf0f1;\n  padding: 5px 0;\n  border-top: none;\n  background: #FFF;\n  margin-left: -1px;\n  box-shadow: rgba(0, 0, 0, 0.1) 0 2px 3px;\n  z-index: 11;\n}\n.trumbowyg-dropdown button {\n  display: block;\n  width: 100%;\n  height: 35px;\n  line-height: 35px;\n  text-decoration: none;\n  background: #FFF;\n  padding: 0 10px;\n  color: #333 !important;\n  border: none;\n  cursor: pointer;\n  text-align: left;\n  font-size: 15px;\n  transition: all 150ms;\n}\n.trumbowyg-dropdown button:hover, .trumbowyg-dropdown button:focus {\n  background: #ecf0f1;\n}\n.trumbowyg-dropdown button svg {\n  float: left;\n  margin-right: 14px;\n}\n\n/* Modal box */\n.trumbowyg-modal {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n  transform: translateX(-50%);\n  max-width: 520px;\n  width: 100%;\n  height: 350px;\n  z-index: 11;\n  overflow: hidden;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n}\n\n.trumbowyg-modal-box {\n  position: absolute;\n  top: 0;\n  left: 50%;\n  -webkit-transform: translateX(-50%);\n  transform: translateX(-50%);\n  max-width: 500px;\n  width: calc(100% - 20px);\n  padding-bottom: 45px;\n  z-index: 1;\n  background-color: #FFF;\n  text-align: center;\n  font-size: 14px;\n  box-shadow: rgba(0, 0, 0, 0.2) 0 2px 3px;\n  -webkit-backface-visibility: hidden;\n  backface-visibility: hidden;\n}\n.trumbowyg-modal-box .trumbowyg-modal-title {\n  font-size: 24px;\n  font-weight: bold;\n  margin: 0 0 20px;\n  padding: 15px 0 13px;\n  display: block;\n  border-bottom: 1px solid #EEE;\n  color: #333;\n  background: #fbfcfc;\n}\n.trumbowyg-modal-box .trumbowyg-progress {\n  width: 100%;\n  height: 3px;\n  position: absolute;\n  top: 58px;\n}\n.trumbowyg-modal-box .trumbowyg-progress .trumbowyg-progress-bar {\n  background: #2BC06A;\n  width: 0;\n  height: 100%;\n  transition: width 150ms linear;\n}\n.trumbowyg-modal-box label {\n  display: block;\n  position: relative;\n  margin: 15px 12px;\n  height: 29px;\n  line-height: 29px;\n  overflow: hidden;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos {\n  display: block;\n  text-align: left;\n  height: 25px;\n  line-height: 25px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos span {\n  display: block;\n  color: #69878f;\n  background-color: #fbfcfc;\n  border: 1px solid #DEDEDE;\n  padding: 0 7px;\n  width: 150px;\n}\n.trumbowyg-modal-box label .trumbowyg-input-infos span.trumbowyg-msg-error {\n  color: #e74c3c;\n}\n.trumbowyg-modal-box label.trumbowyg-input-error input, .trumbowyg-modal-box label.trumbowyg-input-error textarea {\n  border: 1px solid #e74c3c;\n}\n.trumbowyg-modal-box label.trumbowyg-input-error .trumbowyg-input-infos {\n  margin-top: -27px;\n}\n.trumbowyg-modal-box label input {\n  position: absolute;\n  top: 0;\n  right: 0;\n  height: 27px;\n  line-height: 27px;\n  border: 1px solid #DEDEDE;\n  background: #fff;\n  font-size: 14px;\n  max-width: 330px;\n  width: 70%;\n  padding: 0 7px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box label input:hover, .trumbowyg-modal-box label input:focus {\n  outline: none;\n  border: 1px solid #95a5a6;\n}\n.trumbowyg-modal-box label input:focus {\n  background: #fbfcfc;\n}\n.trumbowyg-modal-box .error {\n  margin-top: 25px;\n  display: block;\n  color: red;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button {\n  position: absolute;\n  bottom: 10px;\n  right: 0;\n  text-decoration: none;\n  color: #FFF;\n  display: block;\n  width: 100px;\n  height: 35px;\n  line-height: 33px;\n  margin: 0 10px;\n  background-color: #333;\n  border: none;\n  cursor: pointer;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif;\n  font-size: 16px;\n  transition: all 150ms;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit {\n  right: 110px;\n  background: #2bc06a;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:focus {\n  background: #40d47e;\n  outline: none;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-submit:active {\n  background: #25a25a;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset {\n  color: #555;\n  background: #e6e6e6;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:hover, .trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:focus {\n  background: #fbfbfb;\n  outline: none;\n}\n.trumbowyg-modal-box .trumbowyg-modal-button.trumbowyg-modal-reset:active {\n  background: #d5d5d5;\n}\n\n.trumbowyg-overlay {\n  position: absolute;\n  background-color: rgba(255, 255, 255, 0.5);\n  height: 100%;\n  width: 100%;\n  left: 0;\n  display: none;\n  top: 0;\n  z-index: 10; }\n\n/**\n * Fullscreen\n */\nbody.trumbowyg-body-fullscreen {\n  overflow: hidden; }\n\n.trumbowyg-fullscreen {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  z-index: 99999;\n}\n.trumbowyg-fullscreen.trumbowyg-box, .trumbowyg-fullscreen .trumbowyg-editor {\n  border: none;\n}\n.trumbowyg-fullscreen .trumbowyg-editor, .trumbowyg-fullscreen .trumbowyg-textarea {\n  height: calc(100% - 37px) !important;\n  overflow: auto;\n}\n.trumbowyg-fullscreen .trumbowyg-overlay {\n  height: 100% !important;\n}\n.trumbowyg-fullscreen .trumbowyg-button-group .trumbowyg-fullscreen-button svg {\n  color: #222;\n  fill: transparent;\n}\n\n.trumbowyg-editor object, .trumbowyg-editor embed, .trumbowyg-editor video, .trumbowyg-editor img {\n  max-width: 100%;\n}\n.trumbowyg-editor video, .trumbowyg-editor img {\n  height: auto;\n}\n.trumbowyg-editor img {\n  cursor: move;\n}\n.trumbowyg-editor.trumbowyg-reset-css {\n  background: #FEFEFE !important;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n  font-size: 14px !important;\n  line-height: 1.45em !important;\n  white-space: normal !important;\n  color: #333;\n}\n.trumbowyg-editor.trumbowyg-reset-css a {\n  color: #15c !important;\n  text-decoration: underline !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css div, .trumbowyg-editor.trumbowyg-reset-css p, .trumbowyg-editor.trumbowyg-reset-css ul, .trumbowyg-editor.trumbowyg-reset-css ol, .trumbowyg-editor.trumbowyg-reset-css blockquote {\n  box-shadow: none !important;\n  background: none !important;\n  margin: 0 !important;\n  margin-bottom: 15px !important;\n  line-height: 1.4em !important;\n  font-family: \"Trebuchet MS\", Helvetica, Verdana, sans-serif !important;\n  font-size: 14px !important;\n  border: none;\n}\n.trumbowyg-editor.trumbowyg-reset-css iframe, .trumbowyg-editor.trumbowyg-reset-css object, .trumbowyg-editor.trumbowyg-reset-css hr {\n  margin-bottom: 15px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css blockquote {\n  margin-left: 32px !important;\n  font-style: italic !important;\n  color: #555;\n}\n.trumbowyg-editor.trumbowyg-reset-css ul, .trumbowyg-editor.trumbowyg-reset-css ol {\n  padding-left: 20px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css ul ul, .trumbowyg-editor.trumbowyg-reset-css ol ol, .trumbowyg-editor.trumbowyg-reset-css ul ol, .trumbowyg-editor.trumbowyg-reset-css ol ul {\n  border: none;\n  margin: 2px !important;\n  padding: 0 !important;\n  padding-left: 24px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css hr {\n  display: block;\n  height: 1px;\n  border: none;\n  border-top: 1px solid #CCC;\n}\n.trumbowyg-editor.trumbowyg-reset-css h1, .trumbowyg-editor.trumbowyg-reset-css h2, .trumbowyg-editor.trumbowyg-reset-css h3, .trumbowyg-editor.trumbowyg-reset-css h4 {\n  color: #111;\n  background: none;\n  margin: 0 !important;\n  padding: 0 !important;\n  font-weight: bold;\n}\n.trumbowyg-editor.trumbowyg-reset-css h1 {\n  font-size: 32px !important;\n  line-height: 38px !important;\n  margin-bottom: 20px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h2 {\n  font-size: 26px !important;\n  line-height: 34px !important;\n  margin-bottom: 15px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h3 {\n  font-size: 22px !important;\n  line-height: 28px !important;\n  margin-bottom: 7px !important;\n}\n.trumbowyg-editor.trumbowyg-reset-css h4 {\n  font-size: 16px !important;\n  line-height: 22px !important;\n  margin-bottom: 7px !important;\n}"
  },
  {
    "path": "query_gen/acc_builders.go",
    "content": "package qgen\n\nimport (\n\t\"database/sql\"\n\t\"strings\"\n\n\t//\"fmt\"\n\t\"strconv\"\n)\n\ntype accDeleteBuilder struct {\n\ttable      string\n\twhere      string\n\tdateCutoff *dateCutoff // We might want to do this in a slightly less hacky way\n\n\tbuild *Accumulator\n}\n\nfunc (b *accDeleteBuilder) Where(w string) *accDeleteBuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += w\n\treturn b\n}\n\nfunc (b *accDeleteBuilder) DateCutoff(col string, quantity int, unit string) *accDeleteBuilder {\n\tb.dateCutoff = &dateCutoff{col, quantity, unit, 0}\n\treturn b\n}\n\nfunc (b *accDeleteBuilder) DateOlderThan(col string, quantity int, unit string) *accDeleteBuilder {\n\tb.dateCutoff = &dateCutoff{col, quantity, unit, 1}\n\treturn b\n}\n\nfunc (b *accDeleteBuilder) DateOlderThanQ(col, unit string) *accDeleteBuilder {\n\tb.dateCutoff = &dateCutoff{col, 0, unit, 11}\n\treturn b\n}\n\n/*func (b *accDeleteBuilder) Prepare() *sql.Stmt {\n\treturn b.build.SimpleDelete(b.table, b.where)\n}*/\n\n// TODO: Fix this nasty hack\nfunc (b *accDeleteBuilder) Prepare() *sql.Stmt {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil {\n\t\tdBuilder := b.build.GetAdapter().Builder().Delete().FromAcc(b)\n\t\treturn b.build.prepare(b.build.GetAdapter().ComplexDelete(dBuilder))\n\t}\n\treturn b.build.SimpleDelete(b.table, b.where)\n}\n\nfunc (b *accDeleteBuilder) Exec(args ...interface{}) (res sql.Result, e error) {\n\tstmt := b.Prepare()\n\tif stmt == nil {\n\t\treturn res, b.build.FirstError()\n\t}\n\treturn stmt.Exec(args...)\n}\n\nfunc (b *accDeleteBuilder) Run(args ...interface{}) (int, error) {\n\tres, e := b.Exec(args...)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\treturn int(lastID), e\n}\n\ntype accUpdateBuilder struct {\n\tup    *updatePrebuilder\n\tbuild *Accumulator\n}\n\nfunc (u *accUpdateBuilder) Set(set string) *accUpdateBuilder {\n\tu.up.set = set\n\treturn u\n}\n\nfunc (u *accUpdateBuilder) Where(where string) *accUpdateBuilder {\n\tif u.up.where != \"\" {\n\t\tu.up.where += \" AND \"\n\t}\n\tu.up.where += where\n\treturn u\n}\n\nfunc (b *accUpdateBuilder) DateCutoff(col string, quantity int, unit string) *accUpdateBuilder {\n\tb.up.dateCutoff = &dateCutoff{col, quantity, unit, 0}\n\treturn b\n}\n\nfunc (b *accUpdateBuilder) DateOlderThan(col string, quantity int, unit string) *accUpdateBuilder {\n\tb.up.dateCutoff = &dateCutoff{col, quantity, unit, 1}\n\treturn b\n}\n\nfunc (b *accUpdateBuilder) DateOlderThanQ(col, unit string) *accUpdateBuilder {\n\tb.up.dateCutoff = &dateCutoff{col, 0, unit, 11}\n\treturn b\n}\n\nfunc (b *accUpdateBuilder) WhereQ(sel *selectPrebuilder) *accUpdateBuilder {\n\tb.up.whereSubQuery = sel\n\treturn b\n}\n\nfunc (b *accUpdateBuilder) Prepare() *sql.Stmt {\n\tif b.up.whereSubQuery != nil {\n\t\treturn b.build.prepare(b.build.adapter.SimpleUpdateSelect(b.up))\n\t}\n\treturn b.build.prepare(b.build.adapter.SimpleUpdate(b.up))\n}\nfunc (b *accUpdateBuilder) Stmt() *sql.Stmt {\n\tif b.up.whereSubQuery != nil {\n\t\treturn b.build.prepare(b.build.adapter.SimpleUpdateSelect(b.up))\n\t}\n\treturn b.build.prepare(b.build.adapter.SimpleUpdate(b.up))\n}\n\nfunc (b *accUpdateBuilder) Exec(args ...interface{}) (res sql.Result, err error) {\n\tq, e := b.build.adapter.SimpleUpdate(b.up)\n\tif err != nil {\n\t\treturn res, e\n\t}\n\t//fmt.Println(\"q:\", q)\n\treturn b.build.exec(q, args...)\n}\n\ntype AccBuilder interface {\n\tPrepare() *sql.Stmt\n}\n\ntype AccExec interface {\n\tExec(args ...interface{}) (res sql.Result, err error)\n}\n\ntype AccSelectBuilder struct {\n\ttable      string\n\tcolumns    string\n\twhere      string\n\torderby    string\n\tlimit      string\n\tdateCutoff *dateCutoff // We might want to do this in a slightly less hacky way\n\tinChain    *AccSelectBuilder\n\tinColumn   string\n\n\tbuild *Accumulator\n}\n\nfunc (b *AccSelectBuilder) Columns(cols string) *AccSelectBuilder {\n\tb.columns = cols\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) Cols(cols string) *AccSelectBuilder {\n\tb.columns = cols\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) Where(where string) *AccSelectBuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += where\n\treturn b\n}\n\n// TODO: Don't implement the SQL at the accumulator level but the adapter level\nfunc (b *AccSelectBuilder) In(col string, inList []int) *AccSelectBuilder {\n\tif len(inList) == 0 {\n\t\treturn b\n\t}\n\n\tvar wsb strings.Builder\n\twsb.Grow(len(col) + 5 + 1 + len(b.where) + (len(inList) * 2))\n\twsb.WriteString(col)\n\twsb.WriteString(\" IN(\")\n\tfor i, it := range inList {\n\t\tif i != 0 {\n\t\t\twsb.WriteRune(',')\n\t\t}\n\t\twsb.WriteString(strconv.Itoa(it))\n\t}\n\tif b.where != \"\" {\n\t\twsb.WriteString(\") AND \")\n\t\twsb.WriteString(b.where)\n\t} else {\n\t\twsb.WriteRune(')')\n\t}\n\n\tb.where = wsb.String()\n\treturn b\n}\n\n// TODO: Don't implement the SQL at the accumulator level but the adapter level\nfunc (b *AccSelectBuilder) InPQuery(col string, inList []int) (*sql.Rows, error) {\n\tif len(inList) == 0 {\n\t\treturn nil, sql.ErrNoRows\n\t}\n\t// TODO: Optimise this\n\twhere := col + \" IN(\"\n\n\tidList := make([]interface{}, len(inList))\n\tfor i, id := range inList {\n\t\tidList[i] = strconv.Itoa(id)\n\t\twhere += \"?,\"\n\t}\n\twhere = where[0:len(where)-1] + \")\"\n\n\tif b.where != \"\" {\n\t\twhere += \" AND \" + b.where\n\t}\n\n\tb.where = where\n\treturn b.Query(idList...)\n}\n\nfunc (b *AccSelectBuilder) InQ(col string, sb *AccSelectBuilder) *AccSelectBuilder {\n\tb.inChain = sb\n\tb.inColumn = col\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) DateCutoff(col string, quantity int, unit string) *AccSelectBuilder {\n\tb.dateCutoff = &dateCutoff{col, quantity, unit, 0}\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) DateOlderThanQ(col, unit string) *AccSelectBuilder {\n\tb.dateCutoff = &dateCutoff{col, 0, unit, 11}\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) Orderby(orderby string) *AccSelectBuilder {\n\tb.orderby = orderby\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) Limit(limit string) *AccSelectBuilder {\n\tb.limit = limit\n\treturn b\n}\n\nfunc (b *AccSelectBuilder) Prepare() *sql.Stmt {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil || b.inChain != nil {\n\t\tselectBuilder := b.build.GetAdapter().Builder().Select().FromAcc(b)\n\t\treturn b.build.prepare(b.build.GetAdapter().ComplexSelect(selectBuilder))\n\t}\n\treturn b.build.SimpleSelect(b.table, b.columns, b.where, b.orderby, b.limit)\n}\n\nfunc (b *AccSelectBuilder) Stmt() *sql.Stmt {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil || b.inChain != nil {\n\t\tselectBuilder := b.build.GetAdapter().Builder().Select().FromAcc(b)\n\t\treturn b.build.prepare(b.build.GetAdapter().ComplexSelect(selectBuilder))\n\t}\n\treturn b.build.SimpleSelect(b.table, b.columns, b.where, b.orderby, b.limit)\n}\n\nfunc (b *AccSelectBuilder) ComplexPrepare() *sql.Stmt {\n\tselectBuilder := b.build.GetAdapter().Builder().Select().FromAcc(b)\n\treturn b.build.prepare(b.build.GetAdapter().ComplexSelect(selectBuilder))\n}\n\nfunc (b *AccSelectBuilder) query() (string, error) {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil || b.inChain != nil {\n\t\tselectBuilder := b.build.GetAdapter().Builder().Select().FromAcc(b)\n\t\treturn b.build.GetAdapter().ComplexSelect(selectBuilder)\n\t}\n\treturn b.build.adapter.SimpleSelect(\"\", b.table, b.columns, b.where, b.orderby, b.limit)\n}\n\nfunc (b *AccSelectBuilder) Query(args ...interface{}) (*sql.Rows, error) {\n\tstmt := b.Prepare()\n\tif stmt != nil {\n\t\treturn stmt.Query(args...)\n\t}\n\treturn nil, b.build.FirstError()\n}\n\ntype AccRowWrap struct {\n\trow *sql.Row\n\terr error\n}\n\nfunc (w *AccRowWrap) Scan(dest ...interface{}) error {\n\tif w.err != nil {\n\t\treturn w.err\n\t}\n\treturn w.row.Scan(dest...)\n}\n\n// TODO: Test to make sure the errors are passed up properly\nfunc (b *AccSelectBuilder) QueryRow(args ...interface{}) *AccRowWrap {\n\tstmt := b.Prepare()\n\tif stmt != nil {\n\t\treturn &AccRowWrap{stmt.QueryRow(args...), nil}\n\t}\n\treturn &AccRowWrap{nil, b.build.FirstError()}\n}\n\n// Experimental, reduces lines\nfunc (b *AccSelectBuilder) Each(h func(*sql.Rows) error) error {\n\tquery, e := b.query()\n\tif e != nil {\n\t\treturn e\n\t}\n\trows, e := b.build.query(query)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tif e = h(rows); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\nfunc (b *AccSelectBuilder) EachP(h func(*sql.Rows) error, p ...interface{}) error {\n\tquery, e := b.query()\n\tif e != nil {\n\t\treturn e\n\t}\n\trows, e := b.build.query(query, p)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tif e = h(rows); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\nfunc (b *AccSelectBuilder) EachInt(h func(int) error) error {\n\tquery, e := b.query()\n\tif e != nil {\n\t\treturn e\n\t}\n\trows, e := b.build.query(query)\n\tif e != nil {\n\t\treturn e\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar theInt int\n\t\tif e = rows.Scan(&theInt); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e = h(theInt); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn rows.Err()\n}\n\ntype accInsertBuilder struct {\n\ttable   string\n\tcolumns string\n\tfields  string\n\n\tbuild *Accumulator\n}\n\nfunc (b *accInsertBuilder) Columns(cols string) *accInsertBuilder {\n\tb.columns = cols\n\treturn b\n}\n\nfunc (b *accInsertBuilder) Fields(fields string) *accInsertBuilder {\n\tb.fields = fields\n\treturn b\n}\n\nfunc (b *accInsertBuilder) Prepare() *sql.Stmt {\n\treturn b.build.SimpleInsert(b.table, b.columns, b.fields)\n}\n\nfunc (b *accInsertBuilder) Exec(args ...interface{}) (res sql.Result, e error) {\n\tq, e := b.build.adapter.SimpleInsert(\"\", b.table, b.columns, b.fields)\n\tif e != nil {\n\t\treturn res, e\n\t}\n\treturn b.build.exec(q, args...)\n}\n\nfunc (b *accInsertBuilder) Run(args ...interface{}) (int, error) {\n\tres, e := b.Exec(args...)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\treturn int(lastID), e\n}\n\ntype accBulkInsertBuilder struct {\n\ttable    string\n\tcolumns  string\n\tfieldSet []string\n\n\tbuild *Accumulator\n}\n\nfunc (b *accBulkInsertBuilder) Columns(cols string) *accBulkInsertBuilder {\n\tb.columns = cols\n\treturn b\n}\n\nfunc (b *accBulkInsertBuilder) Fields(fieldSet ...string) *accBulkInsertBuilder {\n\tb.fieldSet = fieldSet\n\treturn b\n}\n\nfunc (b *accBulkInsertBuilder) Prepare() *sql.Stmt {\n\treturn b.build.SimpleBulkInsert(b.table, b.columns, b.fieldSet)\n}\n\nfunc (b *accBulkInsertBuilder) Exec(args ...interface{}) (res sql.Result, err error) {\n\tq, e := b.build.adapter.SimpleBulkInsert(\"\", b.table, b.columns, b.fieldSet)\n\tif e != nil {\n\t\treturn res, e\n\t}\n\treturn b.build.exec(q, args...)\n}\n\nfunc (b *accBulkInsertBuilder) Run(args ...interface{}) (int, error) {\n\tres, e := b.Exec(args...)\n\tif e != nil {\n\t\treturn 0, e\n\t}\n\tlastID, e := res.LastInsertId()\n\treturn int(lastID), e\n}\n\ntype accCountBuilder struct {\n\ttable      string\n\twhere      string\n\tlimit      string\n\tdateCutoff *dateCutoff // We might want to do this in a slightly less hacky way\n\tinChain    *AccSelectBuilder\n\tinColumn   string\n\n\tbuild *Accumulator\n}\n\nfunc (b *accCountBuilder) Where(w string) *accCountBuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += w\n\treturn b\n}\n\nfunc (b *accCountBuilder) Limit(limit string) *accCountBuilder {\n\tb.limit = limit\n\treturn b\n}\n\nfunc (b *accCountBuilder) DateCutoff(col string, quantity int, unit string) *accCountBuilder {\n\tb.dateCutoff = &dateCutoff{col, quantity, unit, 0}\n\treturn b\n}\n\nfunc (b *accCountBuilder) DateOlderThanQ(col, unit string) *accCountBuilder {\n\tb.dateCutoff = &dateCutoff{col, 0, unit, 11}\n\treturn b\n}\n\n// TODO: Fix this nasty hack\nfunc (b *accCountBuilder) Prepare() *sql.Stmt {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil || b.inChain != nil {\n\t\tselBuilder := b.build.GetAdapter().Builder().Count().FromCountAcc(b)\n\t\tselBuilder.columns = \"COUNT(*)\"\n\t\treturn b.build.prepare(b.build.GetAdapter().ComplexSelect(selBuilder))\n\t}\n\treturn b.build.SimpleCount(b.table, b.where, b.limit)\n}\n// TODO: Fix this nasty hack\nfunc (b *accCountBuilder) Stmt() *sql.Stmt {\n\t// TODO: Phase out the procedural API and use the adapter's OO API? The OO API might need a bit more work before we do that and it needs to be rolled out to MSSQL.\n\tif b.dateCutoff != nil || b.inChain != nil {\n\t\tselBuilder := b.build.GetAdapter().Builder().Count().FromCountAcc(b)\n\t\tselBuilder.columns = \"COUNT(*)\"\n\t\treturn b.build.prepare(b.build.GetAdapter().ComplexSelect(selBuilder))\n\t}\n\treturn b.build.SimpleCount(b.table, b.where, b.limit)\n}\n\nfunc (b *accCountBuilder) Total() (total int, e error) {\n\tstmt := b.Prepare()\n\tif stmt == nil {\n\t\treturn 0, b.build.FirstError()\n\t}\n\te = stmt.QueryRow().Scan(&total)\n\treturn total, e\n}\n\nfunc (b *accCountBuilder) TotalP(params ...interface{}) (total int, e error) {\n\tstmt := b.Prepare()\n\tif stmt == nil {\n\t\treturn 0, b.build.FirstError()\n\t}\n\te = stmt.QueryRow(params).Scan(&total)\n\treturn total, e\n}\n\n// TODO: Add a Sum builder for summing viewchunks up into one number for the dashboard?\n"
  },
  {
    "path": "query_gen/accumulator.go",
    "content": "/* WIP: A version of the builder which accumulates errors, we'll see if we can't unify the implementations at some point */\npackage qgen\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\t\"strings\"\n)\n\nvar LogPrepares = true\n\n// So we don't have to do the qgen.Builder.Accumulator() boilerplate all the time\nfunc NewAcc() *Accumulator {\n\treturn Builder.Accumulator()\n}\n\ntype Accumulator struct {\n\tconn     *sql.DB\n\tadapter  Adapter\n\tfirstErr error\n}\n\nfunc (acc *Accumulator) SetConn(conn *sql.DB) {\n\tacc.conn = conn\n}\n\nfunc (acc *Accumulator) SetAdapter(name string) error {\n\tadap, err := GetAdapter(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tacc.adapter = adap\n\treturn nil\n}\n\nfunc (acc *Accumulator) GetAdapter() Adapter {\n\treturn acc.adapter\n}\n\nfunc (acc *Accumulator) FirstError() error {\n\treturn acc.firstErr\n}\n\nfunc (acc *Accumulator) RecordError(err error) {\n\tif err == nil {\n\t\treturn\n\t}\n\tif acc.firstErr == nil {\n\t\tacc.firstErr = err\n\t}\n}\n\nfunc (acc *Accumulator) prepare(res string, err error) *sql.Stmt {\n\t// TODO: Can we make this less noisy on debug mode?\n\tif LogPrepares {\n\t\tlog.Print(\"res: \", res)\n\t}\n\tif err != nil {\n\t\tacc.RecordError(err)\n\t\treturn nil\n\t}\n\tstmt, err := acc.conn.Prepare(res)\n\tacc.RecordError(err)\n\treturn stmt\n}\n\nfunc (acc *Accumulator) RawPrepare(res string) *sql.Stmt {\n\treturn acc.prepare(res, nil)\n}\n\nfunc (acc *Accumulator) query(q string, args ...interface{}) (rows *sql.Rows, err error) {\n\terr = acc.FirstError()\n\tif err != nil {\n\t\treturn rows, err\n\t}\n\treturn acc.conn.Query(q, args...)\n}\n\nfunc (acc *Accumulator) exec(q string, args ...interface{}) (res sql.Result, err error) {\n\terr = acc.FirstError()\n\tif err != nil {\n\t\treturn res, err\n\t}\n\treturn acc.conn.Exec(q, args...)\n}\n\nfunc (acc *Accumulator) Tx(handler func(*TransactionBuilder) error) {\n\ttx, err := acc.conn.Begin()\n\tif err != nil {\n\t\tacc.RecordError(err)\n\t\treturn\n\t}\n\terr = handler(&TransactionBuilder{tx, acc.adapter, nil})\n\tif err != nil {\n\t\ttx.Rollback()\n\t\tacc.RecordError(err)\n\t\treturn\n\t}\n\tacc.RecordError(tx.Commit())\n}\n\nfunc (acc *Accumulator) SimpleSelect(table, columns, where, orderby, limit string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleSelect(\"\", table, columns, where, orderby, limit))\n}\n\nfunc (acc *Accumulator) SimpleCount(table, where, limit string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleCount(\"\", table, where, limit))\n}\n\nfunc (acc *Accumulator) SimpleLeftJoin(table1, table2, columns, joiners, where, orderby, limit string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleLeftJoin(\"\", table1, table2, columns, joiners, where, orderby, limit))\n}\n\nfunc (acc *Accumulator) SimpleInnerJoin(table1, table2, columns, joiners, where, orderby, limit string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleInnerJoin(\"\", table1, table2, columns, joiners, where, orderby, limit))\n}\n\nfunc (acc *Accumulator) CreateTable(table, charset, collation string, columns []DBTableColumn, keys []DBTableKey) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.CreateTable(\"\", table, charset, collation, columns, keys))\n}\n\nfunc (acc *Accumulator) SimpleInsert(table, columns, fields string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleInsert(\"\", table, columns, fields))\n}\n\nfunc (acc *Accumulator) SimpleBulkInsert(table, cols string, fieldSet []string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleBulkInsert(\"\", table, cols, fieldSet))\n}\n\nfunc (acc *Accumulator) SimpleInsertSelect(ins DBInsert, sel DBSelect) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleInsertSelect(\"\", ins, sel))\n}\n\nfunc (acc *Accumulator) SimpleInsertLeftJoin(ins DBInsert, sel DBJoin) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleInsertLeftJoin(\"\", ins, sel))\n}\n\nfunc (acc *Accumulator) SimpleInsertInnerJoin(ins DBInsert, sel DBJoin) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleInsertInnerJoin(\"\", ins, sel))\n}\n\nfunc (acc *Accumulator) SimpleUpdate(table, set, where string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleUpdate(qUpdate(table, set, where)))\n}\n\nfunc (acc *Accumulator) SimpleUpdateSelect(table, set, table2, cols, where, orderby, limit string) *sql.Stmt {\n\tpre := qUpdate(table, set, \"\").WhereQ(acc.GetAdapter().Builder().Select().Table(table2).Columns(cols).Where(where).Orderby(orderby).Limit(limit))\n\treturn acc.prepare(acc.adapter.SimpleUpdateSelect(pre))\n}\n\nfunc (acc *Accumulator) SimpleDelete(table, where string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.SimpleDelete(\"\", table, where))\n}\n\n// I don't know why you need this, but here it is x.x\nfunc (acc *Accumulator) Purge(table string) *sql.Stmt {\n\treturn acc.prepare(acc.adapter.Purge(\"\", table))\n}\n\nfunc (acc *Accumulator) prepareTx(tx *sql.Tx, res string, err error) (stmt *sql.Stmt) {\n\tif err != nil {\n\t\tacc.RecordError(err)\n\t\treturn nil\n\t}\n\tstmt, err = tx.Prepare(res)\n\tacc.RecordError(err)\n\treturn stmt\n}\n\n// These ones support transactions\nfunc (acc *Accumulator) SimpleSelectTx(tx *sql.Tx, table, columns, where, orderby, limit string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleSelect(\"\", table, columns, where, orderby, limit)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleCountTx(tx *sql.Tx, table, where, limit string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleCount(\"\", table, where, limit)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleLeftJoinTx(tx *sql.Tx, table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleLeftJoin(\"\", table1, table2, columns, joiners, where, orderby, limit)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleInnerJoinTx(tx *sql.Tx, table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleInnerJoin(\"\", table1, table2, columns, joiners, where, orderby, limit)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) CreateTableTx(tx *sql.Tx, table, charset, collation string, columns []DBTableColumn, keys []DBTableKey) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.CreateTable(\"\", table, charset, collation, columns, keys)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleInsertTx(tx *sql.Tx, table, columns, fields string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleInsert(\"\", table, columns, fields)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleInsertSelectTx(tx *sql.Tx, ins DBInsert, sel DBSelect) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleInsertSelect(\"\", ins, sel)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleInsertLeftJoinTx(tx *sql.Tx, ins DBInsert, sel DBJoin) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleInsertLeftJoin(\"\", ins, sel)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleInsertInnerJoinTx(tx *sql.Tx, ins DBInsert, sel DBJoin) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleInsertInnerJoin(\"\", ins, sel)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleUpdateTx(tx *sql.Tx, table, set, where string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleUpdate(qUpdate(table, set, where))\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) SimpleDeleteTx(tx *sql.Tx, table, where string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.SimpleDelete(\"\", table, where)\n\treturn acc.prepareTx(tx, res, err)\n}\n\n// I don't know why you need this, but here it is x.x\nfunc (acc *Accumulator) PurgeTx(tx *sql.Tx, table string) (stmt *sql.Stmt) {\n\tres, err := acc.adapter.Purge(\"\", table)\n\treturn acc.prepareTx(tx, res, err)\n}\n\nfunc (acc *Accumulator) Delete(table string) *accDeleteBuilder {\n\treturn &accDeleteBuilder{table, \"\", nil, acc}\n}\n\nfunc (acc *Accumulator) Update(table string) *accUpdateBuilder {\n\treturn &accUpdateBuilder{qUpdate(table, \"\", \"\"), acc}\n}\n\nfunc (acc *Accumulator) Select(table string) *AccSelectBuilder {\n\treturn &AccSelectBuilder{table, \"\", \"\", \"\", \"\", nil, nil, \"\", acc}\n}\n\nfunc (acc *Accumulator) Exists(tbl, col string) *AccSelectBuilder {\n\treturn acc.Select(tbl).Columns(col).Where(col + \"=?\")\n}\n\nfunc (acc *Accumulator) Insert(table string) *accInsertBuilder {\n\treturn &accInsertBuilder{table, \"\", \"\", acc}\n}\n\nfunc (acc *Accumulator) BulkInsert(table string) *accBulkInsertBuilder {\n\treturn &accBulkInsertBuilder{table, \"\", nil, acc}\n}\n\nfunc (acc *Accumulator) Count(table string) *accCountBuilder {\n\treturn &accCountBuilder{table, \"\", \"\", nil, nil, \"\", acc}\n}\n\ntype SimpleModel struct {\n\tdelete *sql.Stmt\n\tcreate *sql.Stmt\n\tupdate *sql.Stmt\n}\n\nfunc (acc *Accumulator) SimpleModel(tbl, colstr, primary string) SimpleModel {\n\tvar qlist, uplist string\n\tfor _, col := range strings.Split(colstr, \",\") {\n\t\tqlist += \"?,\"\n\t\tuplist += col + \"=?,\"\n\t}\n\tif len(qlist) > 0 {\n\t\tqlist = qlist[0 : len(qlist)-1]\n\t\tuplist = uplist[0 : len(uplist)-1]\n\t}\n\n\twhere := primary + \"=?\"\n\treturn SimpleModel{\n\t\tdelete: acc.Delete(tbl).Where(where).Prepare(),\n\t\tcreate: acc.Insert(tbl).Columns(colstr).Fields(qlist).Prepare(),\n\t\tupdate: acc.Update(tbl).Set(uplist).Where(where).Prepare(),\n\t}\n}\n\nfunc (m SimpleModel) Delete(keyVal interface{}) error {\n\t_, err := m.delete.Exec(keyVal)\n\treturn err\n}\n\nfunc (m SimpleModel) Update(args ...interface{}) error {\n\t_, err := m.update.Exec(args...)\n\treturn err\n}\n\nfunc (m SimpleModel) Create(args ...interface{}) error {\n\t_, err := m.create.Exec(args...)\n\treturn err\n}\n\nfunc (m SimpleModel) CreateID(args ...interface{}) (int, error) {\n\tres, err := m.create.Exec(args...)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tlastID, err := res.LastInsertId()\n\treturn int(lastID), err\n}\n\nfunc (acc *Accumulator) Model(table string) *accModelBuilder {\n\treturn &accModelBuilder{table, \"\", acc}\n}\n\ntype accModelBuilder struct {\n\ttable   string\n\tprimary string\n\n\tbuild *Accumulator\n}\n\nfunc (b *accModelBuilder) Primary(col string) *accModelBuilder {\n\tb.primary = col\n\treturn b\n}\n"
  },
  {
    "path": "query_gen/builder.go",
    "content": "/* WIP Under Construction */\npackage qgen\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n)\n\nvar Builder *builder\n\nfunc init() {\n\tBuilder = &builder{conn: nil}\n}\n\n// A set of wrappers around the generator methods, so that we can use this inline in Gosora\ntype builder struct {\n\tconn    *sql.DB\n\tadapter Adapter\n}\n\nfunc (b *builder) Accumulator() *Accumulator {\n\treturn &Accumulator{b.conn, b.adapter, nil}\n}\n\n// TODO: Move this method out of builder?\nfunc (b *builder) Init(adapter string, config map[string]string) error {\n\terr := b.SetAdapter(adapter)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconn, err := b.adapter.BuildConn(config)\n\tb.conn = conn\n\tlog.Print(\"err:\", err) // Is the problem here somehow?\n\treturn err\n}\n\nfunc (b *builder) SetConn(conn *sql.DB) {\n\tb.conn = conn\n}\n\nfunc (b *builder) GetConn() *sql.DB {\n\treturn b.conn\n}\n\nfunc (b *builder) SetAdapter(name string) error {\n\tadap, err := GetAdapter(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tb.adapter = adap\n\treturn nil\n}\n\nfunc (b *builder) GetAdapter() Adapter {\n\treturn b.adapter\n}\n\nfunc (b *builder) DbVersion() (dbVersion string) {\n\tb.conn.QueryRow(b.adapter.DbVersion()).Scan(&dbVersion)\n\treturn dbVersion\n}\n\nfunc (b *builder) Begin() (*sql.Tx, error) {\n\treturn b.conn.Begin()\n}\n\nfunc (b *builder) Tx(h func(*TransactionBuilder) error) error {\n\ttx, err := b.conn.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = h(&TransactionBuilder{tx, b.adapter, nil})\n\tif err != nil {\n\t\ttx.Rollback()\n\t\treturn err\n\t}\n\treturn tx.Commit()\n}\n\nfunc (b *builder) prepare(res string, err error) (*sql.Stmt, error) {\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn b.conn.Prepare(res)\n}\n\nfunc (b *builder) SimpleSelect(table, columns, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleSelect(\"\", table, columns, where, orderby, limit))\n}\n\nfunc (b *builder) SimpleCount(table, where, limit string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleCount(\"\", table, where, limit))\n}\n\nfunc (b *builder) SimpleLeftJoin(table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleLeftJoin(\"\", table1, table2, columns, joiners, where, orderby, limit))\n}\n\nfunc (b *builder) SimpleInnerJoin(table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleInnerJoin(\"\", table1, table2, columns, joiners, where, orderby, limit))\n}\n\nfunc (b *builder) DropTable(table string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.DropTable(\"\", table))\n}\n\nfunc (build *builder) CreateTable(table, charset, collation string, columns []DBTableColumn, keys []DBTableKey) (stmt *sql.Stmt, err error) {\n\treturn build.prepare(build.adapter.CreateTable(\"\", table, charset, collation, columns, keys))\n}\n\nfunc (b *builder) AddColumn(table string, column DBTableColumn, key *DBTableKey) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.AddColumn(\"\", table, column, key))\n}\n\nfunc (b *builder) DropColumn(table, colName string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.DropColumn(\"\", table, colName))\n}\n\nfunc (b *builder) RenameColumn(table, oldName, newName string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.RenameColumn(\"\", table, oldName, newName))\n}\n\nfunc (b *builder) ChangeColumn(table, colName string, col DBTableColumn) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.ChangeColumn(\"\", table, colName, col))\n}\n\nfunc (b *builder) SetDefaultColumn(table, colName, colType, defaultStr string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SetDefaultColumn(\"\", table, colName, colType, defaultStr))\n}\n\nfunc (b *builder) AddIndex(table, iname, colname string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.AddIndex(\"\", table, iname, colname))\n}\n\nfunc (b *builder) AddKey(table, column string, key DBTableKey) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.AddKey(\"\", table, column, key))\n}\n\nfunc (b *builder) RemoveIndex(table, iname string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.RemoveIndex(\"\", table, iname))\n}\n\nfunc (b *builder) AddForeignKey(table, column, ftable, fcolumn string, cascade bool) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.AddForeignKey(\"\", table, column, ftable, fcolumn, cascade))\n}\n\nfunc (b *builder) SimpleInsert(table, columns, fields string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleInsert(\"\", table, columns, fields))\n}\n\nfunc (b *builder) SimpleInsertSelect(ins DBInsert, sel DBSelect) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleInsertSelect(\"\", ins, sel))\n}\n\nfunc (b *builder) SimpleInsertLeftJoin(ins DBInsert, sel DBJoin) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleInsertLeftJoin(\"\", ins, sel))\n}\n\nfunc (b *builder) SimpleInsertInnerJoin(ins DBInsert, sel DBJoin) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleInsertInnerJoin(\"\", ins, sel))\n}\n\nfunc (b *builder) SimpleUpdate(table, set, where string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleUpdate(qUpdate(table, set, where)))\n}\n\nfunc (b *builder) SimpleDelete(table, where string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.SimpleDelete(\"\", table, where))\n}\n\n// I don't know why you need this, but here it is x.x\nfunc (b *builder) Purge(table string) (stmt *sql.Stmt, err error) {\n\treturn b.prepare(b.adapter.Purge(\"\", table))\n}\n\nfunc (b *builder) prepareTx(tx *sql.Tx, res string, err error) (*sql.Stmt, error) {\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn tx.Prepare(res)\n}\n\n// These ones support transactions\nfunc (b *builder) SimpleSelectTx(tx *sql.Tx, table, columns, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleSelect(\"\", table, columns, where, orderby, limit)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleCountTx(tx *sql.Tx, table, where, limit string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleCount(\"\", table, where, limit)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleLeftJoinTx(tx *sql.Tx, table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleLeftJoin(\"\", table1, table2, columns, joiners, where, orderby, limit)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleInnerJoinTx(tx *sql.Tx, table1, table2, columns, joiners, where, orderby, limit string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInnerJoin(\"\", table1, table2, columns, joiners, where, orderby, limit)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) CreateTableTx(tx *sql.Tx, table, charset, collation string, columns []DBTableColumn, keys []DBTableKey) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.CreateTable(\"\", table, charset, collation, columns, keys)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleInsertTx(tx *sql.Tx, table, columns, fields string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInsert(\"\", table, columns, fields)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleInsertSelectTx(tx *sql.Tx, ins DBInsert, sel DBSelect) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInsertSelect(\"\", ins, sel)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleInsertLeftJoinTx(tx *sql.Tx, ins DBInsert, sel DBJoin) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInsertLeftJoin(\"\", ins, sel)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleInsertInnerJoinTx(tx *sql.Tx, ins DBInsert, sel DBJoin) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInsertInnerJoin(\"\", ins, sel)\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleUpdateTx(tx *sql.Tx, table, set, where string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleUpdate(qUpdate(table, set, where))\n\treturn b.prepareTx(tx, res, err)\n}\n\nfunc (b *builder) SimpleDeleteTx(tx *sql.Tx, table, where string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleDelete(\"\", table, where)\n\treturn b.prepareTx(tx, res, err)\n}\n\n// I don't know why you need this, but here it is x.x\nfunc (b *builder) PurgeTx(tx *sql.Tx, table string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.Purge(\"\", table)\n\treturn b.prepareTx(tx, res, err)\n}\n"
  },
  {
    "path": "query_gen/install.go",
    "content": "package qgen\n\nvar Install *installer\n\nfunc init() {\n\tInstall = &installer{instructions: []DBInstallInstruction{}}\n}\n\ntype DBInstallInstruction struct {\n\tTable    string\n\tContents string\n\tType     string\n}\n\n// TODO: Add methods to this to construct it OO-like\ntype DBInstallTable struct {\n\tName      string\n\tCharset   string\n\tCollation string\n\tColumns   []DBTableColumn\n\tKeys      []DBTableKey\n}\n\n// A set of wrappers around the generator methods, so we can use this in the installer\n// TODO: Re-implement the query generation, query builder and installer adapters as layers on-top of a query text adapter\ntype installer struct {\n\tadapter      Adapter\n\tinstructions []DBInstallInstruction\n\ttables       []*DBInstallTable // TODO: Use this in Record() in the next commit to allow us to auto-migrate settings rather than manually patching them in on upgrade\n\tplugins      []QueryPlugin\n}\n\nfunc (i *installer) SetAdapter(name string) error {\n\ta, err := GetAdapter(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.SetAdapterInstance(a)\n\treturn nil\n}\n\nfunc (i *installer) SetAdapterInstance(a Adapter) {\n\ti.adapter = a\n\ti.instructions = []DBInstallInstruction{}\n}\n\nfunc (i *installer) AddPlugins(plugins ...QueryPlugin) {\n\ti.plugins = append(i.plugins, plugins...)\n}\n\nfunc (i *installer) CreateTable(table, charset, collation string, cols []DBTableColumn, keys []DBTableKey) error {\n\ttableStruct := &DBInstallTable{table, charset, collation, cols, keys}\n\terr := i.RunHook(\"CreateTableStart\", tableStruct)\n\tif err != nil {\n\t\treturn err\n\t}\n\tres, err := i.adapter.CreateTable(\"\", table, charset, collation, cols, keys)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = i.RunHook(\"CreateTableAfter\", tableStruct)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.instructions = append(i.instructions, DBInstallInstruction{table, res, \"create-table\"})\n\ti.tables = append(i.tables, tableStruct)\n\treturn nil\n}\n\n// TODO: Let plugins manipulate the parameters like in CreateTable\nfunc (i *installer) AddIndex(table, iname, colName string) error {\n\terr := i.RunHook(\"AddIndexStart\", table, iname, colName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tres, err := i.adapter.AddIndex(\"\", table, iname, colName)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = i.RunHook(\"AddIndexAfter\", table, iname, colName)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.instructions = append(i.instructions, DBInstallInstruction{table, res, \"index\"})\n\treturn nil\n}\n\nfunc (i *installer) AddKey(table, col string, key DBTableKey) error {\n\terr := i.RunHook(\"AddKeyStart\", table, col, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tres, err := i.adapter.AddKey(\"\", table, col, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = i.RunHook(\"AddKeyAfter\", table, col, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.instructions = append(i.instructions, DBInstallInstruction{table, res, \"key\"})\n\treturn nil\n}\n\n// TODO: Let plugins manipulate the parameters like in CreateTable\nfunc (i *installer) SimpleInsert(table, columns, fields string) error {\n\terr := i.RunHook(\"SimpleInsertStart\", table, columns, fields)\n\tif err != nil {\n\t\treturn err\n\t}\n\tres, err := i.adapter.SimpleInsert(\"\", table, columns, fields)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = i.RunHook(\"SimpleInsertAfter\", table, columns, fields, res)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.instructions = append(i.instructions, DBInstallInstruction{table, res, \"insert\"})\n\treturn nil\n}\n\nfunc (i *installer) SimpleBulkInsert(table, cols string, fieldSet []string) error {\n\terr := i.RunHook(\"SimpleBulkInsertStart\", table, cols, fieldSet)\n\tif err != nil {\n\t\treturn err\n\t}\n\tres, err := i.adapter.SimpleBulkInsert(\"\", table, cols, fieldSet)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = i.RunHook(\"SimpleBulkInsertAfter\", table, cols, fieldSet, res)\n\tif err != nil {\n\t\treturn err\n\t}\n\ti.instructions = append(i.instructions, DBInstallInstruction{table, res, \"bulk-insert\"})\n\treturn nil\n}\n\nfunc (i *installer) RunHook(name string, args ...interface{}) error {\n\tfor _, pl := range i.plugins {\n\t\terr := pl.Hook(name, args...)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (i *installer) Write() error {\n\tvar inserts string\n\t// We can't escape backticks, so we have to dump it out a file at a time\n\tfor _, instr := range i.instructions {\n\t\tif instr.Type == \"create-table\" {\n\t\t\terr := writeFile(\"./schema/\"+i.adapter.GetName()+\"/query_\"+instr.Table+\".sql\", instr.Contents)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\tinserts += instr.Contents + \";\\n\"\n\t\t}\n\t}\n\n\terr := writeFile(\"./schema/\"+i.adapter.GetName()+\"/inserts.sql\", inserts)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, plugin := range i.plugins {\n\t\terr := plugin.Write()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "query_gen/micro_builders.go",
    "content": "package qgen\n\ntype dateCutoff struct {\n\tColumn   string\n\tQuantity int\n\tUnit     string\n\tType     int\n}\n\ntype prebuilder struct {\n\tadapter Adapter\n}\n\nfunc (b *prebuilder) Select(nlist ...string) *selectPrebuilder {\n\tname := optString(nlist, \"\")\n\treturn &selectPrebuilder{name, \"\", \"\", \"\", \"\", \"\", nil, nil, \"\", b.adapter}\n}\n\nfunc (b *prebuilder) Count(nlist ...string) *selectPrebuilder {\n\tname := optString(nlist, \"\")\n\treturn &selectPrebuilder{name, \"\", \"COUNT(*)\", \"\", \"\", \"\", nil, nil, \"\", b.adapter}\n}\n\nfunc (b *prebuilder) Insert(nlist ...string) *insertPrebuilder {\n\tname := optString(nlist, \"\")\n\treturn &insertPrebuilder{name, \"\", \"\", \"\", b.adapter}\n}\n\nfunc (b *prebuilder) Update(nlist ...string) *updatePrebuilder {\n\tname := optString(nlist, \"\")\n\treturn &updatePrebuilder{name, \"\", \"\", \"\", nil, nil, b.adapter}\n}\n\nfunc (b *prebuilder) Delete(nlist ...string) *deletePrebuilder {\n\tname := optString(nlist, \"\")\n\treturn &deletePrebuilder{name, \"\", \"\", nil, b.adapter}\n}\n\ntype deletePrebuilder struct {\n\tname       string\n\ttable      string\n\twhere      string\n\tdateCutoff *dateCutoff\n\n\tbuild Adapter\n}\n\nfunc (b *deletePrebuilder) Table(table string) *deletePrebuilder {\n\tb.table = table\n\treturn b\n}\n\nfunc (b *deletePrebuilder) Where(where string) *deletePrebuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += where\n\treturn b\n}\n\n// TODO: We probably want to avoid the double allocation of two builders somehow\nfunc (b *deletePrebuilder) FromAcc(acc *accDeleteBuilder) *deletePrebuilder {\n\tb.table = acc.table\n\tb.where = acc.where\n\tb.dateCutoff = acc.dateCutoff\n\treturn b\n}\n\nfunc (b *deletePrebuilder) Text() (string, error) {\n\treturn b.build.SimpleDelete(b.name, b.table, b.where)\n}\n\nfunc (b *deletePrebuilder) Parse() {\n\tb.build.SimpleDelete(b.name, b.table, b.where)\n}\n\ntype updatePrebuilder struct {\n\tname          string\n\ttable         string\n\tset           string\n\twhere         string\n\tdateCutoff    *dateCutoff // We might want to do this in a slightly less hacky way\n\twhereSubQuery *selectPrebuilder\n\n\tbuild Adapter\n}\n\nfunc qUpdate(table string, set string, where string) *updatePrebuilder {\n\treturn &updatePrebuilder{table: table, set: set, where: where}\n}\n\nfunc (b *updatePrebuilder) Table(table string) *updatePrebuilder {\n\tb.table = table\n\treturn b\n}\n\nfunc (b *updatePrebuilder) Set(set string) *updatePrebuilder {\n\tb.set = set\n\treturn b\n}\n\nfunc (b *updatePrebuilder) Where(where string) *updatePrebuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += where\n\treturn b\n}\n\nfunc (b *updatePrebuilder) WhereQ(sel *selectPrebuilder) *updatePrebuilder {\n\tb.whereSubQuery = sel\n\treturn b\n}\n\nfunc (b *updatePrebuilder) Text() (string, error) {\n\treturn b.build.SimpleUpdate(b)\n}\n\nfunc (b *updatePrebuilder) Parse() {\n\tb.build.SimpleUpdate(b)\n}\n\ntype selectPrebuilder struct {\n\tname       string\n\ttable      string\n\tcolumns    string\n\twhere      string\n\torderby    string\n\tlimit      string\n\tdateCutoff *dateCutoff\n\tinChain    *selectPrebuilder\n\tinColumn   string // for inChain\n\n\tbuild Adapter\n}\n\nfunc (b *selectPrebuilder) Table(table string) *selectPrebuilder {\n\tb.table = table\n\treturn b\n}\n\nfunc (b *selectPrebuilder) Columns(columns string) *selectPrebuilder {\n\tb.columns = columns\n\treturn b\n}\n\nfunc (b *selectPrebuilder) Where(where string) *selectPrebuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += where\n\treturn b\n}\n\nfunc (b *selectPrebuilder) InQ(subBuilder *selectPrebuilder) *selectPrebuilder {\n\tb.inChain = subBuilder\n\treturn b\n}\n\nfunc (b *selectPrebuilder) Orderby(orderby string) *selectPrebuilder {\n\tb.orderby = orderby\n\treturn b\n}\n\nfunc (b *selectPrebuilder) Limit(limit string) *selectPrebuilder {\n\tb.limit = limit\n\treturn b\n}\n\n// TODO: We probably want to avoid the double allocation of two builders somehow\nfunc (b *selectPrebuilder) FromAcc(acc *AccSelectBuilder) *selectPrebuilder {\n\tb.table = acc.table\n\tif acc.columns != \"\" {\n\t\tb.columns = acc.columns\n\t}\n\tb.where = acc.where\n\tb.orderby = acc.orderby\n\tb.limit = acc.limit\n\n\tb.dateCutoff = acc.dateCutoff\n\tif acc.inChain != nil {\n\t\tb.inChain = &selectPrebuilder{\"\", acc.inChain.table, acc.inChain.columns, acc.inChain.where, acc.inChain.orderby, acc.inChain.limit, acc.inChain.dateCutoff, nil, \"\", b.build}\n\t\tb.inColumn = acc.inColumn\n\t}\n\treturn b\n}\n\nfunc (b *selectPrebuilder) FromCountAcc(acc *accCountBuilder) *selectPrebuilder {\n\tb.table = acc.table\n\tb.where = acc.where\n\tb.limit = acc.limit\n\n\tb.dateCutoff = acc.dateCutoff\n\tif acc.inChain != nil {\n\t\tb.inChain = &selectPrebuilder{\"\", acc.inChain.table, acc.inChain.columns, acc.inChain.where, acc.inChain.orderby, acc.inChain.limit, acc.inChain.dateCutoff, nil, \"\", b.build}\n\t\tb.inColumn = acc.inColumn\n\t}\n\treturn b\n}\n\n// TODO: Add support for dateCutoff\nfunc (b *selectPrebuilder) Text() (string, error) {\n\treturn b.build.SimpleSelect(b.name, b.table, b.columns, b.where, b.orderby, b.limit)\n}\n\n// TODO: Add support for dateCutoff\nfunc (b *selectPrebuilder) Parse() {\n\tb.build.SimpleSelect(b.name, b.table, b.columns, b.where, b.orderby, b.limit)\n}\n\ntype insertPrebuilder struct {\n\tname    string\n\ttable   string\n\tcolumns string\n\tfields  string\n\n\tbuild Adapter\n}\n\nfunc (b *insertPrebuilder) Table(table string) *insertPrebuilder {\n\tb.table = table\n\treturn b\n}\n\nfunc (b *insertPrebuilder) Columns(columns string) *insertPrebuilder {\n\tb.columns = columns\n\treturn b\n}\n\nfunc (b *insertPrebuilder) Fields(fields string) *insertPrebuilder {\n\tb.fields = fields\n\treturn b\n}\n\nfunc (b *insertPrebuilder) Text() (string, error) {\n\treturn b.build.SimpleInsert(b.name, b.table, b.columns, b.fields)\n}\n\nfunc (b *insertPrebuilder) Parse() {\n\tb.build.SimpleInsert(b.name, b.table, b.columns, b.fields)\n}\n\n/*type countPrebuilder struct {\n\tname  string\n\ttable string\n\twhere string\n\tlimit string\n\n\tbuild Adapter\n}\n\nfunc (b *countPrebuilder) Table(table string) *countPrebuilder {\n\tb.table = table\n\treturn b\n}\n\nfunc b *countPrebuilder) Where(where string) *countPrebuilder {\n\tif b.where != \"\" {\n\t\tb.where += \" AND \"\n\t}\n\tb.where += where\n\treturn b\n}\n\nfunc (b *countPrebuilder) Limit(limit string) *countPrebuilder {\n\tb.limit = limit\n\treturn b\n}\n\nfunc (b *countPrebuilder) Text() (string, error) {\n\treturn b.build.SimpleCount(b.name, b.table, b.where, b.limit)\n}\n\nfunc (b *countPrebuilder) Parse() {\n\tb.build.SimpleCount(b.name, b.table, b.where, b.limit)\n}*/\n\nfunc optString(nlist []string, defaultStr string) string {\n\tif len(nlist) == 0 {\n\t\treturn defaultStr\n\t}\n\treturn nlist[0]\n}\n"
  },
  {
    "path": "query_gen/mssql.go",
    "content": "/* WIP Under Really Heavy Construction */\npackage qgen\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"log\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nfunc init() {\n\tRegistry = append(Registry,\n\t\t&MssqlAdapter{Name: \"mssql\", Buffer: make(map[string]DBStmt)},\n\t)\n}\n\ntype MssqlAdapter struct {\n\tName        string // ? - Do we really need this? Can't we hard-code this?\n\tBuffer      map[string]DBStmt\n\tBufferOrder []string // Map iteration order is random, so we need this to track the order, so we don't get huge diffs every commit\n\tkeys        map[string]string\n}\n\n// GetName gives you the name of the database adapter. In this case, it's Mssql\nfunc (a *MssqlAdapter) GetName() string {\n\treturn a.Name\n}\n\nfunc (a *MssqlAdapter) GetStmt(name string) DBStmt {\n\treturn a.Buffer[name]\n}\n\nfunc (a *MssqlAdapter) GetStmts() map[string]DBStmt {\n\treturn a.Buffer\n}\n\n// TODO: Implement this\nfunc (a *MssqlAdapter) BuildConn(config map[string]string) (*sql.DB, error) {\n\treturn nil, nil\n}\n\nfunc (a *MssqlAdapter) DbVersion() string {\n\treturn \"SELECT CONCAT(SERVERPROPERTY('productversion'), SERVERPROPERTY ('productlevel'), SERVERPROPERTY ('edition'))\"\n}\n\nfunc (a *MssqlAdapter) DropTable(name, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"DROP TABLE IF EXISTS [\" + table + \"];\"\n\ta.pushStatement(name, \"drop-table\", q)\n\treturn q, nil\n}\n\n// TODO: Add support for foreign keys?\n// TODO: Convert any remaining stringy types to nvarchar\n// We may need to change the CreateTable API to better suit Mssql and the other database drivers which are coming up\nfunc (a *MssqlAdapter) CreateTable(name, table, charset, collation string, columns []DBTableColumn, keys []DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"You can't have a table with no columns\")\n\t}\n\n\tq := \"CREATE TABLE [\" + table + \"] (\"\n\tfor _, column := range columns {\n\t\tcolumn, size, end := a.parseColumn(column)\n\t\tq += \"\\n\\t[\" + column.Name + \"] \" + column.Type + size + end + \",\"\n\t}\n\n\tif len(keys) > 0 {\n\t\tfor _, key := range keys {\n\t\t\tq += \"\\n\\t\" + key.Type\n\t\t\tif key.Type != \"unique\" {\n\t\t\t\tq += \" key\"\n\t\t\t}\n\t\t\tq += \"(\"\n\t\t\tfor _, column := range strings.Split(key.Columns, \",\") {\n\t\t\t\tq += \"[\" + column + \"],\"\n\t\t\t}\n\t\t\tq = q[0:len(q)-1] + \"),\"\n\t\t}\n\t}\n\n\tq = q[0:len(q)-1] + \"\\n);\"\n\ta.pushStatement(name, \"create-table\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) parseColumn(column DBTableColumn) (col DBTableColumn, size string, end string) {\n\tvar max, createdAt bool\n\tswitch column.Type {\n\tcase \"createdAt\":\n\t\tcolumn.Type = \"datetime\"\n\t\tcreatedAt = true\n\tcase \"varchar\":\n\t\tcolumn.Type = \"nvarchar\"\n\tcase \"text\":\n\t\tcolumn.Type = \"nvarchar\"\n\t\tmax = true\n\tcase \"json\":\n\t\tcolumn.Type = \"nvarchar\"\n\t\tmax = true\n\tcase \"boolean\":\n\t\tcolumn.Type = \"bit\"\n\t}\n\tif column.Size > 0 {\n\t\tsize = \" (\" + strconv.Itoa(column.Size) + \")\"\n\t}\n\tif max {\n\t\tsize = \" (MAX)\"\n\t}\n\n\tif column.Default != \"\" {\n\t\tend = \" DEFAULT \"\n\t\tif createdAt {\n\t\t\tend += \"GETUTCDATE()\" // TODO: Use GETUTCDATE() in updates instead of the neutral format\n\t\t} else if a.stringyType(column.Type) && column.Default != \"''\" {\n\t\t\tend += \"'\" + column.Default + \"'\"\n\t\t} else {\n\t\t\tend += column.Default\n\t\t}\n\t}\n\tif !column.Null {\n\t\tend += \" not null\"\n\t}\n\n\t// ! Not exactly the meaning of auto increment...\n\tif column.AutoIncrement {\n\t\tend += \" IDENTITY\"\n\t}\n\treturn column, size, end\n}\n\n// TODO: Test this, not sure if some things work\n// TODO: Add support for keys\nfunc (a *MssqlAdapter) AddColumn(name, table string, column DBTableColumn, key *DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tcolumn, size, end := a.parseColumn(column)\n\tq := \"ALTER TABLE [\" + table + \"] ADD [\" + column.Name + \"] \" + column.Type + size + end + \";\"\n\ta.pushStatement(name, \"add-column\", q)\n\treturn q, nil\n}\n\n// TODO: Implement this\nfunc (a *MssqlAdapter) DropColumn(name, table, colName string) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *MssqlAdapter) RenameColumn(name, table, oldName, newName string) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *MssqlAdapter) ChangeColumn(name, table, colName string, col DBTableColumn) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *MssqlAdapter) SetDefaultColumn(name, table, colName, colType, defaultStr string) (string, error) {\n\tif colType == \"text\" {\n\t\treturn \"\", errors.New(\"text fields cannot have default values\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *MssqlAdapter) AddIndex(name, table, iname, colname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\tif colname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the column\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *MssqlAdapter) AddKey(name, table, column string, key DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif column == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the column\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *MssqlAdapter) RemoveIndex(name, table, iname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *MssqlAdapter) AddForeignKey(name, table, column, ftable, fcolumn string, cascade bool) (out string, e error) {\n\tc := func(str string, val bool) {\n\t\tif e != nil || !val {\n\t\t\treturn\n\t\t}\n\t\te = errors.New(\"You need a \" + str + \" for this table\")\n\t}\n\tc(\"name\", table == \"\")\n\tc(\"column\", column == \"\")\n\tc(\"ftable\", ftable == \"\")\n\tc(\"fcolumn\", fcolumn == \"\")\n\tif e != nil {\n\t\treturn \"\", e\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\nfunc (a *MssqlAdapter) SimpleInsert(name, table, cols, fields string) (string, error) {\n\tq, err := a.simpleBulkInsert(name, table, cols, []string{fields})\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, err\n}\n\nfunc (a *MssqlAdapter) SimpleBulkInsert(name, table, cols string, fieldSet []string) (string, error) {\n\tq, err := a.simpleBulkInsert(name, table, cols, fieldSet)\n\ta.pushStatement(name, \"bulk-insert\", q)\n\treturn q, err\n}\n\nfunc (a *MssqlAdapter) simpleBulkInsert(name, table, cols string, fieldSet []string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tq := \"INSERT INTO [\" + table + \"] (\"\n\tif cols == \"\" {\n\t\tq += \") VALUES ()\"\n\t\ta.pushStatement(name, \"insert\", q)\n\t\treturn q, nil\n\t}\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tfor _, col := range processColumns(cols) {\n\t\tif col.Type == TokenFunc {\n\t\t\tq += col.Left + \",\"\n\t\t} else {\n\t\t\tq += \"[\" + col.Left + \"],\"\n\t\t}\n\t}\n\tq = q[0 : len(q)-1]\n\n\tq += \") VALUES (\"\n\tfor oi, fields := range fieldSet {\n\t\tif oi != 0 {\n\t\t\tq += \",(\"\n\t\t}\n\t\tfor _, field := range processFields(fields) {\n\t\t\tfield.Name = strings.Replace(field.Name, \"UTC_TIMESTAMP()\", \"GETUTCDATE()\", -1)\n\t\t\t//log.Print(\"field.Name \", field.Name)\n\t\t\tnameLen := len(field.Name)\n\t\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\t\tfield.Name = \"'\" + field.Name[1:nameLen-1] + \"'\"\n\t\t\t}\n\t\t\tif field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\t\tfield.Name = \"'\" + strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1) + \"'\"\n\t\t\t}\n\t\t\tq += field.Name + \",\"\n\t\t}\n\t\tq = q[0:len(q)-1] + \")\"\n\t}\n\treturn q, nil\n}\n\n// ! DEPRECATED\nfunc (a *MssqlAdapter) SimpleReplace(name, table, columns, fields string) (string, error) {\n\tlog.Print(\"In SimpleReplace\")\n\tkey, ok := a.keys[table]\n\tif !ok {\n\t\treturn \"\", errors.New(\"Unable to elide key from table '\" + table + \"', please use SimpleUpsert (coming soon!) instead\")\n\t}\n\tlog.Print(\"After the key check\")\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tvar keyPosition int\n\tfor _, column := range processColumns(columns) {\n\t\tif column.Left == key {\n\t\t\tcontinue\n\t\t}\n\t\tkeyPosition++\n\t}\n\n\tvar keyValue string\n\tfor fieldID, field := range processFields(fields) {\n\t\tfield.Name = strings.Replace(field.Name, \"UTC_TIMESTAMP()\", \"GETUTCDATE()\", -1)\n\t\tnameLen := len(field.Name)\n\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\tfield.Name = \"'\" + field.Name[1:nameLen-1] + \"'\"\n\t\t}\n\t\tif field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\tfield.Name = \"'\" + strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1) + \"'\"\n\t\t}\n\t\tif keyPosition == fieldID {\n\t\t\tkeyValue = field.Name\n\t\t\tcontinue\n\t\t}\n\t}\n\treturn a.SimpleUpsert(name, table, columns, fields, \"key = \"+keyValue)\n}\n\nfunc (a *MssqlAdapter) SimpleUpsert(name, table, columns, fields, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInsert\")\n\t}\n\tif len(fields) == 0 {\n\t\treturn \"\", errors.New(\"No input data found for SimpleInsert\")\n\t}\n\n\tvar fieldCount int\n\tvar fieldOutput string\n\tq := \"MERGE [\" + table + \"] WITH(HOLDLOCK) as t1 USING (VALUES(\"\n\tparsedFields := processFields(fields)\n\tfor _, field := range parsedFields {\n\t\tfieldCount++\n\t\tfield.Name = strings.Replace(field.Name, \"UTC_TIMESTAMP()\", \"GETUTCDATE()\", -1)\n\t\t//log.Print(\"field.Name \", field.Name)\n\t\tnameLen := len(field.Name)\n\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\tfield.Name = \"'\" + field.Name[1:nameLen-1] + \"'\"\n\t\t}\n\t\tif field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\tfield.Name = \"'\" + strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1) + \"'\"\n\t\t}\n\t\tfieldOutput += field.Name + \",\"\n\t}\n\tfieldOutput = fieldOutput[0 : len(fieldOutput)-1]\n\tq += fieldOutput + \")) AS updates (\"\n\n\t// nolint The linter wants this to be less readable\n\tfor fieldID, _ := range parsedFields {\n\t\tq += \"f\" + strconv.Itoa(fieldID) + \",\"\n\t}\n\tq = q[0:len(q)-1] + \") ON \"\n\n\t//querystr += \"t1.[\" + key + \"] = \"\n\t// Add support for BETWEEN x.x\n\tfor _, loc := range processWhere(where) {\n\t\tfor _, token := range loc.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenSub:\n\t\t\t\tq += \" ?\"\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t}\n\t\t\t\tq += \" \" + token.Contents\n\t\t\tcase TokenColumn:\n\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\tcase TokenString:\n\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\tdefault:\n\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t}\n\t\t}\n\t}\n\n\tmatched := \" WHEN MATCHED THEN UPDATE SET \"\n\tnotMatched := \"WHEN NOT MATCHED THEN INSERT(\"\n\tvar fieldList string\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tfor columnID, col := range processColumns(columns) {\n\t\tfieldList += \"f\" + strconv.Itoa(columnID) + \",\"\n\t\tif col.Type == TokenFunc {\n\t\t\tmatched += col.Left + \" = f\" + strconv.Itoa(columnID) + \",\"\n\t\t\tnotMatched += col.Left + \",\"\n\t\t} else {\n\t\t\tmatched += \"[\" + col.Left + \"] = f\" + strconv.Itoa(columnID) + \",\"\n\t\t\tnotMatched += \"[\" + col.Left + \"],\"\n\t\t}\n\t}\n\n\tmatched = matched[0 : len(matched)-1]\n\tnotMatched = notMatched[0 : len(notMatched)-1]\n\tfieldList = fieldList[0 : len(fieldList)-1]\n\n\tnotMatched += \") VALUES (\" + fieldList + \");\"\n\tq += matched + \" \" + notMatched\n\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"upsert\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleUpdate(up *updatePrebuilder) (string, error) {\n\tif up.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif up.set == \"\" {\n\t\treturn \"\", errors.New(\"You need to set data in this update statement\")\n\t}\n\n\tq := \"UPDATE [\" + up.table + \"] SET \"\n\tfor _, item := range processSet(up.set) {\n\t\tq += \"[\" + item.Column + \"]=\"\n\t\tfor _, token := range item.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenSub:\n\t\t\t\tq += \" ?\"\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr:\n\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t}\n\t\t\t\tq += \" \" + token.Contents\n\t\t\tcase TokenColumn:\n\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\tcase TokenString:\n\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\tdefault:\n\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t}\n\t\t}\n\t\tq += \",\"\n\t}\n\tq = q[0 : len(q)-1]\n\n\t// Add support for BETWEEN x.x\n\tif len(up.where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(up.where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\ta.pushStatement(up.name, \"update\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleUpdateSelect(b *updatePrebuilder) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\nfunc (a *MssqlAdapter) SimpleDelete(name string, table string, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif where == \"\" {\n\t\treturn \"\", errors.New(\"You need to specify what data you want to delete\")\n\t}\n\tq := \"DELETE FROM [\" + table + \"] WHERE\"\n\n\t// Add support for BETWEEN x.x\n\tfor _, loc := range processWhere(where) {\n\t\tfor _, token := range loc.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenSub:\n\t\t\t\tq += \" ?\"\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t}\n\t\t\t\tq += \" \" + token.Contents\n\t\t\tcase TokenColumn:\n\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\tcase TokenString:\n\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\tdefault:\n\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t}\n\t\t}\n\t\tq += \" AND\"\n\t}\n\n\tq = strings.TrimSpace(q[0 : len(q)-4])\n\ta.pushStatement(name, \"delete\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) ComplexDelete(b *deletePrebuilder) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// We don't want to accidentally wipe tables, so we'll have a separate method for purging tables instead\nfunc (a *MssqlAdapter) Purge(name string, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"DELETE FROM [\" + table + \"]\"\n\ta.pushStatement(name, \"purge\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleSelect(name string, table string, columns string, where string, orderby string, limit string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleSelect\")\n\t}\n\t// TODO: Add this to the MySQL adapter in order to make this problem more discoverable?\n\tif len(orderby) == 0 && limit != \"\" {\n\t\treturn \"\", errors.New(\"Orderby needs to be set to use limit on Mssql\")\n\t}\n\tsubCount := 0\n\tq := \"\"\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tcolslice := strings.Split(strings.TrimSpace(columns), \",\")\n\tfor _, column := range colslice {\n\t\tq += \"[\" + strings.TrimSpace(column) + \"],\"\n\t}\n\tq = q[0:len(q)-1] + \" FROM [\" + table + \"]\"\n\n\t// Add support for BETWEEN x.x\n\tif len(where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenSub:\n\t\t\t\t\tsubCount++\n\t\t\t\t\tq += \" ?\" + strconv.Itoa(subCount)\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\t// MSSQL seems to convert the formats? so we'll compare it with a regular date. Do this with the other methods too?\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\t// TODO: MSSQL requires ORDER BY for LIMIT\n\tif len(orderby) != 0 {\n\t\tq += \" ORDER BY \"\n\t\tfor _, column := range processOrderby(orderby) {\n\t\t\t// TODO: We might want to escape this column\n\t\t\tq += column.Column + \" \" + strings.ToUpper(column.Order) + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t}\n\n\tif limit != \"\" {\n\t\tlimiter := processLimit(limit)\n\t\tlog.Printf(\"limiter: %+v\\n\", limiter)\n\t\tif limiter.Offset != \"\" {\n\t\t\tif limiter.Offset == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tq += \" OFFSET ?\" + strconv.Itoa(subCount) + \" ROWS\"\n\t\t\t} else {\n\t\t\t\tq += \" OFFSET \" + limiter.Offset + \" ROWS\"\n\t\t\t}\n\t\t}\n\n\t\t// ! Does this work without an offset?\n\t\tif limiter.MaxCount != \"\" {\n\t\t\tif limiter.MaxCount == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tlimiter.MaxCount = \"?\" + strconv.Itoa(subCount)\n\t\t\t}\n\t\t\tq += \" FETCH NEXT \" + limiter.MaxCount + \" ROWS ONLY \"\n\t\t}\n\t}\n\n\tq = strings.TrimSpace(\"SELECT \" + q)\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' && limit == \"\" {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\n// TODO: ComplexSelect\nfunc (a *MssqlAdapter) ComplexSelect(preBuilder *selectPrebuilder) (string, error) {\n\treturn \"\", nil\n}\n\nfunc (a *MssqlAdapter) SimpleLeftJoin(name string, table1 string, table2 string, columns string, joiners string, where string, orderby string, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleLeftJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleLeftJoin\")\n\t}\n\t// TODO: Add this to the MySQL adapter in order to make this problem more discoverable?\n\tif len(orderby) == 0 && limit != \"\" {\n\t\treturn \"\", errors.New(\"Orderby needs to be set to use limit on Mssql\")\n\t}\n\tsubCount := 0\n\tq := \"\"\n\n\tfor _, col := range processColumns(columns) {\n\t\tvar source, alias string\n\t\t// Escape the column names, just in case we've used a reserved keyword\n\t\tif col.Table != \"\" {\n\t\t\tsource = \"[\" + col.Table + \"].[\" + col.Left + \"]\"\n\t\t} else if col.Type == TokenFunc {\n\t\t\tsource = col.Left\n\t\t} else {\n\t\t\tsource = \"[\" + col.Left + \"]\"\n\t\t}\n\n\t\tif col.Alias != \"\" {\n\t\t\talias = \" AS '\" + col.Alias + \"'\"\n\t\t}\n\t\tq += source + alias + \",\"\n\t}\n\t// Remove the trailing comma\n\tq = q[0 : len(q)-1]\n\n\tq += \" FROM [\" + table1 + \"] LEFT JOIN [\" + table2 + \"] ON \"\n\tfor _, j := range processJoiner(joiners) {\n\t\tq += \"[\" + j.LeftTable + \"].[\" + j.LeftColumn + \"]\" + j.Operator + \"[\" + j.RightTable + \"].[\" + j.RightColumn + \"] AND \"\n\t}\n\t// Remove the trailing AND\n\tq = q[0 : len(q)-4]\n\n\t// Add support for BETWEEN x.x\n\tif len(where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenSub:\n\t\t\t\t\tsubCount++\n\t\t\t\t\tq += \" ?\" + strconv.Itoa(subCount)\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\thalves := strings.Split(token.Contents, \".\")\n\t\t\t\t\tif len(halves) == 2 {\n\t\t\t\t\t\tq += \" [\" + halves[0] + \"].[\" + halves[1] + \"]\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\t\t}\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\t// TODO: MSSQL requires ORDER BY for LIMIT\n\tif len(orderby) != 0 {\n\t\tq += \" ORDER BY \"\n\t\tfor _, column := range processOrderby(orderby) {\n\t\t\tlog.Print(\"column: \", column)\n\t\t\t// TODO: We might want to escape this column\n\t\t\tq += column.Column + \" \" + strings.ToUpper(column.Order) + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t} else if limit != \"\" {\n\t\tkey, ok := a.keys[table1]\n\t\tif ok {\n\t\t\tq += \" ORDER BY [\" + table1 + \"].[\" + key + \"]\"\n\t\t}\n\t}\n\n\tif limit != \"\" {\n\t\tlimiter := processLimit(limit)\n\t\tif limiter.Offset != \"\" {\n\t\t\tif limiter.Offset == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tq += \" OFFSET ?\" + strconv.Itoa(subCount) + \" ROWS\"\n\t\t\t} else {\n\t\t\t\tq += \" OFFSET \" + limiter.Offset + \" ROWS\"\n\t\t\t}\n\t\t}\n\n\t\t// ! Does this work without an offset?\n\t\tif limiter.MaxCount != \"\" {\n\t\t\tif limiter.MaxCount == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tlimiter.MaxCount = \"?\" + strconv.Itoa(subCount)\n\t\t\t}\n\t\t\tq += \" FETCH NEXT \" + limiter.MaxCount + \" ROWS ONLY \"\n\t\t}\n\t}\n\n\tq = strings.TrimSpace(\"SELECT \" + q)\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' && limit == \"\" {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleInnerJoin(name string, table1 string, table2 string, columns string, joiners string, where string, orderby string, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInnerJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleInnerJoin\")\n\t}\n\t// TODO: Add this to the MySQL adapter in order to make this problem more discoverable?\n\tif len(orderby) == 0 && limit != \"\" {\n\t\treturn \"\", errors.New(\"Orderby needs to be set to use limit on Mssql\")\n\t}\n\tsubCount := 0\n\tq := \"\"\n\n\tfor _, col := range processColumns(columns) {\n\t\tvar source, alias string\n\t\t// Escape the column names, just in case we've used a reserved keyword\n\t\tif col.Table != \"\" {\n\t\t\tsource = \"[\" + col.Table + \"].[\" + col.Left + \"]\"\n\t\t} else if col.Type == TokenFunc {\n\t\t\tsource = col.Left\n\t\t} else {\n\t\t\tsource = \"[\" + col.Left + \"]\"\n\t\t}\n\n\t\tif col.Alias != \"\" {\n\t\t\talias = \" AS '\" + col.Alias + \"'\"\n\t\t}\n\t\tq += source + alias + \",\"\n\t}\n\t// Remove the trailing comma\n\tq = q[0 : len(q)-1]\n\n\tq += \" FROM [\" + table1 + \"] INNER JOIN [\" + table2 + \"] ON \"\n\tfor _, j := range processJoiner(joiners) {\n\t\tq += \"[\" + j.LeftTable + \"].[\" + j.LeftColumn + \"]\" + j.Operator + \"[\" + j.RightTable + \"].[\" + j.RightColumn + \"] AND \"\n\t}\n\t// Remove the trailing AND\n\tq = q[0 : len(q)-4]\n\n\t// Add support for BETWEEN x.x\n\tif len(where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenSub:\n\t\t\t\t\tsubCount++\n\t\t\t\t\tq += \" ?\" + strconv.Itoa(subCount)\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\thalves := strings.Split(token.Contents, \".\")\n\t\t\t\t\tif len(halves) == 2 {\n\t\t\t\t\t\tq += \" [\" + halves[0] + \"].[\" + halves[1] + \"]\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\t\t}\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\t// TODO: MSSQL requires ORDER BY for LIMIT\n\tif len(orderby) != 0 {\n\t\tq += \" ORDER BY \"\n\t\tfor _, column := range processOrderby(orderby) {\n\t\t\tlog.Print(\"column: \", column)\n\t\t\t// TODO: We might want to escape this column\n\t\t\tq += column.Column + \" \" + strings.ToUpper(column.Order) + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t} else if limit != \"\" {\n\t\tkey, ok := a.keys[table1]\n\t\tif ok {\n\t\t\tlog.Print(\"key: \", key)\n\t\t\tq += \" ORDER BY [\" + table1 + \"].[\" + key + \"]\"\n\t\t}\n\t}\n\n\tif limit != \"\" {\n\t\tlimiter := processLimit(limit)\n\t\tif limiter.Offset != \"\" {\n\t\t\tif limiter.Offset == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tq += \" OFFSET ?\" + strconv.Itoa(subCount) + \" ROWS\"\n\t\t\t} else {\n\t\t\t\tq += \" OFFSET \" + limiter.Offset + \" ROWS\"\n\t\t\t}\n\t\t}\n\n\t\t// ! Does this work without an offset?\n\t\tif limiter.MaxCount != \"\" {\n\t\t\tif limiter.MaxCount == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tlimiter.MaxCount = \"?\" + strconv.Itoa(subCount)\n\t\t\t}\n\t\t\tq += \" FETCH NEXT \" + limiter.MaxCount + \" ROWS ONLY \"\n\t\t}\n\t}\n\n\tq = strings.TrimSpace(\"SELECT \" + q)\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' && limit == \"\" {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleInsertSelect(name string, ins DBInsert, sel DBSelect) (string, error) {\n\t// TODO: More errors.\n\t// TODO: Add this to the MySQL adapter in order to make this problem more discoverable?\n\tif len(sel.Orderby) == 0 && sel.Limit != \"\" {\n\t\treturn \"\", errors.New(\"Orderby needs to be set to use limit on Mssql\")\n\t}\n\n\t/* Insert */\n\tq := \"INSERT INTO [\" + ins.Table + \"] (\"\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tfor _, col := range processColumns(ins.Columns) {\n\t\tif col.Type == TokenFunc {\n\t\t\tq += col.Left + \",\"\n\t\t} else {\n\t\t\tq += \"[\" + col.Left + \"],\"\n\t\t}\n\t}\n\tq = q[0:len(q)-1] + \") SELECT \"\n\n\t/* Select */\n\tsubCount := 0\n\n\tfor _, col := range processColumns(sel.Columns) {\n\t\tvar source, alias string\n\t\t// Escape the column names, just in case we've used a reserved keyword\n\t\tif col.Type == TokenFunc || col.Type == TokenSub {\n\t\t\tsource = col.Left\n\t\t} else {\n\t\t\tsource = \"[\" + col.Left + \"]\"\n\t\t}\n\t\tif col.Alias != \"\" {\n\t\t\talias = \" AS [\" + col.Alias + \"]\"\n\t\t}\n\t\tq += \" \" + source + alias + \",\"\n\t}\n\tq = q[0:len(q)-1] + \" FROM [\" + sel.Table + \"] \"\n\n\t// Add support for BETWEEN x.x\n\tif len(sel.Where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(sel.Where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenSub:\n\t\t\t\t\tsubCount++\n\t\t\t\t\tq += \" ?\" + strconv.Itoa(subCount)\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\t// TODO: MSSQL requires ORDER BY for LIMIT\n\tif len(sel.Orderby) != 0 {\n\t\tq += \" ORDER BY \"\n\t\tfor _, column := range processOrderby(sel.Orderby) {\n\t\t\t// TODO: We might want to escape this column\n\t\t\tq += column.Column + \" \" + strings.ToUpper(column.Order) + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t} else if sel.Limit != \"\" {\n\t\tkey, ok := a.keys[sel.Table]\n\t\tif ok {\n\t\t\tq += \" ORDER BY [\" + sel.Table + \"].[\" + key + \"]\"\n\t\t}\n\t}\n\n\tif sel.Limit != \"\" {\n\t\tlimiter := processLimit(sel.Limit)\n\t\tif limiter.Offset != \"\" {\n\t\t\tif limiter.Offset == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tq += \" OFFSET ?\" + strconv.Itoa(subCount) + \" ROWS\"\n\t\t\t} else {\n\t\t\t\tq += \" OFFSET \" + limiter.Offset + \" ROWS\"\n\t\t\t}\n\t\t}\n\n\t\t// ! Does this work without an offset?\n\t\tif limiter.MaxCount != \"\" {\n\t\t\tif limiter.MaxCount == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tlimiter.MaxCount = \"?\" + strconv.Itoa(subCount)\n\t\t\t}\n\t\t\tq += \" FETCH NEXT \" + limiter.MaxCount + \" ROWS ONLY \"\n\t\t}\n\t}\n\n\tq = strings.TrimSpace(q)\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' && sel.Limit == \"\" {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) simpleJoin(name string, ins DBInsert, sel DBJoin, joinType string) (string, error) {\n\t// TODO: More errors.\n\t// TODO: Add this to the MySQL adapter in order to make this problem more discoverable?\n\tif len(sel.Orderby) == 0 && sel.Limit != \"\" {\n\t\treturn \"\", errors.New(\"Orderby needs to be set to use limit on Mssql\")\n\t}\n\n\t/* Insert */\n\tq := \"INSERT INTO [\" + ins.Table + \"] (\"\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tfor _, col := range processColumns(ins.Columns) {\n\t\tif col.Type == TokenFunc {\n\t\t\tq += col.Left + \",\"\n\t\t} else {\n\t\t\tq += \"[\" + col.Left + \"],\"\n\t\t}\n\t}\n\tq = q[0:len(q)-1] + \") SELECT \"\n\n\t/* Select */\n\tsubCount := 0\n\n\tfor _, col := range processColumns(sel.Columns) {\n\t\tvar source, alias string\n\t\t// Escape the column names, just in case we've used a reserved keyword\n\t\tif col.Table != \"\" {\n\t\t\tsource = \"[\" + col.Table + \"].[\" + col.Left + \"]\"\n\t\t} else if col.Type == TokenFunc {\n\t\t\tsource = col.Left\n\t\t} else {\n\t\t\tsource = \"[\" + col.Left + \"]\"\n\t\t}\n\t\tif col.Alias != \"\" {\n\t\t\talias = \" AS '\" + col.Alias + \"'\"\n\t\t}\n\t\tq += source + alias + \",\"\n\t}\n\tq = q[0 : len(q)-1]\n\n\tq += \" FROM [\" + sel.Table1 + \"] \" + joinType + \" JOIN [\" + sel.Table2 + \"] ON \"\n\tfor _, j := range processJoiner(sel.Joiners) {\n\t\tq += \"[\" + j.LeftTable + \"].[\" + j.LeftColumn + \"] \" + j.Operator + \" [\" + j.RightTable + \"].[\" + j.RightColumn + \"] AND \"\n\t}\n\tq = q[0 : len(q)-4]\n\n\t// Add support for BETWEEN x.x\n\tif len(sel.Where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(sel.Where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenSub:\n\t\t\t\t\tsubCount++\n\t\t\t\t\tq += \" ?\" + strconv.Itoa(subCount)\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\t// TODO: Split the function case off to speed things up\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\thalves := strings.Split(token.Contents, \".\")\n\t\t\t\t\tif len(halves) == 2 {\n\t\t\t\t\t\tq += \" [\" + halves[0] + \"].[\" + halves[1] + \"]\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\t\t}\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\t// TODO: MSSQL requires ORDER BY for LIMIT\n\tif len(sel.Orderby) != 0 {\n\t\tq += \" ORDER BY \"\n\t\tfor _, column := range processOrderby(sel.Orderby) {\n\t\t\tlog.Print(\"column: \", column)\n\t\t\t// TODO: We might want to escape this column\n\t\t\tq += column.Column + \" \" + strings.ToUpper(column.Order) + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t} else if sel.Limit != \"\" {\n\t\tkey, ok := a.keys[sel.Table1]\n\t\tif ok {\n\t\t\tq += \" ORDER BY [\" + sel.Table1 + \"].[\" + key + \"]\"\n\t\t}\n\t}\n\n\tif sel.Limit != \"\" {\n\t\tlimiter := processLimit(sel.Limit)\n\t\tif limiter.Offset != \"\" {\n\t\t\tif limiter.Offset == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tq += \" OFFSET ?\" + strconv.Itoa(subCount) + \" ROWS\"\n\t\t\t} else {\n\t\t\t\tq += \" OFFSET \" + limiter.Offset + \" ROWS\"\n\t\t\t}\n\t\t}\n\n\t\t// ! Does this work without an offset?\n\t\tif limiter.MaxCount != \"\" {\n\t\t\tif limiter.MaxCount == \"?\" {\n\t\t\t\tsubCount++\n\t\t\t\tlimiter.MaxCount = \"?\" + strconv.Itoa(subCount)\n\t\t\t}\n\t\t\tq += \" FETCH NEXT \" + limiter.MaxCount + \" ROWS ONLY \"\n\t\t}\n\t}\n\n\tq = strings.TrimSpace(q)\n\t// TODO: Run this on debug mode?\n\tif name[0] == '_' && sel.Limit == \"\" {\n\t\tlog.Print(name+\" query: \", q)\n\t}\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) SimpleInsertLeftJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\treturn a.simpleJoin(name, ins, sel, \"LEFT\")\n}\n\nfunc (a *MssqlAdapter) SimpleInsertInnerJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\treturn a.simpleJoin(name, ins, sel, \"INNER\")\n}\n\nfunc (a *MssqlAdapter) SimpleCount(name, table, where, limit string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"SELECT COUNT(*) FROM [\" + table + \"]\"\n\n\t// TODO: Add support for BETWEEN x.x\n\tif len(where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"GETUTCDATE()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tq += \" [\" + token.Contents + \"]\"\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\tif limit != \"\" {\n\t\tq += \" LIMIT \" + limit\n\t}\n\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MssqlAdapter) Builder() *prebuilder {\n\treturn &prebuilder{a}\n}\n\nfunc (a *MssqlAdapter) Write() error {\n\tvar stmts, body string\n\tfor _, name := range a.BufferOrder {\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tstmt := a.Buffer[name]\n\t\t// TODO: Add support for create-table? Table creation might be a little complex for Go to do outside a SQL file :(\n\t\tif stmt.Type != \"create-table\" {\n\t\t\tstmts += \"\\t\" + name + \" *sql.Stmt\\n\"\n\t\t\tbody += `\t\n\tcommon.DebugLog(\"Preparing ` + name + ` statement.\")\n\tstmts.` + name + `, err = db.Prepare(\"` + stmt.Contents + `\")\n\tif err != nil {\n\t\tlog.Print(\"Error in ` + name + ` statement.\")\n\t\tlog.Print(\"Bad Query: \",\"` + stmt.Contents + `\")\n\t\treturn err\n\t}\n\t`\n\t\t}\n\t}\n\n\t// TODO: Move these custom queries out of this file\n\tout := `// +build mssql\n\n// This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time.\npackage main\n\nimport \"log\"\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/common\"\n\n// nolint\ntype Stmts struct {\n` + stmts + `\n\tgetActivityFeedByWatcher *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_mssql() (err error) {\n\tcommon.DebugLog(\"Building the generated statements\")\n` + body + `\n\treturn nil\n}\n`\n\treturn writeFile(\"./gen_mssql.go\", out)\n}\n\n// Internal methods, not exposed in the interface\nfunc (a *MssqlAdapter) pushStatement(name, stype, q string) {\n\tif name == \"\" {\n\t\treturn\n\t}\n\ta.Buffer[name] = DBStmt{q, stype}\n\ta.BufferOrder = append(a.BufferOrder, name)\n}\n\nfunc (a *MssqlAdapter) stringyType(ct string) bool {\n\tct = strings.ToLower(ct)\n\treturn ct == \"char\" || ct == \"varchar\" || ct == \"datetime\" || ct == \"text\" || ct == \"nvarchar\"\n}\n\ntype SetPrimaryKeys interface {\n\tSetPrimaryKeys(keys map[string]string)\n}\n\nfunc (a *MssqlAdapter) SetPrimaryKeys(keys map[string]string) {\n\ta.keys = keys\n}\n"
  },
  {
    "path": "query_gen/mysql.go",
    "content": "/* WIP Under Construction */\npackage qgen\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\n\t//\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\nvar ErrNoCollation = errors.New(\"You didn't provide a collation\")\n\nfunc init() {\n\tRegistry = append(Registry,\n\t\t&MysqlAdapter{Name: \"mysql\", Buffer: make(map[string]DBStmt)},\n\t)\n}\n\ntype MysqlAdapter struct {\n\tName        string // ? - Do we really need this? Can't we hard-code this?\n\tBuffer      map[string]DBStmt\n\tBufferOrder []string // Map iteration order is random, so we need this to track the order, so we don't get huge diffs every commit\n}\n\n// GetName gives you the name of the database adapter. In this case, it's mysql\nfunc (a *MysqlAdapter) GetName() string {\n\treturn a.Name\n}\n\nfunc (a *MysqlAdapter) GetStmt(name string) DBStmt {\n\treturn a.Buffer[name]\n}\n\nfunc (a *MysqlAdapter) GetStmts() map[string]DBStmt {\n\treturn a.Buffer\n}\n\n// TODO: Add an option to disable unix pipes\nfunc (a *MysqlAdapter) BuildConn(config map[string]string) (*sql.DB, error) {\n\tdbCollation, ok := config[\"collation\"]\n\tif !ok {\n\t\treturn nil, ErrNoCollation\n\t}\n\tvar dbpassword string\n\tif config[\"password\"] != \"\" {\n\t\tdbpassword = \":\" + config[\"password\"]\n\t}\n\n\t// First try opening a pipe as those are faster\n\tif runtime.GOOS == \"linux\" {\n\t\tdbsocket := \"/tmp/mysql.sock\"\n\t\tif config[\"socket\"] != \"\" {\n\t\t\tdbsocket = config[\"socket\"]\n\t\t}\n\n\t\t// The MySQL adapter refuses to open any other connections, if the unix socket doesn't exist, so check for it first\n\t\t_, err := os.Stat(dbsocket)\n\t\tif err == nil {\n\t\t\tdb, err := sql.Open(\"mysql\", config[\"username\"]+dbpassword+\"@unix(\"+dbsocket+\")/\"+config[\"name\"]+\"?collation=\"+dbCollation+\"&parseTime=true\")\n\t\t\tif err == nil {\n\t\t\t\t// Make sure that the connection is alive\n\t\t\t\treturn db, db.Ping()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Open the database connection\n\tdb, err := sql.Open(\"mysql\", config[\"username\"]+dbpassword+\"@tcp(\"+config[\"host\"]+\":\"+config[\"port\"]+\")/\"+config[\"name\"]+\"?collation=\"+dbCollation+\"&parseTime=true\")\n\tif err != nil {\n\t\treturn db, err\n\t}\n\n\t// Make sure that the connection is alive\n\treturn db, db.Ping()\n}\n\nfunc (a *MysqlAdapter) DbVersion() string {\n\treturn \"SELECT VERSION()\"\n}\n\nfunc (a *MysqlAdapter) DropTable(name, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"DROP TABLE IF EXISTS `\" + table + \"`;\"\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"drop-table\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) CreateTable(name, table, charset, collation string, cols []DBTableColumn, keys []DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(cols) == 0 {\n\t\treturn \"\", errors.New(\"You can't have a table with no columns\")\n\t}\n\n\tvar qsb strings.Builder\n\t//q := \"CREATE TABLE `\" + table + \"`(\"\n\tw := func(s string) {\n\t\tqsb.WriteString(s)\n\t}\n\tw(\"CREATE TABLE `\")\n\tw(table)\n\tw(\"`(\")\n\tfor i, col := range cols {\n\t\tif i != 0 {\n\t\t\tw(\",\\n\\t`\")\n\t\t} else {\n\t\t\tw(\"\\n\\t`\")\n\t\t}\n\t\tcol, size, end := a.parseColumn(col)\n\t\t//q += \"\\n\\t`\" + col.Name + \"` \" + col.Type + size + end + \",\"\n\t\tw(col.Name)\n\t\tw(\"` \")\n\t\tw(col.Type)\n\t\tw(size)\n\t\tw(end)\n\t}\n\n\tif len(keys) > 0 {\n\t\t/*if len(cols) > 0 {\n\t\t\tw(\",\")\n\t\t}*/\n\t\tfor _, k := range keys {\n\t\t\t/*if ii != 0 {\n\t\t\t\tw(\",\\n\\t\")\n\t\t\t} else {\n\t\t\t\tw(\"\\n\\t\")\n\t\t\t}*/\n\t\t\tw(\",\\n\\t\")\n\t\t\t//q += \"\\n\\t\" + key.Type\n\t\t\tw(k.Type)\n\t\t\tif k.Type != \"unique\" {\n\t\t\t\t//q += \" key\"\n\t\t\t\tw(\" key\")\n\t\t\t}\n\t\t\tif k.Type == \"foreign\" {\n\t\t\t\tcols := strings.Split(k.Columns, \",\")\n\t\t\t\t//q += \"(`\" + cols[0] + \"`) REFERENCES `\" + k.FTable + \"`(`\" + cols[1] + \"`)\"\n\t\t\t\tw(\"(`\")\n\t\t\t\tw(cols[0])\n\t\t\t\tw(\"`) REFERENCES `\")\n\t\t\t\tw(k.FTable)\n\t\t\t\tw(\"`(`\")\n\t\t\t\tw(cols[1])\n\t\t\t\tw(\"`)\")\n\t\t\t\tif k.Cascade {\n\t\t\t\t\t//q += \" ON DELETE CASCADE\"\n\t\t\t\t\tw(\" ON DELETE CASCADE\")\n\t\t\t\t}\n\t\t\t\t//q += \",\"\n\t\t\t} else {\n\t\t\t\t//q += \"(\"\n\t\t\t\tw(\"(\")\n\t\t\t\tfor i, col := range strings.Split(k.Columns, \",\") {\n\t\t\t\t\t/*if i != 0 {\n\t\t\t\t\t\tq += \",`\" + col + \"`\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tq += \"`\" + col + \"`\"\n\t\t\t\t\t}*/\n\t\t\t\t\tif i != 0 {\n\t\t\t\t\t\tw(\",`\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\tw(\"`\")\n\t\t\t\t\t}\n\t\t\t\t\tw(col)\n\t\t\t\t\tw(\"`\")\n\t\t\t\t}\n\t\t\t\t//q += \"),\"\n\t\t\t\tw(\")\")\n\t\t\t}\n\t\t}\n\t}\n\n\t//q = q[0:len(q)-1] + \"\\n)\"\n\tw(\"\\n)\")\n\tif charset != \"\" {\n\t\t//q += \" CHARSET=\" + charset\n\t\tw(\" CHARSET=\")\n\t\tw(charset)\n\t}\n\tif collation != \"\" {\n\t\t//q += \" COLLATE \" + collation\n\t\tw(\" COLLATE \")\n\t\tw(collation)\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\t//q += \";\"\n\tw(\";\")\n\tq := qsb.String()\n\ta.pushStatement(name, \"create-table\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) DropColumn(name, table, colName string) (string, error) {\n\tq := \"ALTER TABLE `\" + table + \"` DROP COLUMN `\" + colName + \"`;\"\n\ta.pushStatement(name, \"drop-column\", q)\n\treturn q, nil\n}\n\n// ! Currently broken in MariaDB. Planned.\nfunc (a *MysqlAdapter) RenameColumn(name, table, oldName, newName string) (string, error) {\n\tq := \"ALTER TABLE `\" + table + \"` RENAME COLUMN `\" + oldName + \"` TO `\" + newName + \"`;\"\n\ta.pushStatement(name, \"rename-column\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) ChangeColumn(name, table, colName string, col DBTableColumn) (string, error) {\n\tcol.Default = \"\"\n\tcol, size, end := a.parseColumn(col)\n\tq := \"ALTER TABLE `\" + table + \"` CHANGE COLUMN `\" + colName + \"` `\" + col.Name + \"` \" + col.Type + size + end\n\ta.pushStatement(name, \"change-column\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SetDefaultColumn(name, table, colName, colType, defaultStr string) (string, error) {\n\tif defaultStr == \"\" {\n\t\tdefaultStr = \"''\"\n\t}\n\t// TODO: Exclude the other variants of text like mediumtext and longtext too\n\texpr := \"\"\n\t/*if colType == \"datetime\" && defaultStr[len(defaultStr)-1] == ')' {\n\t\tend += defaultStr\n\t} else */if a.stringyType(colType) && defaultStr != \"''\" {\n\t\texpr += \"'\" + defaultStr + \"'\"\n\t} else {\n\t\texpr += defaultStr\n\t}\n\tq := \"ALTER TABLE `\" + table + \"` ALTER COLUMN `\" + colName + \"` SET DEFAULT \" + expr + \";\"\n\ta.pushStatement(name, \"set-default-column\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) parseColumn(col DBTableColumn) (ocol DBTableColumn, size, end string) {\n\t// Make it easier to support Cassandra in the future\n\tif col.Type == \"createdAt\" {\n\t\tcol.Type = \"datetime\"\n\t\t// MySQL doesn't support this x.x\n\t\t/*if col.Default == \"\" {\n\t\t\tcol.Default = \"UTC_TIMESTAMP()\"\n\t\t}*/\n\t} else if col.Type == \"json\" {\n\t\tcol.Type = \"text\"\n\t}\n\tif col.Size > 0 {\n\t\tsize = \"(\" + strconv.Itoa(col.Size) + \")\"\n\t}\n\n\t// TODO: Exclude the other variants of text like mediumtext and longtext too\n\tif col.Default != \"\" && col.Type != \"text\" {\n\t\tend = \" DEFAULT \"\n\t\t/*if col.Type == \"datetime\" && col.Default[len(col.Default)-1] == ')' {\n\t\t\tend += column.Default\n\t\t} else */if a.stringyType(col.Type) && col.Default != \"''\" {\n\t\t\tend += \"'\" + col.Default + \"'\"\n\t\t} else {\n\t\t\tend += col.Default\n\t\t}\n\t}\n\n\tif col.Null {\n\t\tend += \" null\"\n\t} else {\n\t\tend += \" not null\"\n\t}\n\tif col.AutoIncrement {\n\t\tend += \" AUTO_INCREMENT\"\n\t}\n\treturn col, size, end\n}\n\n// TODO: Support AFTER column\n// TODO: Test to make sure everything works here\nfunc (a *MysqlAdapter) AddColumn(name, table string, col DBTableColumn, key *DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tcol, size, end := a.parseColumn(col)\n\tq := \"ALTER TABLE `\" + table + \"` ADD COLUMN \" + \"`\" + col.Name + \"` \" + col.Type + size + end\n\n\tif key != nil {\n\t\tq += \" \" + key.Type\n\t\tif key.Type != \"unique\" {\n\t\t\tq += \" key\"\n\t\t} else if key.Type == \"primary\" {\n\t\t\tq += \" first\"\n\t\t}\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"add-column\", q)\n\treturn q, nil\n}\n\n// TODO: Test to make sure everything works here\nfunc (a *MysqlAdapter) AddIndex(name, table, iname, colname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\tif colname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the column\")\n\t}\n\n\tq := \"ALTER TABLE `\" + table + \"` ADD INDEX \" + \"`i_\" + iname + \"` (`\" + colname + \"`);\"\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"add-index\", q)\n\treturn q, nil\n}\n\n// TODO: Test to make sure everything works here\n// Only supports FULLTEXT right now\nfunc (a *MysqlAdapter) AddKey(name, table, cols string, key DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif cols == \"\" {\n\t\treturn \"\", errors.New(\"You need to specify columns\")\n\t}\n\n\tvar colstr string\n\tfor _, col := range strings.Split(cols, \",\") {\n\t\tcolstr += \"`\" + col + \"`,\"\n\t}\n\tif len(colstr) > 1 {\n\t\tcolstr = colstr[:len(colstr)-1]\n\t}\n\n\tvar q string\n\tif key.Type == \"fulltext\" {\n\t\tq = \"ALTER TABLE `\" + table + \"` ADD FULLTEXT(\" + colstr + \")\"\n\t} else {\n\t\treturn \"\", errors.New(\"Only fulltext is supported by AddKey right now\")\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"add-key\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) RemoveIndex(name, table, iname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\tq := \"ALTER TABLE `\" + table + \"` DROP INDEX `\" + iname + \"`\"\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"remove-index\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) AddForeignKey(name, table, col, ftable, fcolumn string, cascade bool) (out string, e error) {\n\tc := func(str string, val bool) {\n\t\tif e != nil || !val {\n\t\t\treturn\n\t\t}\n\t\te = errors.New(\"You need a \" + str + \" for this table\")\n\t}\n\tc(\"name\", table == \"\")\n\tc(\"col\", col == \"\")\n\tc(\"ftable\", ftable == \"\")\n\tc(\"fcolumn\", fcolumn == \"\")\n\tif e != nil {\n\t\treturn \"\", e\n\t}\n\n\tq := \"ALTER TABLE `\" + table + \"` ADD CONSTRAINT `fk_\" + col + \"` FOREIGN KEY(`\" + col + \"`) REFERENCES `\" + ftable + \"`(`\" + fcolumn + \"`)\"\n\tif cascade {\n\t\tq += \" ON DELETE CASCADE\"\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"add-foreign-key\", q)\n\treturn q, nil\n}\n\nconst silen1 = len(\"INSERT INTO``()VALUES() \")\n\nfunc (a *MysqlAdapter) SimpleInsert(name, table, cols, fields string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\n\tsb.Grow(silen1 + len(table))\n\tsb.WriteString(\"INSERT INTO`\")\n\tsb.WriteString(table)\n\tif cols != \"\" {\n\t\tsb.WriteString(\"`(\")\n\t\tsb.WriteString(a.buildColumns(cols))\n\t\tsb.WriteString(\")VALUES(\")\n\t\tfs := processFields(fields)\n\t\tsb.Grow(len(fs) * 3)\n\t\tfor i, field := range fs {\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteString(\",\")\n\t\t\t}\n\t\t\tnameLen := len(field.Name)\n\t\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\t\tsb.WriteString(field.Name[1 : nameLen-1])\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\t} else if field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\t\tsb.WriteString(strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1))\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\t} else {\n\t\t\t\tsb.WriteString(field.Name)\n\t\t\t}\n\t\t}\n\t\tsb.WriteString(\")\")\n\t} else {\n\t\tsb.WriteString(\"`()VALUES()\")\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\tq := sb.String()\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleBulkInsert(name, table, cols string, fieldSet []string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.Grow(silen1 + len(table))\n\tsb.WriteString(\"INSERT INTO`\")\n\tsb.WriteString(table)\n\tif cols != \"\" {\n\t\tsb.WriteString(\"`(\")\n\t\tsb.WriteString(a.buildColumns(cols))\n\t\tsb.WriteString(\")VALUES(\")\n\t\tfor oi, fields := range fieldSet {\n\t\t\tif oi != 0 {\n\t\t\t\tsb.WriteString(\",(\")\n\t\t\t}\n\t\t\tfs := processFields(fields)\n\t\t\tsb.Grow(len(fs) * 3)\n\t\t\tfor i, field := range fs {\n\t\t\t\tif i != 0 {\n\t\t\t\t\tsb.WriteString(\",\")\n\t\t\t\t}\n\t\t\t\tnameLen := len(field.Name)\n\t\t\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\t\t\t//field.Name = \"'\" + field.Name[1:nameLen-1] + \"'\"\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\t\tsb.WriteString(field.Name[1 : nameLen-1])\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\t\t\t//field.Name = \"'\" + strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1) + \"'\"\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\t\tsb.WriteString(strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1))\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsb.WriteString(field.Name)\n\t\t\t}\n\t\t\tsb.WriteString(\")\")\n\t\t}\n\t} else {\n\t\tsb.WriteString(\"`()VALUES()\")\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\tq := sb.String()\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(name, \"bulk-insert\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) buildColumns(cols string) string {\n\tif cols == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// Escape the column names, just in case we've used a reserved keyword\n\tvar cb strings.Builder\n\tpcols := processColumns(cols)\n\tvar n int\n\tfor i, col := range pcols {\n\t\tif i != 0 {\n\t\t\tn += 1\n\t\t}\n\t\tif col.Type == TokenFunc {\n\t\t\tn += len(col.Left)\n\t\t} else {\n\t\t\tn += len(col.Left) + 2\n\t\t}\n\t}\n\tcb.Grow(n)\n\n\tfor i, col := range pcols {\n\t\tif col.Type == TokenFunc {\n\t\t\tif i != 0 {\n\t\t\t\tcb.WriteString(\",\")\n\t\t\t}\n\t\t\t//q += col.Left + \",\"\n\t\t\tcb.WriteString(col.Left)\n\t\t} else {\n\t\t\t//q += \"`\" + col.Left + \"`,\"\n\t\t\tif i != 0 {\n\t\t\t\tcb.WriteString(\",`\")\n\t\t\t} else {\n\t\t\t\tcb.WriteString(\"`\")\n\t\t\t}\n\t\t\tcb.WriteString(col.Left)\n\t\t\tcb.WriteString(\"`\")\n\t\t}\n\t}\n\n\treturn cb.String()\n}\n\n// ! DEPRECATED\nfunc (a *MysqlAdapter) SimpleReplace(name, table, cols, fields string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(cols) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInsert\")\n\t}\n\tif len(fields) == 0 {\n\t\treturn \"\", errors.New(\"No input data found for SimpleInsert\")\n\t}\n\n\tq := \"REPLACE INTO `\" + table + \"`(\" + a.buildColumns(cols) + \") VALUES (\"\n\tfor _, field := range processFields(fields) {\n\t\tq += field.Name + \",\"\n\t}\n\tq = q[0:len(q)-1] + \")\"\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"replace\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleUpsert(name, table, columns, fields, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInsert\")\n\t}\n\tif len(fields) == 0 {\n\t\treturn \"\", errors.New(\"No input data found for SimpleInsert\")\n\t}\n\tif where == \"\" {\n\t\treturn \"\", errors.New(\"You need a where for this upsert\")\n\t}\n\n\tq := \"INSERT INTO `\" + table + \"`(\"\n\tparsedFields := processFields(fields)\n\n\tvar insertColumns, insertValues string\n\tsetBit := \") ON DUPLICATE KEY UPDATE \"\n\n\tfor columnID, col := range processColumns(columns) {\n\t\tfield := parsedFields[columnID]\n\t\tif col.Type == TokenFunc {\n\t\t\tinsertColumns += col.Left + \",\"\n\t\t\tinsertValues += field.Name + \",\"\n\t\t\tsetBit += col.Left + \" = \" + field.Name + \" AND \"\n\t\t} else {\n\t\t\tinsertColumns += \"`\" + col.Left + \"`,\"\n\t\t\tinsertValues += field.Name + \",\"\n\t\t\tsetBit += \"`\" + col.Left + \"` = \" + field.Name + \" AND \"\n\t\t}\n\t}\n\tinsertColumns = insertColumns[0 : len(insertColumns)-1]\n\tinsertValues = insertValues[0 : len(insertValues)-1]\n\tinsertColumns += \") VALUES (\" + insertValues\n\tsetBit = setBit[0 : len(setBit)-5]\n\n\tq += insertColumns + setBit\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"upsert\", q)\n\treturn q, nil\n}\n\nconst sulen1 = len(\"UPDATE `` SET \")\n\nfunc (a *MysqlAdapter) SimpleUpdate(up *updatePrebuilder) (string, error) {\n\tif up.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif up.set == \"\" {\n\t\treturn \"\", errors.New(\"You need to set data in this update statement\")\n\t}\n\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.Grow(sulen1 + len(up.table))\n\tsb.WriteString(\"UPDATE `\")\n\tsb.WriteString(up.table)\n\tsb.WriteString(\"` SET \")\n\n\tset := processSet(up.set)\n\tsb.Grow(len(set) * 6)\n\tfor i, item := range set {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(\",`\")\n\t\t} else {\n\t\t\tsb.WriteString(\"`\")\n\t\t}\n\t\tsb.WriteString(item.Column)\n\t\tsb.WriteString(\"`=\")\n\t\tfor _, token := range item.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr:\n\t\t\t\tsb.WriteString(\" \")\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\tcase TokenColumn:\n\t\t\t\tsb.WriteString(\" `\")\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteString(\"`\")\n\t\t\tcase TokenString:\n\t\t\t\tsb.WriteString(\" '\")\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteString(\"'\")\n\t\t\t}\n\t\t}\n\t}\n\n\te := a.buildFlexiWhereSb(sb, up.where, up.dateCutoff)\n\tif e != nil {\n\t\treturn sb.String(), e\n\t}\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\tq := sb.String()\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(up.name, \"update\", q)\n\treturn q, nil\n}\n\nconst sdlen1 = len(\"DELETE FROM `` WHERE\")\n\nfunc (a *MysqlAdapter) SimpleDelete(name, table, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif where == \"\" {\n\t\treturn \"\", errors.New(\"You need to specify what data you want to delete\")\n\t}\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.Grow(sdlen1 + len(table))\n\tsb.WriteString(\"DELETE FROM `\")\n\tsb.WriteString(table)\n\tsb.WriteString(\"` WHERE\")\n\n\t// Add support for BETWEEN x.x\n\tfor i, loc := range processWhere(where) {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(\" AND\")\n\t\t}\n\t\tfor _, token := range loc.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot:\n\t\t\t\tsb.WriteRune(' ')\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\tcase TokenColumn:\n\t\t\t\tsb.WriteString(\" `\")\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteRune('`')\n\t\t\tcase TokenString:\n\t\t\t\tsb.WriteString(\" '\")\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\tdefault:\n\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t}\n\t\t}\n\t}\n\n\tq := strings.TrimSpace(sb.String())\n\tqueryStrPool.Put(sb)\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(name, \"delete\", q)\n\treturn q, nil\n}\n\nconst cdlen1 = len(\"DELETE FROM ``\")\n\nfunc (a *MysqlAdapter) ComplexDelete(b *deletePrebuilder) (string, error) {\n\tif b.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif b.where == \"\" && b.dateCutoff == nil {\n\t\treturn \"\", errors.New(\"You need to specify what data you want to delete\")\n\t}\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.Grow(cdlen1 + len(b.table))\n\tsb.WriteString(\"DELETE FROM `\")\n\tsb.WriteString(b.table)\n\tsb.WriteRune('`')\n\n\te := a.buildFlexiWhereSb(sb, b.where, b.dateCutoff)\n\tif e != nil {\n\t\treturn sb.String(), e\n\t}\n\tq := sb.String()\n\tqueryStrPool.Put(sb)\n\n\t// TODO: Shunt the table name logic and associated stmt list up to the a higher layer to reduce the amount of unnecessary overhead in the builder / accumulator\n\ta.pushStatement(b.name, \"delete\", q)\n\treturn q, nil\n}\n\n// We don't want to accidentally wipe tables, so we'll have a separate method for purging tables instead\nfunc (a *MysqlAdapter) Purge(name, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"DELETE FROM `\" + table + \"`\"\n\ta.pushStatement(name, \"purge\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) buildWhere(where string, sb *strings.Builder) error {\n\tif len(where) == 0 {\n\t\treturn nil\n\t}\n\tspl := processWhere(where)\n\tsb.Grow(len(spl) * 8)\n\tfor i, loc := range spl {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(\" AND \")\n\t\t} else {\n\t\t\tsb.WriteString(\" WHERE \")\n\t\t}\n\t\tfor _, token := range loc.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteRune(' ')\n\t\t\tcase TokenColumn:\n\t\t\t\tsb.WriteRune('`')\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteRune('`')\n\t\t\tcase TokenString:\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tsb.WriteRune('\\'')\n\t\t\tdefault:\n\t\t\t\treturn errors.New(\"This token doesn't exist o_o\")\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// The new version of buildWhere() currently only used in ComplexSelect for complex OO builder queries\nconst FlexiHint1 = len(` <UTC_TIMESTAMP()-interval ?  `)\n\nfunc (a *MysqlAdapter) buildFlexiWhere(where string, dateCutoff *dateCutoff) (q string, err error) {\n\tif len(where) == 0 && dateCutoff == nil {\n\t\treturn \"\", nil\n\t}\n\n\tvar sb strings.Builder\n\tsb.WriteString(\" WHERE\")\n\tif dateCutoff != nil {\n\t\tsb.Grow(6 + FlexiHint1)\n\t\tsb.WriteRune(' ')\n\t\tsb.WriteString(dateCutoff.Column)\n\t\tswitch dateCutoff.Type {\n\t\tcase 0:\n\t\t\tsb.WriteString(\" BETWEEN (UTC_TIMESTAMP()-interval \")\n\t\t\tsb.WriteString(strconv.Itoa(dateCutoff.Quantity))\n\t\t\tsb.WriteString(\" \")\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\t\tsb.WriteString(\") AND UTC_TIMESTAMP()\")\n\t\tcase 11:\n\t\t\tsb.WriteString(\"<UTC_TIMESTAMP()-interval ? \")\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\tdefault:\n\t\t\tsb.WriteString(\"<UTC_TIMESTAMP()-interval \")\n\t\t\tsb.WriteString(strconv.Itoa(dateCutoff.Quantity))\n\t\t\tsb.WriteRune(' ')\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\t}\n\t}\n\tif dateCutoff != nil && len(where) != 0 {\n\t\tsb.WriteString(\" AND\")\n\t}\n\n\tif len(where) != 0 {\n\t\twh := processWhere(where)\n\t\tsb.Grow((len(wh) * 8) - 5)\n\t\tfor i, loc := range wh {\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteString(\" AND \")\n\t\t\t}\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\tsb.WriteString(\" \")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tsb.WriteString(\" `\")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\t\tsb.WriteString(\"`\")\n\t\t\t\tcase TokenString:\n\t\t\t\t\tsb.WriteString(\" '\")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\tdefault:\n\t\t\t\t\treturn sb.String(), errors.New(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn sb.String(), nil\n}\n\nfunc (a *MysqlAdapter) buildFlexiWhereSb(sb *strings.Builder, where string, dateCutoff *dateCutoff) (err error) {\n\tif len(where) == 0 && dateCutoff == nil {\n\t\treturn nil\n\t}\n\n\tsb.WriteString(\" WHERE\")\n\tif dateCutoff != nil {\n\t\tsb.Grow(6 + FlexiHint1)\n\t\tsb.WriteRune(' ')\n\t\tsb.WriteString(dateCutoff.Column)\n\t\tswitch dateCutoff.Type {\n\t\tcase 0:\n\t\t\tsb.WriteString(\" BETWEEN (UTC_TIMESTAMP()-interval \")\n\t\t\tsb.WriteString(strconv.Itoa(dateCutoff.Quantity))\n\t\t\tsb.WriteString(\" \")\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\t\tsb.WriteString(\") AND UTC_TIMESTAMP()\")\n\t\tcase 11:\n\t\t\tsb.WriteString(\"<UTC_TIMESTAMP()-interval ? \")\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\tdefault:\n\t\t\tsb.WriteString(\"<UTC_TIMESTAMP()-interval \")\n\t\t\tsb.WriteString(strconv.Itoa(dateCutoff.Quantity))\n\t\t\tsb.WriteRune(' ')\n\t\t\tsb.WriteString(dateCutoff.Unit)\n\t\t}\n\t}\n\tif dateCutoff != nil && len(where) != 0 {\n\t\tsb.WriteString(\" AND\")\n\t}\n\n\tif len(where) != 0 {\n\t\twh := processWhere(where)\n\t\tsb.Grow((len(wh) * 8) - 5)\n\t\tfor i, loc := range wh {\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteString(\" AND \")\n\t\t\t}\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\tsb.WriteString(\" \")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tsb.WriteString(\" `\")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\t\tsb.WriteString(\"`\")\n\t\t\t\tcase TokenString:\n\t\t\t\t\tsb.WriteString(\" '\")\n\t\t\t\t\tsb.WriteString(token.Contents)\n\t\t\t\t\tsb.WriteString(\"'\")\n\t\t\t\tdefault:\n\t\t\t\t\treturn errors.New(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (a *MysqlAdapter) buildOrderby(orderby string) (q string) {\n\tif len(orderby) != 0 {\n\t\tvar sb strings.Builder\n\t\tord := processOrderby(orderby)\n\t\tsb.Grow(10 + (len(ord) * 8) - 1)\n\t\tsb.WriteString(\" ORDER BY \")\n\t\tfor i, col := range ord {\n\t\t\t// TODO: We might want to escape this column\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteString(\",`\")\n\t\t\t} else {\n\t\t\t\tsb.WriteString(\"`\")\n\t\t\t}\n\t\t\tsb.WriteString(strings.Replace(col.Column, \".\", \"`.`\", -1))\n\t\t\tsb.WriteString(\"` \")\n\t\t\tsb.WriteString(strings.ToUpper(col.Order))\n\t\t}\n\t\tq = sb.String()\n\t}\n\treturn q\n}\n\nfunc (a *MysqlAdapter) buildOrderbySb(sb *strings.Builder, orderby string) {\n\tif len(orderby) != 0 {\n\t\tord := processOrderby(orderby)\n\t\tsb.Grow(10 + (len(ord) * 8) - 1)\n\t\tsb.WriteString(\" ORDER BY \")\n\t\tfor i, col := range ord {\n\t\t\t// TODO: We might want to escape this column\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteString(\",`\")\n\t\t\t} else {\n\t\t\t\tsb.WriteString(\"`\")\n\t\t\t}\n\t\t\tsb.WriteString(strings.Replace(col.Column, \".\", \"`.`\", -1))\n\t\t\tsb.WriteString(\"` \")\n\t\t\tsb.WriteString(strings.ToUpper(col.Order))\n\t\t}\n\t}\n}\n\nfunc (a *MysqlAdapter) SimpleSelect(name, table, cols, where, orderby, limit string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(cols) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleSelect\")\n\t}\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.WriteString(\"SELECT \")\n\n\t// Slice up the user friendly strings into something easier to process\n\tfor i, col := range strings.Split(strings.TrimSpace(cols), \",\") {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(\"`,`\")\n\t\t} else {\n\t\t\tsb.WriteRune('`')\n\t\t}\n\t\tsb.WriteString(strings.TrimSpace(col))\n\t}\n\n\tsb.WriteString(\"`FROM`\")\n\tsb.WriteString(table)\n\tsb.WriteRune('`')\n\terr := a.buildWhere(where, sb)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\ta.buildOrderbySb(sb, orderby)\n\ta.buildLimitSb(sb, limit)\n\n\tq := strings.TrimSpace(sb.String())\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) ComplexSelect(preBuilder *selectPrebuilder) (out string, e error) {\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\te = a.complexSelect(preBuilder, sb)\n\tout = sb.String()\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(preBuilder.name, \"select\", out)\n\treturn out, e\n}\n\nconst cslen1 = len(\"SELECT  FROM ``\")\nconst cslen2 = len(\"WHERE``IN(\")\n\nfunc (a *MysqlAdapter) complexSelect(preBuilder *selectPrebuilder, sb *strings.Builder) error {\n\tif preBuilder.table == \"\" {\n\t\treturn errors.New(\"You need a name for this table\")\n\t}\n\tif len(preBuilder.columns) == 0 {\n\t\treturn errors.New(\"No columns found for ComplexSelect\")\n\t}\n\n\tcols := a.buildJoinColumns(preBuilder.columns)\n\tsb.Grow(cslen1 + len(cols) + len(preBuilder.table))\n\tsb.WriteString(\"SELECT \")\n\tsb.WriteString(cols)\n\tsb.WriteString(\" FROM `\")\n\tsb.WriteString(preBuilder.table)\n\tsb.WriteRune('`')\n\n\t// TODO: Let callers have a Where() and a InQ()\n\tif preBuilder.inChain != nil {\n\t\tsb.Grow(cslen2 + len(preBuilder.inColumn))\n\t\tsb.WriteString(\"WHERE`\")\n\t\tsb.WriteString(preBuilder.inColumn)\n\t\tsb.WriteString(\"`IN(\")\n\t\te := a.complexSelect(preBuilder.inChain, sb)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tsb.WriteRune(')')\n\t} else {\n\t\te := a.buildFlexiWhereSb(sb, preBuilder.where, preBuilder.dateCutoff)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\n\ta.buildOrderbySb(sb, preBuilder.orderby)\n\ta.buildLimitSb(sb, preBuilder.limit)\n\treturn nil\n}\n\nfunc (a *MysqlAdapter) SimpleLeftJoin(name, table1, table2, columns, joiners, where, orderby, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleLeftJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleLeftJoin\")\n\t}\n\n\twhereStr, err := a.buildJoinWhere(where)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tthalf1 := strings.Split(strings.Replace(table1, \" as \", \" AS \", -1), \" AS \")\n\tvar as1 string\n\tif len(thalf1) == 2 {\n\t\tas1 = \" AS `\" + thalf1[1] + \"`\"\n\t}\n\tthalf2 := strings.Split(strings.Replace(table2, \" as \", \" AS \", -1), \" AS \")\n\tvar as2 string\n\tif len(thalf2) == 2 {\n\t\tas2 = \" AS `\" + thalf2[1] + \"`\"\n\t}\n\n\tq := \"SELECT\" + a.buildJoinColumns(columns) + \" FROM `\" + thalf1[0] + \"`\" + as1 + \" LEFT JOIN `\" + thalf2[0] + \"`\" + as2 + \" ON \" + a.buildJoiners(joiners) + whereStr + a.buildOrderby(orderby) + a.buildLimit(limit)\n\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleInnerJoin(name, table1, table2, columns, joiners, where, orderby, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInnerJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleInnerJoin\")\n\t}\n\n\twhereStr, err := a.buildJoinWhere(where)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tthalf1 := strings.Split(strings.Replace(table1, \" as \", \" AS \", -1), \" AS \")\n\tvar as1 string\n\tif len(thalf1) == 2 {\n\t\tas1 = \" AS `\" + thalf1[1] + \"`\"\n\t}\n\tthalf2 := strings.Split(strings.Replace(table2, \" as \", \" AS \", -1), \" AS \")\n\tvar as2 string\n\tif len(thalf2) == 2 {\n\t\tas2 = \" AS `\" + thalf2[1] + \"`\"\n\t}\n\n\tq := \"SELECT \" + a.buildJoinColumns(columns) + \" FROM `\" + thalf1[0] + \"`\" + as1 + \" INNER JOIN `\" + thalf2[0] + \"`\" + as2 + \" ON \" + a.buildJoiners(joiners) + whereStr + a.buildOrderby(orderby) + a.buildLimit(limit)\n\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleUpdateSelect(up *updatePrebuilder) (string, error) {\n\tsel := up.whereSubQuery\n\tsb := &strings.Builder{}\n\terr := a.buildWhere(sel.where, sb)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tvar setter string\n\tfor _, item := range processSet(up.set) {\n\t\tsetter += \"`\" + item.Column + \"`=\"\n\t\tfor _, token := range item.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr:\n\t\t\t\tsetter += token.Contents\n\t\t\tcase TokenColumn:\n\t\t\t\tsetter += \"`\" + token.Contents + \"`\"\n\t\t\tcase TokenString:\n\t\t\t\tsetter += \"'\" + token.Contents + \"'\"\n\t\t\t}\n\t\t}\n\t\tsetter += \",\"\n\t}\n\tsetter = setter[0 : len(setter)-1]\n\n\tq := \"UPDATE `\" + up.table + \"` SET \" + setter + \" WHERE (SELECT\" + a.buildJoinColumns(sel.columns) + \" FROM `\" + sel.table + \"`\" + sb.String() + a.buildOrderby(sel.orderby) + a.buildLimit(sel.limit) + \")\"\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(up.name, \"update\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleInsertSelect(name string, ins DBInsert, sel DBSelect) (string, error) {\n\tsb := &strings.Builder{}\n\te := a.buildWhere(sel.Where, sb)\n\tif e != nil {\n\t\treturn \"\", e\n\t}\n\n\tq := \"INSERT INTO `\" + ins.Table + \"`(\" + a.buildColumns(ins.Columns) + \") SELECT\" + a.buildJoinColumns(sel.Columns) + \" FROM `\" + sel.Table + \"`\" + sb.String() + a.buildOrderby(sel.Orderby) + a.buildLimit(sel.Limit)\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) SimpleInsertLeftJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\twhereStr, e := a.buildJoinWhere(sel.Where)\n\tif e != nil {\n\t\treturn \"\", e\n\t}\n\n\tq := \"INSERT INTO `\" + ins.Table + \"`(\" + a.buildColumns(ins.Columns) + \") SELECT\" + a.buildJoinColumns(sel.Columns) + \" FROM `\" + sel.Table1 + \"` LEFT JOIN `\" + sel.Table2 + \"` ON \" + a.buildJoiners(sel.Joiners) + whereStr + a.buildOrderby(sel.Orderby) + a.buildLimit(sel.Limit)\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\n// TODO: Make this more consistent with the other build* methods?\nfunc (a *MysqlAdapter) buildJoiners(joiners string) (q string) {\n\tvar qb strings.Builder\n\tfor i, j := range processJoiner(joiners) {\n\t\t//q += \"`\" + j.LeftTable + \"`.`\" + j.LeftColumn + \"` \" + j.Operator + \" `\" + j.RightTable + \"`.`\" + j.RightColumn + \"` AND \"\n\t\tif i != 0 {\n\t\t\tqb.WriteString(\"AND`\")\n\t\t} else {\n\t\t\tqb.WriteString(\"`\")\n\t\t}\n\t\tqb.WriteString(j.LeftTable)\n\t\tqb.WriteString(\"`.`\")\n\t\tqb.WriteString(j.LeftColumn)\n\t\tqb.WriteString(\"` \")\n\t\tqb.WriteString(j.Operator)\n\t\tqb.WriteString(\" `\")\n\t\tqb.WriteString(j.RightTable)\n\t\tqb.WriteString(\"`.`\")\n\t\tqb.WriteString(j.RightColumn)\n\t\tqb.WriteString(\"`\")\n\t}\n\treturn qb.String()\n}\n\n// Add support for BETWEEN x.x\nfunc (a *MysqlAdapter) buildJoinWhere(where string) (q string, e error) {\n\tif len(where) != 0 {\n\t\tvar qsb strings.Builder\n\t\tws := processWhere(where)\n\t\tqsb.Grow(6 + (len(ws) * 5))\n\t\tqsb.WriteString(\" WHERE\")\n\t\tfor i, loc := range ws {\n\t\t\tif i != 0 {\n\t\t\t\tqsb.WriteString(\" AND\")\n\t\t\t}\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc, TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\tqsb.WriteRune(' ')\n\t\t\t\t\tqsb.WriteString(token.Contents)\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tqsb.WriteString(\" `\")\n\t\t\t\t\thalves := strings.Split(token.Contents, \".\")\n\t\t\t\t\tif len(halves) == 2 {\n\t\t\t\t\t\tqsb.WriteString(halves[0])\n\t\t\t\t\t\tqsb.WriteString(\"`.`\")\n\t\t\t\t\t\tqsb.WriteString(halves[1])\n\t\t\t\t\t} else {\n\t\t\t\t\t\tqsb.WriteString(token.Contents)\n\t\t\t\t\t}\n\t\t\t\t\tqsb.WriteRune('`')\n\t\t\t\tcase TokenString:\n\t\t\t\t\tqsb.WriteString(\" '\")\n\t\t\t\t\tqsb.WriteString(token.Contents)\n\t\t\t\t\tqsb.WriteRune('\\'')\n\t\t\t\tdefault:\n\t\t\t\t\treturn qsb.String(), errors.New(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn qsb.String(), nil\n\t}\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) buildLimit(limit string) (q string) {\n\tif limit != \"\" {\n\t\tq = \" LIMIT \" + limit\n\t}\n\treturn q\n}\n\nfunc (a *MysqlAdapter) buildLimitSb(sb *strings.Builder, limit string) {\n\tif limit != \"\" {\n\t\tsb.WriteString(\" LIMIT \")\n\t\tsb.WriteString(limit)\n\t}\n}\n\nfunc (a *MysqlAdapter) buildJoinColumns(cols string) (q string) {\n\tfor _, col := range processColumns(cols) {\n\t\t// TODO: Move the stirng and number logic to processColumns?\n\t\t// TODO: Error if [0] doesn't exist\n\t\tfirstChar := col.Left[0]\n\t\tif firstChar == '\\'' {\n\t\t\tcol.Type = TokenString\n\t\t} else {\n\t\t\t_, e := strconv.Atoi(string(firstChar))\n\t\t\tif e == nil {\n\t\t\t\tcol.Type = TokenNumber\n\t\t\t}\n\t\t}\n\n\t\t// Escape the column names, just in case we've used a reserved keyword\n\t\tsource := col.Left\n\t\tif col.Table != \"\" {\n\t\t\tsource = \"`\" + col.Table + \"`.`\" + source + \"`\"\n\t\t} else if col.Type != TokenScope && col.Type != TokenFunc && col.Type != TokenNumber && col.Type != TokenSub && col.Type != TokenString {\n\t\t\tsource = \"`\" + source + \"`\"\n\t\t}\n\n\t\tvar alias string\n\t\tif col.Alias != \"\" {\n\t\t\talias = \" AS `\" + col.Alias + \"`\"\n\t\t}\n\t\tq += \" \" + source + alias + \",\"\n\t}\n\treturn q[0 : len(q)-1]\n}\n\nfunc (a *MysqlAdapter) SimpleInsertInnerJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\twhereStr, err := a.buildJoinWhere(sel.Where)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tq := \"INSERT INTO `\" + ins.Table + \"`(\" + a.buildColumns(ins.Columns) + \") SELECT\" + a.buildJoinColumns(sel.Columns) + \" FROM `\" + sel.Table1 + \"` INNER JOIN `\" + sel.Table2 + \"` ON \" + a.buildJoiners(sel.Joiners) + whereStr + a.buildOrderby(sel.Orderby) + a.buildLimit(sel.Limit)\n\tq = strings.TrimSpace(q)\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\nconst sclen1 = len(\"SELECT COUNT(*) FROM``\")\n\nfunc (a *MysqlAdapter) SimpleCount(name, table, where, limit string) (q string, e error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tvar sb *strings.Builder\n\tii := queryStrPool.Get()\n\tif ii == nil {\n\t\tsb = &strings.Builder{}\n\t} else {\n\t\tsb = ii.(*strings.Builder)\n\t\tsb.Reset()\n\t}\n\tsb.Grow(sclen1 + len(table))\n\tsb.WriteString(\"SELECT COUNT(*) FROM`\")\n\tsb.WriteString(table)\n\tsb.WriteRune('`')\n\tif e = a.buildWhere(where, sb); e != nil {\n\t\treturn \"\", e\n\t}\n\ta.buildLimitSb(sb, limit)\n\n\tq = strings.TrimSpace(sb.String())\n\tqueryStrPool.Put(sb)\n\ta.pushStatement(name, \"select\", q)\n\treturn q, nil\n}\n\nfunc (a *MysqlAdapter) Builder() *prebuilder {\n\treturn &prebuilder{a}\n}\n\nfunc (a *MysqlAdapter) Write() error {\n\tvar stmts, body string\n\tfor _, name := range a.BufferOrder {\n\t\tif name[0] == '_' {\n\t\t\tcontinue\n\t\t}\n\t\tstmt := a.Buffer[name]\n\t\t// ? - Table creation might be a little complex for Go to do outside a SQL file :(\n\t\tif stmt.Type == \"upsert\" {\n\t\t\tstmts += \"\\t\" + name + \" *qgen.MySQLUpsertCallback\\n\"\n\t\t\tbody += `\t\n\tdl(\"Preparing ` + name + ` statement.\")\n\tstmts.` + name + `, e = qgen.PrepareMySQLUpsertCallback(db,\"` + stmt.Contents + `\")\n\tif e != nil {\n\t\tl(\"Error in ` + name + ` statement.\")\n\t\treturn e\n\t}\n\t`\n\t\t} else if stmt.Type != \"create-table\" {\n\t\t\tstmts += \"\\t\" + name + \" *sql.Stmt\\n\"\n\t\t\tbody += `\t\n\tdl(\"Preparing ` + name + ` statement.\")\n\tstmts.` + name + `, e = db.Prepare(\"` + stmt.Contents + `\")\n\tif e != nil {\n\t\tl(\"Error in ` + name + ` statement.\")\n\t\treturn e\n\t}\n\t`\n\t\t}\n\t}\n\n\t// TODO: Move these custom queries out of this file\n\tout := `// +build !pgsql,!mssql\n\n/* This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time. */\n\npackage main\n\nimport(\n\t\"log\"\n\t\"database/sql\"\n\tc \"github.com/Azareal/Gosora/common\"\n\t//\"github.com/Azareal/Gosora/query_gen\"\n)\n\n// nolint\ntype Stmts struct {\n` + stmts + `\n\tgetActivityFeedByWatcher *sql.Stmt\n\t//getActivityFeedByWatcherAfter *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_mysql() (e error) {\n\tdl := c.DebugLog\n\tdl(\"Building the generated statements\")\n\tl := log.Print\n\t_ = l\n` + body + `\n\treturn nil\n}\n`\n\treturn writeFile(\"./gen_mysql.go\", out)\n}\n\n// Internal methods, not exposed in the interface\nfunc (a *MysqlAdapter) pushStatement(name, stype, q string) {\n\tif name == \"\" {\n\t\treturn\n\t}\n\ta.Buffer[name] = DBStmt{q, stype}\n\ta.BufferOrder = append(a.BufferOrder, name)\n}\n\nfunc (a *MysqlAdapter) stringyType(ct string) bool {\n\tct = strings.ToLower(ct)\n\treturn ct == \"varchar\" || ct == \"tinytext\" || ct == \"text\" || ct == \"mediumtext\" || ct == \"longtext\" || ct == \"char\" || ct == \"datetime\" || ct == \"timestamp\" || ct == \"time\" || ct == \"date\"\n}\n"
  },
  {
    "path": "query_gen/pgsql.go",
    "content": "/* WIP Under Really Heavy Construction */\npackage qgen\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nfunc init() {\n\tRegistry = append(Registry,\n\t\t&PgsqlAdapter{Name: \"pgsql\", Buffer: make(map[string]DBStmt)},\n\t)\n}\n\ntype PgsqlAdapter struct {\n\tName        string // ? - Do we really need this? Can't we hard-code this?\n\tBuffer      map[string]DBStmt\n\tBufferOrder []string // Map iteration order is random, so we need this to track the order, so we don't get huge diffs every commit\n}\n\n// GetName gives you the name of the database adapter. In this case, it's pgsql\nfunc (a *PgsqlAdapter) GetName() string {\n\treturn a.Name\n}\n\nfunc (a *PgsqlAdapter) GetStmt(name string) DBStmt {\n\treturn a.Buffer[name]\n}\n\nfunc (a *PgsqlAdapter) GetStmts() map[string]DBStmt {\n\treturn a.Buffer\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) BuildConn(config map[string]string) (*sql.DB, error) {\n\treturn nil, nil\n}\n\nfunc (a *PgsqlAdapter) DbVersion() string {\n\treturn \"SELECT version()\"\n}\n\nfunc (a *PgsqlAdapter) DropTable(name, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tq := \"DROP TABLE IF EXISTS \\\"\" + table + \"\\\";\"\n\ta.pushStatement(name, \"drop-table\", q)\n\treturn q, nil\n}\n\n// TODO: Implement this\n// We may need to change the CreateTable API to better suit PGSQL and the other database drivers which are coming up\nfunc (a *PgsqlAdapter) CreateTable(name, table, charset, collation string, cols []DBTableColumn, keys []DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(cols) == 0 {\n\t\treturn \"\", errors.New(\"You can't have a table with no columns\")\n\t}\n\n\tq := \"CREATE TABLE \\\"\" + table + \"\\\" (\"\n\tfor _, col := range cols {\n\t\tif col.AutoIncrement {\n\t\t\tcol.Type = \"serial\"\n\t\t} else if col.Type == \"createdAt\" {\n\t\t\tcol.Type = \"timestamp\"\n\t\t} else if col.Type == \"datetime\" {\n\t\t\tcol.Type = \"timestamp\"\n\t\t}\n\n\t\tvar size string\n\t\tif col.Size > 0 {\n\t\t\tsize = \" (\" + strconv.Itoa(col.Size) + \")\"\n\t\t}\n\n\t\tvar end string\n\t\tif col.Default != \"\" {\n\t\t\tend = \" DEFAULT \"\n\t\t\tif a.stringyType(col.Type) && col.Default != \"''\" {\n\t\t\t\tend += \"'\" + col.Default + \"'\"\n\t\t\t} else {\n\t\t\t\tend += col.Default\n\t\t\t}\n\t\t}\n\t\tif !col.Null {\n\t\t\tend += \" not null\"\n\t\t}\n\n\t\tq += \"\\n\\t`\" + col.Name + \"` \" + col.Type + size + end + \",\"\n\t}\n\n\tif len(keys) > 0 {\n\t\tfor _, key := range keys {\n\t\t\tq += \"\\n\\t\" + key.Type\n\t\t\tif key.Type != \"unique\" {\n\t\t\t\tq += \" key\"\n\t\t\t}\n\t\t\tq += \"(\"\n\t\t\tfor _, column := range strings.Split(key.Columns, \",\") {\n\t\t\t\tq += \"`\" + column + \"`,\"\n\t\t\t}\n\t\t\tq = q[0:len(q)-1] + \"),\"\n\t\t}\n\t}\n\n\tq = q[0:len(q)-1] + \"\\n);\"\n\ta.pushStatement(name, \"create-table\", q)\n\treturn q, nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) AddColumn(name, table string, column DBTableColumn, key *DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) DropColumn(name, table, colName string) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) RenameColumn(name, table, oldName, newName string) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) ChangeColumn(name, table, colName string, col DBTableColumn) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SetDefaultColumn(name, table, colName, colType, defaultStr string) (string, error) {\n\tif colType == \"text\" {\n\t\treturn \"\", errors.New(\"text fields cannot have default values\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *PgsqlAdapter) AddIndex(name, table, iname, colname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\tif colname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the column\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *PgsqlAdapter) AddKey(name, table, column string, key DBTableKey) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif column == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the column\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *PgsqlAdapter) RemoveIndex(name, table, iname string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif iname == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the index\")\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\n// TODO: Test to make sure everything works here\nfunc (a *PgsqlAdapter) AddForeignKey(name, table, column, ftable, fcolumn string, cascade bool) (out string, e error) {\n\tvar c = func(str string, val bool) {\n\t\tif e != nil || !val {\n\t\t\treturn\n\t\t}\n\t\te = errors.New(\"You need a \" + str + \" for this table\")\n\t}\n\tc(\"name\", table == \"\")\n\tc(\"column\", column == \"\")\n\tc(\"ftable\", ftable == \"\")\n\tc(\"fcolumn\", fcolumn == \"\")\n\tif e != nil {\n\t\treturn \"\", e\n\t}\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Test this\n// ! We need to get the last ID out of this somehow, maybe add returning to every query? Might require some sort of wrapper over the sql statements\nfunc (a *PgsqlAdapter) SimpleInsert(name, table, columns, fields string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\n\tq := \"INSERT INTO \\\"\" + table + \"\\\"(\"\n\tif columns != \"\" {\n\t\tq += a.buildColumns(columns) + \") VALUES (\"\n\t\tfor _, field := range processFields(fields) {\n\t\t\tnameLen := len(field.Name)\n\t\t\tif field.Name[0] == '\"' && field.Name[nameLen-1] == '\"' && nameLen >= 3 {\n\t\t\t\tfield.Name = \"'\" + field.Name[1:nameLen-1] + \"'\"\n\t\t\t}\n\t\t\tif field.Name[0] == '\\'' && field.Name[nameLen-1] == '\\'' && nameLen >= 3 {\n\t\t\t\tfield.Name = \"'\" + strings.Replace(field.Name[1:nameLen-1], \"'\", \"''\", -1) + \"'\"\n\t\t\t}\n\t\t\tq += field.Name + \",\"\n\t\t}\n\t\tq = q[0 : len(q)-1]\n\t} else {\n\t\tq += \") VALUES (\"\n\t}\n\tq += \")\"\n\n\ta.pushStatement(name, \"insert\", q)\n\treturn q, nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleBulkInsert(name, table, columns string, fieldSet []string) (string, error) {\n\treturn \"\", nil\n}\n\nfunc (a *PgsqlAdapter) buildColumns(cols string) (q string) {\n\tif cols == \"\" {\n\t\treturn \"\"\n\t}\n\t// Escape the column names, just in case we've used a reserved keyword\n\tfor _, col := range processColumns(cols) {\n\t\tif col.Type == TokenFunc {\n\t\t\tq += col.Left + \",\"\n\t\t} else {\n\t\t\tq += \"\\\"\" + col.Left + \"\\\",\"\n\t\t}\n\t}\n\treturn q[0 : len(q)-1]\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleReplace(name, table, columns, fields string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInsert\")\n\t}\n\tif len(fields) == 0 {\n\t\treturn \"\", errors.New(\"No input data found for SimpleInsert\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleUpsert(name, table, columns, fields, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInsert\")\n\t}\n\tif len(fields) == 0 {\n\t\treturn \"\", errors.New(\"No input data found for SimpleInsert\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implemented, but we need CreateTable and a better installer to *test* it\nfunc (a *PgsqlAdapter) SimpleUpdate(up *updatePrebuilder) (string, error) {\n\tif up.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif up.set == \"\" {\n\t\treturn \"\", errors.New(\"You need to set data in this update statement\")\n\t}\n\n\tq := \"UPDATE \\\"\" + up.table + \"\\\" SET \"\n\tfor _, item := range processSet(up.set) {\n\t\tq += \"`\" + item.Column + \"`=\"\n\t\tfor _, token := range item.Expr {\n\t\t\tswitch token.Type {\n\t\t\tcase TokenFunc:\n\t\t\t\t// TODO: Write a more sophisticated function parser on the utils side.\n\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\ttoken.Contents = \"LOCALTIMESTAMP()\"\n\t\t\t\t}\n\t\t\t\tq += \" \" + token.Contents\n\t\t\tcase TokenOp, TokenNumber, TokenSub, TokenOr:\n\t\t\t\tq += \" \" + token.Contents\n\t\t\tcase TokenColumn:\n\t\t\t\tq += \" `\" + token.Contents + \"`\"\n\t\t\tcase TokenString:\n\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t}\n\t\t}\n\t\tq += \",\"\n\t}\n\tq = q[0 : len(q)-1]\n\n\t// Add support for BETWEEN x.x\n\tif len(up.where) != 0 {\n\t\tq += \" WHERE\"\n\t\tfor _, loc := range processWhere(up.where) {\n\t\t\tfor _, token := range loc.Expr {\n\t\t\t\tswitch token.Type {\n\t\t\t\tcase TokenFunc:\n\t\t\t\t\t// TODO: Write a more sophisticated function parser on the utils side. What's the situation in regards to case sensitivity?\n\t\t\t\t\tif strings.ToUpper(token.Contents) == \"UTC_TIMESTAMP()\" {\n\t\t\t\t\t\ttoken.Contents = \"LOCALTIMESTAMP()\"\n\t\t\t\t\t}\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenOp, TokenNumber, TokenSub, TokenOr, TokenNot, TokenLike:\n\t\t\t\t\tq += \" \" + token.Contents\n\t\t\t\tcase TokenColumn:\n\t\t\t\t\tq += \" `\" + token.Contents + \"`\"\n\t\t\t\tcase TokenString:\n\t\t\t\t\tq += \" '\" + token.Contents + \"'\"\n\t\t\t\tdefault:\n\t\t\t\t\tpanic(\"This token doesn't exist o_o\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tq += \" AND\"\n\t\t}\n\t\tq = q[0 : len(q)-4]\n\t}\n\n\ta.pushStatement(up.name, \"update\", q)\n\treturn q, nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleUpdateSelect(up *updatePrebuilder) (string, error) {\n\treturn \"\", errors.New(\"not implemented\")\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleDelete(name, table, where string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif where == \"\" {\n\t\treturn \"\", errors.New(\"You need to specify what data you want to delete\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) ComplexDelete(b *deletePrebuilder) (string, error) {\n\tif b.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif b.where == \"\" {\n\t\treturn \"\", errors.New(\"You need to specify what data you want to delete\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\n// We don't want to accidentally wipe tables, so we'll have a separate method for purging tables instead\nfunc (a *PgsqlAdapter) Purge(name, table string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleSelect(name, table, columns, where, orderby, limit string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleSelect\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) ComplexSelect(prebuilder *selectPrebuilder) (string, error) {\n\tif prebuilder.table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\tif len(prebuilder.columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for ComplexSelect\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleLeftJoin(name, table1, table2, columns, joiners, where, orderby, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleLeftJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleLeftJoin\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleInnerJoin(name, table1, table2, columns, joiners, where, orderby, limit string) (string, error) {\n\tif table1 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the left table\")\n\t}\n\tif table2 == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for the right table\")\n\t}\n\tif len(columns) == 0 {\n\t\treturn \"\", errors.New(\"No columns found for SimpleInnerJoin\")\n\t}\n\tif len(joiners) == 0 {\n\t\treturn \"\", errors.New(\"No joiners found for SimpleInnerJoin\")\n\t}\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleInsertSelect(name string, ins DBInsert, sel DBSelect) (string, error) {\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleInsertLeftJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleInsertInnerJoin(name string, ins DBInsert, sel DBJoin) (string, error) {\n\treturn \"\", nil\n}\n\n// TODO: Implement this\nfunc (a *PgsqlAdapter) SimpleCount(name, table, where, limit string) (string, error) {\n\tif table == \"\" {\n\t\treturn \"\", errors.New(\"You need a name for this table\")\n\t}\n\treturn \"\", nil\n}\n\nfunc (a *PgsqlAdapter) Builder() *prebuilder {\n\treturn &prebuilder{a}\n}\n\nfunc (a *PgsqlAdapter) Write() error {\n\tvar stmts, body string\n\tfor _, name := range a.BufferOrder {\n\t\tif name[0] == '_' {\n\t\t\tcontinue\n\t\t}\n\t\tstmt := a.Buffer[name]\n\t\t// TODO: Add support for create-table? Table creation might be a little complex for Go to do outside a SQL file :(\n\t\tif stmt.Type != \"create-table\" {\n\t\t\tstmts += \"\\t\" + name + \" *sql.Stmt\\n\"\n\t\t\tbody += `\t\n\tcommon.DebugLog(\"Preparing ` + name + ` statement.\")\n\tstmts.` + name + `, err = db.Prepare(\"` + strings.Replace(stmt.Contents, \"\\\"\", \"\\\\\\\"\", -1) + `\")\n\tif err != nil {\n\t\tlog.Print(\"Error in ` + name + ` statement.\")\n\t\treturn err\n\t}\n\t`\n\t\t}\n\t}\n\n\t// TODO: Move these custom queries out of this file\n\tout := `// +build pgsql\n\n// This file was generated by Gosora's Query Generator. Please try to avoid modifying this file, as it might change at any time.\npackage main\n\nimport \"log\"\nimport \"database/sql\"\nimport \"github.com/Azareal/Gosora/common\"\n\n// nolint\ntype Stmts struct {\n` + stmts + `\n\tgetActivityFeedByWatcher *sql.Stmt\n\tgetActivityCountByWatcher *sql.Stmt\n\n\tMocks bool\n}\n\n// nolint\nfunc _gen_pgsql() (err error) {\n\tcommon.DebugLog(\"Building the generated statements\")\n` + body + `\n\treturn nil\n}\n`\n\treturn writeFile(\"./gen_pgsql.go\", out)\n}\n\n// Internal methods, not exposed in the interface\nfunc (a *PgsqlAdapter) pushStatement(name, stype, q string) {\n\tif name == \"\" {\n\t\treturn\n\t}\n\ta.Buffer[name] = DBStmt{q, stype}\n\ta.BufferOrder = append(a.BufferOrder, name)\n}\n\nfunc (a *PgsqlAdapter) stringyType(ctype string) bool {\n\tctype = strings.ToLower(ctype)\n\treturn ctype == \"char\" || ctype == \"varchar\" || ctype == \"timestamp\" || ctype == \"text\"\n}\n"
  },
  {
    "path": "query_gen/querygen.go",
    "content": "/* WIP Under Construction */\npackage qgen // import \"github.com/Azareal/Gosora/query_gen\"\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n)\n\nvar Registry []Adapter\nvar ErrNoAdapter = errors.New(\"This adapter doesn't exist\")\nvar queryStrPool = sync.Pool{}\n\ntype DBTableColumn struct {\n\tName          string\n\tType          string\n\tSize          int\n\tNull          bool\n\tAutoIncrement bool\n\tDefault       string\n}\n\ntype DBTableKey struct {\n\tColumns string\n\tType    string\n\n\t// Foreign keys only\n\tFTable  string\n\tCascade bool\n}\n\ntype DBSelect struct {\n\tTable   string\n\tColumns string\n\tWhere   string\n\tOrderby string\n\tLimit   string\n}\n\ntype DBJoin struct {\n\tTable1  string\n\tTable2  string\n\tColumns string\n\tJoiners string\n\tWhere   string\n\tOrderby string\n\tLimit   string\n}\n\ntype DBInsert struct {\n\tTable   string\n\tColumns string\n\tFields  string\n}\n\ntype DBColumn struct {\n\tTable string\n\tLeft  string // Could be a function or a column, so I'm naming this Left\n\tAlias string // aka AS Blah, if it's present\n\t//Type  string // function or column\n\tType int\n}\n\nconst (\n\tIdenFunc = iota\n\tIdenColumn\n\tIdenString\n\tIdenLiteral\n)\n\ntype DBField struct {\n\tName string\n\t//Type string\n\tType int\n}\n\ntype DBWhere struct {\n\tExpr []DBToken // Simple expressions, the innards of functions are opaque for now.\n}\n\ntype DBJoiner struct {\n\tLeftTable   string\n\tLeftColumn  string\n\tRightTable  string\n\tRightColumn string\n\tOperator    string\n}\n\ntype DBOrder struct {\n\tColumn string\n\tOrder  string\n}\n\nconst (\n\tTokenFunc = iota\n\tTokenOp\n\tTokenColumn\n\tTokenNumber\n\tTokenString\n\tTokenSub\n\tTokenOr\n\tTokenNot\n\tTokenLike\n\tTokenScope\n)\n\ntype DBToken struct {\n\tContents string\n\t//Type     string // function, op, column, number, string, sub, not, like\n\tType int\n}\n\ntype DBSetter struct {\n\tColumn string\n\tExpr   []DBToken // Simple expressions, the innards of functions are opaque for now.\n}\n\ntype DBLimit struct {\n\tOffset   string // ? or int\n\tMaxCount string // ? or int\n}\n\ntype DBStmt struct {\n\tContents string\n\tType     string // create-table, insert, update, delete\n}\n\n// TODO: Add the TableExists and ColumnExists methods\ntype Adapter interface {\n\tGetName() string\n\tBuildConn(config map[string]string) (*sql.DB, error)\n\tDbVersion() string\n\n\tDropTable(name, table string) (string, error)\n\tCreateTable(name, table, charset, collation string, cols []DBTableColumn, keys []DBTableKey) (string, error)\n\t// TODO: Some way to add indices and keys\n\t// TODO: Test this\n\tAddColumn(name, table string, col DBTableColumn, key *DBTableKey) (string, error)\n\tDropColumn(name, table, colName string) (string, error)\n\tRenameColumn(name, table, oldName, newName string) (string, error)\n\tChangeColumn(name, table, colName string, col DBTableColumn) (string, error)\n\tSetDefaultColumn(name, table, colName, colType, defaultStr string) (string, error)\n\tAddIndex(name, table, iname, colname string) (string, error)\n\tAddKey(name, table, col string, key DBTableKey) (string, error)\n\tRemoveIndex(name, table, col string) (string, error)\n\tAddForeignKey(name, table, col, ftable, fcol string, cascade bool) (out string, e error)\n\tSimpleInsert(name, table, cols, fields string) (string, error)\n\tSimpleBulkInsert(name, table, cols string, fieldSet []string) (string, error)\n\tSimpleUpdate(b *updatePrebuilder) (string, error)\n\tSimpleUpdateSelect(b *updatePrebuilder) (string, error) // ! Experimental\n\tSimpleDelete(name, table, where string) (string, error)\n\tPurge(name, table string) (string, error)\n\tSimpleSelect(name, table, cols, where, orderby, limit string) (string, error)\n\tComplexDelete(b *deletePrebuilder) (string, error)\n\tSimpleLeftJoin(name, table1, table2, cols, joiners, where, orderby, limit string) (string, error)\n\tSimpleInnerJoin(string, string, string, string, string, string, string, string) (string, error)\n\tSimpleInsertSelect(string, DBInsert, DBSelect) (string, error)\n\tSimpleInsertLeftJoin(string, DBInsert, DBJoin) (string, error)\n\tSimpleInsertInnerJoin(string, DBInsert, DBJoin) (string, error)\n\tSimpleCount(string, string, string, string) (string, error)\n\n\tComplexSelect(*selectPrebuilder) (string, error)\n\n\tBuilder() *prebuilder\n\tWrite() error\n}\n\nfunc GetAdapter(name string) (adap Adapter, err error) {\n\tfor _, adapter := range Registry {\n\t\tif adapter.GetName() == name {\n\t\t\treturn adapter, nil\n\t\t}\n\t}\n\treturn adap, ErrNoAdapter\n}\n\ntype QueryPlugin interface {\n\tHook(name string, args ...interface{}) error\n\tWrite() error\n}\n\ntype MySQLUpsertCallback struct {\n\tstmt *sql.Stmt\n}\n\nfunc (double *MySQLUpsertCallback) Exec(args ...interface{}) (res sql.Result, err error) {\n\tif len(args) < 2 {\n\t\treturn res, errors.New(\"Need two or more arguments\")\n\t}\n\targs = args[:len(args)-1]\n\treturn double.stmt.Exec(append(args, args...)...)\n}\n\nfunc PrepareMySQLUpsertCallback(db *sql.DB, query string) (*MySQLUpsertCallback, error) {\n\tstmt, err := db.Prepare(query)\n\treturn &MySQLUpsertCallback{stmt}, err\n}\n\ntype LitStr string\n\n// TODO: Test this\nfunc InterfaceMapToInsertStrings(data map[string]interface{}, order string) (cols, values string) {\n\tdone := make(map[string]bool)\n\taddValue := func(value interface{}) {\n\t\tswitch value := value.(type) {\n\t\tcase string:\n\t\t\tvalues += \"'\" + strings.Replace(value, \"'\", \"\\\\'\", -1) + \"',\"\n\t\tcase int:\n\t\t\tvalues += strconv.Itoa(value) + \",\"\n\t\tcase LitStr:\n\t\t\tvalues += string(value) + \",\"\n\t\tcase bool:\n\t\t\tif value {\n\t\t\t\tvalues += \"1,\"\n\t\t\t} else {\n\t\t\t\tvalues += \"0,\"\n\t\t\t}\n\t\t}\n\t}\n\n\t// Add the ordered items\n\tfor _, col := range strings.Split(order, \",\") {\n\t\tcol = strings.TrimSpace(col)\n\t\tvalue, ok := data[col]\n\t\tif ok {\n\t\t\tcols += col + \",\"\n\t\t\taddValue(value)\n\t\t\tdone[col] = true\n\t\t}\n\t}\n\n\t// Go over any unordered items and add them at the end\n\tif len(data) > len(done) {\n\t\tfor col, value := range data {\n\t\t\t_, ok := done[col]\n\t\t\tif ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcols += col + \",\"\n\t\t\taddValue(value)\n\t\t}\n\t}\n\n\tif cols != \"\" {\n\t\tcols = cols[:len(cols)-1]\n\t\tvalues = values[:len(values)-1]\n\t}\n\treturn cols, values\n}\n"
  },
  {
    "path": "query_gen/transaction.go",
    "content": "package qgen\n\nimport \"database/sql\"\n\ntype transactionStmt struct {\n\tstmt     *sql.Stmt\n\tfirstErr error // This'll let us chain the methods to reduce boilerplate\n}\n\nfunc newTransactionStmt(stmt *sql.Stmt, err error) *transactionStmt {\n\treturn &transactionStmt{stmt, err}\n}\n\nfunc (stmt *transactionStmt) Exec(args ...interface{}) (*sql.Result, error) {\n\tif stmt.firstErr != nil {\n\t\treturn nil, stmt.firstErr\n\t}\n\treturn stmt.Exec(args...)\n}\n\ntype TransactionBuilder struct {\n\ttx         *sql.Tx\n\tadapter    Adapter\n\ttextToStmt map[string]*transactionStmt\n}\n\nfunc (b *TransactionBuilder) SimpleDelete(table string, where string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleDelete(\"\", table, where)\n\tif err != nil {\n\t\treturn stmt, err\n\t}\n\treturn b.tx.Prepare(res)\n}\n\n// Quick* versions refer to it being quick to type not the performance. For performance critical transactions, you might want to use the Simple* methods or the *Tx methods on the main builder. Alternate suggestions for names are welcome :)\nfunc (b *TransactionBuilder) QuickDelete(table string, where string) *transactionStmt {\n\tres, err := b.adapter.SimpleDelete(\"\", table, where)\n\tif err != nil {\n\t\treturn newTransactionStmt(nil, err)\n\t}\n\n\tstmt, ok := b.textToStmt[res]\n\tif ok {\n\t\treturn stmt\n\t}\n\tstmt = newTransactionStmt(b.tx.Prepare(res))\n\tb.textToStmt[res] = stmt\n\treturn stmt\n}\n\nfunc (b *TransactionBuilder) SimpleInsert(table string, columns string, fields string) (stmt *sql.Stmt, err error) {\n\tres, err := b.adapter.SimpleInsert(\"\", table, columns, fields)\n\tif err != nil {\n\t\treturn stmt, err\n\t}\n\treturn b.tx.Prepare(res)\n}\n\nfunc (b *TransactionBuilder) QuickInsert(table string, where string) *transactionStmt {\n\tres, err := b.adapter.SimpleDelete(\"\", table, where)\n\tif err != nil {\n\t\treturn newTransactionStmt(nil, err)\n\t}\n\n\tstmt, ok := b.textToStmt[res]\n\tif ok {\n\t\treturn stmt\n\t}\n\tstmt = newTransactionStmt(b.tx.Prepare(res))\n\tb.textToStmt[res] = stmt\n\treturn stmt\n}\n"
  },
  {
    "path": "query_gen/utils.go",
    "content": "/*\n*\n*\tQuery Generator Library\n*\tWIP Under Construction\n*\tCopyright Azareal 2017 - 2020\n*\n */\npackage qgen\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t//\"fmt\"\n)\n\n// TODO: Add support for numbers and strings?\nfunc processColumns(colStr string) (columns []DBColumn) {\n\tif colStr == \"\" {\n\t\treturn columns\n\t}\n\tcolStr = strings.Replace(colStr, \" as \", \" AS \", -1)\n\tfor _, segment := range strings.Split(colStr, \",\") {\n\t\tvar outCol DBColumn\n\t\tdotHalves := strings.Split(strings.TrimSpace(segment), \".\")\n\n\t\tvar halves []string\n\t\tif len(dotHalves) == 2 {\n\t\t\toutCol.Table = dotHalves[0]\n\t\t\thalves = strings.Split(dotHalves[1], \" AS \")\n\t\t} else {\n\t\t\thalves = strings.Split(dotHalves[0], \" AS \")\n\t\t}\n\n\t\thalves[0] = strings.TrimSpace(halves[0])\n\t\tif len(halves) == 2 {\n\t\t\toutCol.Alias = strings.TrimSpace(halves[1])\n\t\t}\n\t\t//fmt.Printf(\"halves: %+v\\n\", halves)\n\t\t//fmt.Printf(\"halves[0]: %+v\\n\", halves[0])\n\t\tswitch {\n\t\tcase halves[0][0] == '(':\n\t\t\toutCol.Type = TokenScope\n\t\t\toutCol.Table = \"\"\n\t\tcase halves[0][len(halves[0])-1] == ')':\n\t\t\toutCol.Type = TokenFunc\n\t\tcase halves[0] == \"?\":\n\t\t\toutCol.Type = TokenSub\n\t\tdefault:\n\t\t\toutCol.Type = TokenColumn\n\t\t}\n\n\t\toutCol.Left = halves[0]\n\t\tcolumns = append(columns, outCol)\n\t}\n\treturn columns\n}\n\n// TODO: Allow order by statements without a direction\nfunc processOrderby(orderStr string) (order []DBOrder) {\n\tif orderStr == \"\" {\n\t\treturn order\n\t}\n\tfor _, segment := range strings.Split(orderStr, \",\") {\n\t\tvar outOrder DBOrder\n\t\thalves := strings.Split(strings.TrimSpace(segment), \" \")\n\t\tif len(halves) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\toutOrder.Column = halves[0]\n\t\toutOrder.Order = strings.ToLower(halves[1])\n\t\torder = append(order, outOrder)\n\t}\n\treturn order\n}\n\nfunc processJoiner(joinStr string) (joiner []DBJoiner) {\n\tif joinStr == \"\" {\n\t\treturn joiner\n\t}\n\tjoinStr = strings.Replace(joinStr, \" on \", \" ON \", -1)\n\tjoinStr = strings.Replace(joinStr, \" and \", \" AND \", -1)\n\tfor _, segment := range strings.Split(joinStr, \" AND \") {\n\t\tvar outJoin DBJoiner\n\t\tvar parseOffset int\n\t\tvar left, right string\n\n\t\tleft, parseOffset = getIdentifier(segment, parseOffset)\n\t\toutJoin.Operator, parseOffset = getOperator(segment, parseOffset+1)\n\t\tright, parseOffset = getIdentifier(segment, parseOffset+1)\n\n\t\tleftColumn := strings.Split(left, \".\")\n\t\trightColumn := strings.Split(right, \".\")\n\t\toutJoin.LeftTable = strings.TrimSpace(leftColumn[0])\n\t\toutJoin.RightTable = strings.TrimSpace(rightColumn[0])\n\t\toutJoin.LeftColumn = strings.TrimSpace(leftColumn[1])\n\t\toutJoin.RightColumn = strings.TrimSpace(rightColumn[1])\n\n\t\tjoiner = append(joiner, outJoin)\n\t}\n\treturn joiner\n}\n\nfunc (wh *DBWhere) parseNumber(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif '0' <= ch && ch <= '9' {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\ti--\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\twh.Expr = append(wh.Expr, DBToken{str, TokenNumber})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (wh *DBWhere) parseColumn(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tswitch {\n\t\tcase ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ch == '.' || ch == '_':\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\tcase ch == '(':\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\treturn wh.parseFunction(seg, str, i)\n\t\tdefault:\n\t\t\ti--\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\twh.Expr = append(wh.Expr, DBToken{str, TokenColumn})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (wh *DBWhere) parseFunction(seg, buffer string, i int) int {\n\tpreI := i\n\ti = skipFunctionCall(seg, i-1)\n\tbuffer += seg[preI:i] + string(seg[i])\n\twh.Expr = append(wh.Expr, DBToken{buffer, TokenFunc})\n\treturn i\n}\n\nfunc (wh *DBWhere) parseString(seg string, i int) int {\n\t//var buffer string\n\ti++\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif ch != '\\'' {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\twh.Expr = append(wh.Expr, DBToken{str, TokenString})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (wh *DBWhere) parseOperator(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif isOpByte(ch) {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\ti--\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\twh.Expr = append(wh.Expr, DBToken{str, TokenOp})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\n// TODO: Make this case insensitive\nfunc normalizeAnd(in string) string {\n\tin = strings.Replace(in, \" and \", \" AND \", -1)\n\treturn strings.Replace(in, \" && \", \" AND \", -1)\n}\nfunc normalizeOr(in string) string {\n\tin = strings.Replace(in, \" or \", \" OR \", -1)\n\treturn strings.Replace(in, \" || \", \" OR \", -1)\n}\n\n// TODO: Write tests for this\nfunc processWhere(whereStr string) (where []DBWhere) {\n\tif whereStr == \"\" {\n\t\treturn where\n\t}\n\twhereStr = normalizeAnd(whereStr)\n\twhereStr = normalizeOr(whereStr)\n\n\tfor _, seg := range strings.Split(whereStr, \" AND \") {\n\t\ttmpWhere := &DBWhere{[]DBToken{}}\n\t\tseg += \")\"\n\t\tfor i := 0; i < len(seg); i++ {\n\t\t\tch := seg[i]\n\t\t\tswitch {\n\t\t\tcase '0' <= ch && ch <= '9':\n\t\t\t\ti = tmpWhere.parseNumber(seg, i)\n\t\t\t// TODO: Sniff the third byte offset from char or it's non-existent to avoid matching uppercase strings which start with OR\n\t\t\tcase ch == 'O' && (i+1) < len(seg) && seg[i+1] == 'R':\n\t\t\t\ttmpWhere.Expr = append(tmpWhere.Expr, DBToken{\"OR\", TokenOr})\n\t\t\t\ti += 1\n\t\t\tcase ch == 'N' && (i+2) < len(seg) && seg[i+1] == 'O' && seg[i+2] == 'T':\n\t\t\t\ttmpWhere.Expr = append(tmpWhere.Expr, DBToken{\"NOT\", TokenNot})\n\t\t\t\ti += 2\n\t\t\tcase ch == 'L' && (i+3) < len(seg) && seg[i+1] == 'I' && seg[i+2] == 'K' && seg[i+3] == 'E':\n\t\t\t\ttmpWhere.Expr = append(tmpWhere.Expr, DBToken{\"LIKE\", TokenLike})\n\t\t\t\ti += 3\n\t\t\tcase ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ch == '_':\n\t\t\t\ti = tmpWhere.parseColumn(seg, i)\n\t\t\tcase ch == '\\'':\n\t\t\t\ti = tmpWhere.parseString(seg, i)\n\t\t\tcase ch == ')' && i < (len(seg)-1):\n\t\t\t\ttmpWhere.Expr = append(tmpWhere.Expr, DBToken{\")\", TokenOp})\n\t\t\tcase isOpByte(ch):\n\t\t\t\ti = tmpWhere.parseOperator(seg, i)\n\t\t\tcase ch == '?':\n\t\t\t\ttmpWhere.Expr = append(tmpWhere.Expr, DBToken{\"?\", TokenSub})\n\t\t\t}\n\t\t}\n\t\twhere = append(where, *tmpWhere)\n\t}\n\treturn where\n}\n\nfunc (set *DBSetter) parseNumber(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif '0' <= ch && ch <= '9' {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\tset.Expr = append(set.Expr, DBToken{str, TokenNumber})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (set *DBSetter) parseColumn(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tswitch {\n\t\tcase ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ch == '_':\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\tcase ch == '(':\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\treturn set.parseFunction(seg, str, i)\n\t\tdefault:\n\t\t\ti--\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\tset.Expr = append(set.Expr, DBToken{str, TokenColumn})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (set *DBSetter) parseFunction(segment, buffer string, i int) int {\n\tpreI := i\n\ti = skipFunctionCall(segment, i-1)\n\tbuffer += segment[preI:i] + string(segment[i])\n\tset.Expr = append(set.Expr, DBToken{buffer, TokenFunc})\n\treturn i\n}\n\nfunc (set *DBSetter) parseString(seg string, i int) int {\n\t//var buffer string\n\ti++\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif ch != '\\'' {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\tset.Expr = append(set.Expr, DBToken{str, TokenString})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc (set *DBSetter) parseOperator(seg string, i int) int {\n\t//var buffer string\n\tsi := i\n\tl := 0\n\tfor ; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif isOpByte(ch) {\n\t\t\t//buffer += string(ch)\n\t\t\tl++\n\t\t} else {\n\t\t\ti--\n\t\t\tvar str string\n\t\t\tif l != 0 {\n\t\t\t\tstr = seg[si : si+l]\n\t\t\t}\n\t\t\tset.Expr = append(set.Expr, DBToken{str, TokenOp})\n\t\t\treturn i\n\t\t}\n\t}\n\treturn i\n}\n\nfunc processSet(setstr string) (setter []DBSetter) {\n\tif setstr == \"\" {\n\t\treturn setter\n\t}\n\n\t// First pass, splitting the string by commas while ignoring the innards of functions\n\tvar setset []string\n\tvar buffer string\n\tvar lastItem int\n\tsetstr += \",\"\n\tfor i := 0; i < len(setstr); i++ {\n\t\tif setstr[i] == '(' {\n\t\t\ti = skipFunctionCall(setstr, i-1)\n\t\t\tsetset = append(setset, setstr[lastItem:i+1])\n\t\t\tbuffer = \"\"\n\t\t\tlastItem = i + 2\n\t\t} else if setstr[i] == ',' && buffer != \"\" {\n\t\t\tsetset = append(setset, buffer)\n\t\t\tbuffer = \"\"\n\t\t\tlastItem = i + 1\n\t\t} else if (setstr[i] > 32) && setstr[i] != ',' && setstr[i] != ')' {\n\t\t\tbuffer += string(setstr[i])\n\t\t}\n\t}\n\n\t// Second pass. Break this setitem into manageable chunks\n\tfor _, setitem := range setset {\n\t\thalves := strings.Split(setitem, \"=\")\n\t\tif len(halves) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttmpSetter := &DBSetter{Column: strings.TrimSpace(halves[0])}\n\t\tsegment := halves[1] + \")\"\n\n\t\tfor i := 0; i < len(segment); i++ {\n\t\t\tch := segment[i]\n\t\t\tswitch {\n\t\t\tcase '0' <= ch && ch <= '9':\n\t\t\t\ti = tmpSetter.parseNumber(segment, i)\n\t\t\tcase ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ch == '_':\n\t\t\t\ti = tmpSetter.parseColumn(segment, i)\n\t\t\tcase ch == '\\'':\n\t\t\t\ti = tmpSetter.parseString(segment, i)\n\t\t\tcase isOpByte(ch):\n\t\t\t\ti = tmpSetter.parseOperator(segment, i)\n\t\t\tcase ch == '?':\n\t\t\t\ttmpSetter.Expr = append(tmpSetter.Expr, DBToken{\"?\", TokenSub})\n\t\t\t}\n\t\t}\n\t\tsetter = append(setter, *tmpSetter)\n\t}\n\treturn setter\n}\n\nfunc processLimit(limitStr string) (limit DBLimit) {\n\thalves := strings.Split(limitStr, \",\")\n\tif len(halves) == 2 {\n\t\tlimit.Offset = halves[0]\n\t\tlimit.MaxCount = halves[1]\n\t} else {\n\t\tlimit.MaxCount = halves[0]\n\t}\n\treturn limit\n}\n\nfunc isOpByte(ch byte) bool {\n\treturn ch == '<' || ch == '>' || ch == '=' || ch == '!' || ch == '*' || ch == '%' || ch == '+' || ch == '-' || ch == '/' || ch == '(' || ch == ')'\n}\n\nfunc isOpRune(ch rune) bool {\n\treturn ch == '<' || ch == '>' || ch == '=' || ch == '!' || ch == '*' || ch == '%' || ch == '+' || ch == '-' || ch == '/' || ch == '(' || ch == ')'\n}\n\nfunc processFields(fieldStr string) (fields []DBField) {\n\tif fieldStr == \"\" {\n\t\treturn fields\n\t}\n\tvar buffer string\n\tvar lastItem int\n\tfieldStr += \",\"\n\tfor i := 0; i < len(fieldStr); i++ {\n\t\tch := fieldStr[i]\n\t\tif ch == '(' {\n\t\t\ti = skipFunctionCall(fieldStr, i-1)\n\t\t\tfields = append(fields, DBField{Name: fieldStr[lastItem : i+1], Type: getIdentifierType(fieldStr[lastItem : i+1])})\n\t\t\tbuffer = \"\"\n\t\t\tlastItem = i + 2\n\t\t} else if ch == ',' && buffer != \"\" {\n\t\t\tfields = append(fields, DBField{Name: buffer, Type: getIdentifierType(buffer)})\n\t\t\tbuffer = \"\"\n\t\t\tlastItem = i + 1\n\t\t} else if (ch >= 32) && ch != ',' && ch != ')' {\n\t\t\tbuffer += string(ch)\n\t\t}\n\t}\n\treturn fields\n}\n\nfunc getIdentifierType(iden string) int {\n\tif ('a' <= iden[0] && iden[0] <= 'z') || ('A' <= iden[0] && iden[0] <= 'Z') {\n\t\tif iden[len(iden)-1] == ')' {\n\t\t\treturn IdenFunc\n\t\t}\n\t\treturn IdenColumn\n\t}\n\tif iden[0] == '\\'' || iden[0] == '\"' {\n\t\treturn IdenString\n\t}\n\treturn IdenLiteral\n}\n\nfunc getIdentifier(seg string, startOffset int) (out string, i int) {\n\tseg = strings.TrimSpace(seg)\n\tseg += \" \" // Avoid overflow bugs with slicing\n\tfor i = startOffset; i < len(seg); i++ {\n\t\tch := seg[i]\n\t\tif ch == '(' {\n\t\t\ti = skipFunctionCall(seg, i)\n\t\t\treturn strings.TrimSpace(seg[startOffset:i]), (i - 1)\n\t\t}\n\t\tif (ch == ' ' || isOpByte(ch)) && i != startOffset {\n\t\t\treturn strings.TrimSpace(seg[startOffset:i]), (i - 1)\n\t\t}\n\t}\n\treturn strings.TrimSpace(seg[startOffset:]), (i - 1)\n}\n\nfunc getOperator(seg string, startOffset int) (out string, i int) {\n\tseg = strings.TrimSpace(seg)\n\tseg += \" \" // Avoid overflow bugs with slicing\n\tfor i = startOffset; i < len(seg); i++ {\n\t\tif !isOpByte(seg[i]) && i != startOffset {\n\t\t\treturn strings.TrimSpace(seg[startOffset:i]), (i - 1)\n\t\t}\n\t}\n\treturn strings.TrimSpace(seg[startOffset:]), (i - 1)\n}\n\nfunc skipFunctionCall(data string, index int) int {\n\tvar braceCount int\n\tfor ; index < len(data); index++ {\n\t\tchar := data[index]\n\t\tif char == '(' {\n\t\t\tbraceCount++\n\t\t} else if char == ')' {\n\t\t\tbraceCount--\n\t\t\tif braceCount == 0 {\n\t\t\t\treturn index\n\t\t\t}\n\t\t}\n\t}\n\treturn index\n}\n\nfunc writeFile(name, content string) (err error) {\n\tf, err := os.Create(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.WriteString(content)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = f.Sync()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn f.Close()\n}\n"
  },
  {
    "path": "query_gen/utils_test.go",
    "content": "package qgen\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\ntype MT struct {\n\tType     int\n\tContents string\n}\n\nfunc expectTokens(t *testing.T, whs []DBWhere, tokens ...MT) {\n\ti := 0\n\tfor _, wh := range whs {\n\t\tfor _, expr := range wh.Expr {\n\t\t\tif expr.Type != tokens[i].Type || expr.Contents != tokens[i].Contents {\n\t\t\t\tt.Fatalf(\"token mismatch: %+v - %+v\\n\", expr, tokens[i])\n\t\t\t}\n\t\t\ti++\n\t\t}\n\t}\n}\n\nfunc TestProcessWhere(t *testing.T) {\n\twhs := processWhere(\"uid = ?\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenSub, \"?\"})\n\twhs = processWhere(\"uid = 1\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenNumber, \"1\"})\n\twhs = processWhere(\"uid = 0\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenNumber, \"0\"})\n\twhs = processWhere(\"uid = '1'\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"1\"})\n\twhs = processWhere(\"uid = 't'\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"t\"})\n\twhs = processWhere(\"uid = ''\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"\"})\n\twhs = processWhere(\"uid = '\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"\"})\n\n\twhs = processWhere(\"uid=?\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenSub, \"?\"})\n\twhs = processWhere(\"uid=1\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenNumber, \"1\"})\n\twhs = processWhere(\"uid=0\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenNumber, \"0\"})\n\twhs = processWhere(\"uid=20\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenNumber, \"20\"})\n\twhs = processWhere(\"uid=uid+1\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"+\"}, MT{TokenNumber, \"1\"})\n\twhs = processWhere(\"uid='1'\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"1\"})\n\twhs = processWhere(\"uid='t'\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"}, MT{TokenOp, \"=\"}, MT{TokenString, \"t\"})\n\n\twhs = processWhere(\"uid\")\n\texpectTokens(t, whs, MT{TokenColumn, \"uid\"})\n}\n\nfunc TestMySQLBuildWhere(t *testing.T) {\n\ta := &MysqlAdapter{Name: \"mysql\", Buffer: make(map[string]DBStmt)}\n\treap := func(wh, ex string) {\n\t\tsb := &strings.Builder{}\n\t\ta.buildWhere(wh, sb)\n\t\tres := sb.String()\n\t\tif res != ex {\n\t\t\tt.Fatalf(\"build where mismatch: '%+v' - '%+v'\\n\", ex, res)\n\t\t}\n\t}\n\treap(\"uid = 0\", \" WHERE `uid`= 0 \")\n\treap(\"uid = '0'\", \" WHERE `uid`= '0'\")\n\treap(\"uid=0\", \" WHERE `uid`= 0 \")\n}\n"
  },
  {
    "path": "quick-update-linux",
    "content": "echo \"Updating Gosora\"\ngit stash\ngit pull origin master\ngit stash apply\n\necho \"Patching Gosora\"\ngo build -ldflags=\"-s -w\" -o Patcher \"./patcher\"\n./Patcher"
  },
  {
    "path": "rev_templates.go",
    "content": "//+lbuild experiment\n\n// ! EXPERIMENTAL\npackage main\n\nimport (\n\t\"errors\"\n\t\"regexp\"\n)\n\ntype Mango struct {\n\ttagFinder *regexp.Regexp\n}\n\nfunc (m *Mango) Init() {\n\tm.tagFinder = regexp.MustCompile(`(?s)\\{\\{(.*)\\}\\}`)\n}\n\nfunc (m *Mango) Parse(tmpl string) (out string, err error) {\n\ttagIndices := m.tagFinder.FindAllStringIndex(tmpl, -1)\n\tif len(tagIndices) > 0 {\n\t\tif tagIndices[0][0] == 0 {\n\t\t\treturn \"\", errors.New(\"We don't support tags in the outermost layer yet\")\n\t\t}\n\t\tvar lastTag = 0\n\t\tvar lastID = 0\n\t\tfor _, tagIndex := range tagIndices {\n\t\t\tvar nestingLayer = 0\n\t\t\tfor i := tagIndex[0]; i > 0; i-- {\n\t\t\t\tswitch tmpl[i] {\n\t\t\t\tcase '>':\n\t\t\t\t\tii, closeTag, err := m.tasteTagToLeft(tmpl, i)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn \"\", err\n\t\t\t\t\t}\n\t\t\t\t\tif closeTag {\n\t\t\t\t\t\tnestingLayer++\n\t\t\t\t\t} else {\n\t\t\t\t\t\t_, tagID := m.parseTag(tmpl, ii, i)\n\t\t\t\t\t\tif tagID == \"\" {\n\t\t\t\t\t\t\tout += tmpl[lastTag:ii] + m.injectID(ii, i)\n\t\t\t\t\t\t\tlastID++\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tout += tmpl[lastTag:i]\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase '<':\n\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", nil\n}\n\nfunc (m *Mango) injectID(start int, end int) string {\n\treturn \"\"\n}\n\nfunc (m *Mango) parseTag(tmpl string, start int, end int) (tagType string, tagID string) {\n\tvar i = start\n\tfor ; i < end; i++ {\n\t\tif tmpl[i] == ' ' {\n\t\t\tbreak\n\t\t}\n\t}\n\ttagType = tmpl[start:i]\n\ti = start\n\tfor ; i < (end - 4); i++ {\n\t\tif tmpl[i] == ' ' && tmpl[i+1] == 'i' && tmpl[i+2] == 'd' && tmpl[i+3] == '=' {\n\t\t\ttagID = m.extractAttributeContents(tmpl, i+4, end)\n\t\t}\n\t}\n\treturn tagType, tagID\n}\n\nfunc (m *Mango) extractAttributeContents(tmpl string, i int, end int) (contents string) {\n\tvar start = i\n\tvar quoteChar byte = 0 // nolint\n\tif m.isHTMLQuoteChar(tmpl[i]) {\n\t\ti++\n\t\tquoteChar = tmpl[i]\n\t}\n\ti += 3\n\tfor ; i < end; i++ {\n\t\tif quoteChar != 0 {\n\t\t\tif tmpl[i] == quoteChar {\n\t\t\t\tbreak\n\t\t\t}\n\t\t} else if tmpl[i] == ' ' {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn tmpl[start:i]\n}\n\nfunc (m *Mango) isHTMLQuoteChar(char byte) bool {\n\treturn char == '\\'' || char == '\"'\n}\n\nfunc (m *Mango) tasteTagToLeft(tmpl string, index int) (indexOut int, closeTag bool, err error) {\n\tvar foundLeftBrace = false\n\tfor ; index > 0; index-- {\n\t\t// What if the / isn't adjacent to the < but has a space instead? Is that even valid?\n\t\tif index >= 1 && tmpl[index] == '/' && tmpl[index-1] == '<' {\n\t\t\tcloseTag = true\n\t\t\tbreak\n\t\t} else if tmpl[index] == '<' {\n\t\t\tfoundLeftBrace = true\n\t\t}\n\t}\n\tif !foundLeftBrace {\n\t\treturn index, closeTag, errors.New(\"The left portion of the tag is missing\")\n\t}\n\treturn index, closeTag, nil\n}\n"
  },
  {
    "path": "router.go",
    "content": "// Now home to the parts of gen_router.go which aren't expected to change from generation to generation\npackage main\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n)\n\n// TODO: Stop spilling these into the package scope?\nfunc init() {\n\t_ = time.Now()\n\tco.SetRouteMapEnum(routeMapEnum)\n\tco.SetReverseRouteMapEnum(reverseRouteMapEnum)\n\tco.SetAgentMapEnum(agentMapEnum)\n\tco.SetReverseAgentMapEnum(reverseAgentMapEnum)\n\tco.SetOSMapEnum(osMapEnum)\n\tco.SetReverseOSMapEnum(reverseOSMapEnum)\n\n\tg := func(n string) int {\n\t\ta, ok := agentMapEnum[n]\n\t\tif !ok {\n\t\t\tpanic(\"name not found in agentMapEnum\")\n\t\t}\n\t\treturn a\n\t}\n\tc.Chrome = g(\"chrome\")\n\tc.Firefox = g(\"firefox\")\n\tc.SimpleBots = []int{\n\t\tg(\"semrush\"),\n\t\tg(\"ahrefs\"),\n\t\tg(\"python\"),\n\t\t//g(\"go\"),\n\t\tg(\"curl\"),\n\t}\n}\n\ntype WriterIntercept struct {\n\thttp.ResponseWriter\n}\n\nfunc NewWriterIntercept(w http.ResponseWriter) *WriterIntercept {\n\treturn &WriterIntercept{w}\n}\n\nvar wiMaxAge = \"max-age=\" + strconv.Itoa(int(c.Day))\n\nfunc (wi *WriterIntercept) WriteHeader(code int) {\n\tif code == 200 {\n\t\th := wi.ResponseWriter.Header()\n\t\th.Set(\"Cache-Control\", wiMaxAge)\n\t\th.Set(\"Vary\", \"Accept-Encoding\")\n\t}\n\twi.ResponseWriter.WriteHeader(code)\n}\n\ntype GenRouter struct {\n\tUploadHandler func(http.ResponseWriter, *http.Request)\n\textraRoutes   map[string]func(http.ResponseWriter, *http.Request, *c.User) c.RouteError\n\n\treqLogger *log.Logger\n\n\treqLog2 *RouterLog\n\tsuspLog *RouterLog\n\n\tsync.RWMutex\n}\n\ntype RouterLogLog struct {\n\tFile *os.File\n\tLog  *log.Logger\n}\ntype RouterLog struct {\n\tFileVal atomic.Value\n\tLogVal  atomic.Value\n\n\tsync.RWMutex\n}\n\nfunc (r *GenRouter) DailyTick() error {\n\tcurrentTime := time.Now()\n\trotateLog := func(l *RouterLog, name string) error {\n\t\tl.Lock()\n\t\tdefer l.Unlock()\n\n\t\tf := l.FileVal.Load().(*os.File)\n\t\tstat, e := f.Stat()\n\t\tif e != nil {\n\t\t\treturn nil\n\t\t}\n\t\tif (stat.Size() < int64(c.Megabyte)) && (currentTime.Sub(c.StartTime).Hours() >= (24 * 7)) {\n\t\t\treturn nil\n\t\t}\n\t\tif e = f.Close(); e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\tstimestr := strconv.FormatInt(currentTime.Unix(), 10)\n\t\tf, e = os.OpenFile(c.Config.LogDir+name+stimestr+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tlval := log.New(f, \"\", log.LstdFlags)\n\t\tl.FileVal.Store(f)\n\t\tl.LogVal.Store(lval)\n\t\treturn nil\n\t}\n\n\tif !c.Config.DisableSuspLog {\n\t\terr := rotateLog(r.suspLog, \"reqs-susp-\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn rotateLog(r.reqLog2, \"reqs-\")\n}\n\ntype RouterConfig struct {\n\tUploads     http.Handler\n\tDisableTick bool\n}\n\nfunc NewGenRouter(cfg *RouterConfig) (*GenRouter, error) {\n\tstimestr := strconv.FormatInt(c.StartTime.Unix(), 10)\n\tcreateLog := func(name, stimestr string) (*RouterLog, error) {\n\t\tf, err := os.OpenFile(c.Config.LogDir+name+\"-\"+stimestr+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tl := log.New(f, \"\", log.LstdFlags)\n\t\tvar aVal atomic.Value\n\t\tvar aVal2 atomic.Value\n\t\taVal.Store(f)\n\t\taVal2.Store(l)\n\t\treturn &RouterLog{FileVal: aVal, LogVal: aVal2}, nil\n\t}\n\treqLog, err := createLog(\"reqs\", stimestr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar suspReqLog *RouterLog\n\tif !c.Config.DisableSuspLog {\n\t\tsuspReqLog, err = createLog(\"reqs-susp\", stimestr)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tf3, err := os.OpenFile(c.Config.LogDir+\"reqs-misc-\"+stimestr+\".log\", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0755)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treqMiscLog := log.New(f3, \"\", log.LstdFlags)\n\n\tro := &GenRouter{\n\t\tUploadHandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\twrit := NewWriterIntercept(w)\n\t\t\thttp.StripPrefix(\"/uploads/\", cfg.Uploads).ServeHTTP(writ, r)\n\t\t},\n\t\textraRoutes: make(map[string]func(http.ResponseWriter, *http.Request, *c.User) c.RouteError),\n\n\t\treqLogger: reqMiscLog,\n\t\treqLog2:   reqLog,\n\t\tsuspLog:   suspReqLog,\n\t}\n\tif !cfg.DisableTick {\n\t\tc.Tasks.Day.Add(ro.DailyTick)\n\t}\n\treturn ro, nil\n}\n\nfunc (r *GenRouter) handleError(err c.RouteError, w http.ResponseWriter, req *http.Request, u *c.User) {\n\tif err.Handled() {\n\t\treturn\n\t}\n\tif err.Type() == \"system\" {\n\t\tc.InternalErrorJSQ(err, w, req, err.JSON())\n\t\treturn\n\t}\n\tc.LocalErrorJSQ(err.Error(), w, req, u, err.JSON())\n}\n\nfunc (r *GenRouter) Handle(_ string, _ http.Handler) {\n}\n\nfunc (r *GenRouter) HandleFunc(pattern string, h func(http.ResponseWriter, *http.Request, *c.User) c.RouteError) {\n\tr.Lock()\n\tdefer r.Unlock()\n\tr.extraRoutes[pattern] = h\n}\n\nfunc (r *GenRouter) RemoveFunc(pattern string) error {\n\tr.Lock()\n\tdefer r.Unlock()\n\t_, ok := r.extraRoutes[pattern]\n\tif !ok {\n\t\treturn ErrNoRoute\n\t}\n\tdelete(r.extraRoutes, pattern)\n\treturn nil\n}\n\nfunc (r *GenRouter) dumpRequest(req *http.Request, pre string, log *RouterLog) {\n\tvar sb strings.Builder\n\tr.ddumpRequest(req, pre, log, &sb)\n}\n\n// TODO: Some of these sanitisations may be redundant\nvar dumpReqLen = len(\"\\nUA: \\n Host: \\nIP: \\n\") + 7\nvar dumpReqLen2 = len(\"\\nHead : \") + 2\n\nfunc (r *GenRouter) ddumpRequest(req *http.Request, pre string, l *RouterLog, sb *strings.Builder) {\n\tnfield := func(label, val string) {\n\t\tsb.WriteString(label)\n\t\tsb.WriteString(val)\n\t}\n\tfield := func(label, val string) {\n\t\tnfield(label, c.SanitiseSingleLine(val))\n\t}\n\tua := req.UserAgent()\n\n\tsb.Grow(dumpReqLen + len(pre) + len(ua) + len(req.Method) + len(req.Host) + (dumpReqLen2 * len(req.Header)))\n\tsb.WriteString(pre)\n\tsb.WriteString(\"\\n\")\n\tsb.WriteString(c.SanitiseSingleLine(req.Method))\n\tsb.WriteRune(' ')\n\tsb.WriteString(c.SanitiseSingleLine(req.URL.Path))\n\tfield(\"\\nUA: \", ua)\n\n\tfor key, val := range req.Header {\n\t\t// Avoid logging this for security reasons\n\t\tif key == \"Cookie\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, vvalue := range val {\n\t\t\tsb.WriteString(\"\\nHead \")\n\t\t\tsb.WriteString(c.SanitiseSingleLine(key))\n\t\t\tsb.WriteString(\": \")\n\t\t\tsb.WriteString(c.SanitiseSingleLine(vvalue))\n\t\t}\n\t}\n\tfield(\"\\nHost: \", req.Host)\n\tif rawQuery := req.URL.RawQuery; rawQuery != \"\" {\n\t\tfield(\"\\nURL.RawQuery: \", rawQuery)\n\t}\n\tif ref := req.Referer(); ref != \"\" {\n\t\tfield(\"\\nRef: \", ref)\n\t}\n\tnfield(\"\\nIP: \", req.RemoteAddr)\n\tsb.WriteString(\"\\n\")\n\n\tstr := sb.String()\n\tl.RLock()\n\tl.LogVal.Load().(*log.Logger).Print(str)\n\tl.RUnlock()\n}\n\nfunc (r *GenRouter) DumpRequest(req *http.Request, pre string) {\n\tr.dumpRequest(req, pre, r.reqLog2)\n}\n\nfunc (r *GenRouter) unknownUA(req *http.Request) {\n\tif c.Dev.DebugMode {\n\t\tvar presb strings.Builder\n\t\tpresb.WriteString(\"Unknown UA: \")\n\t\tfor _, ch := range req.UserAgent() {\n\t\t\tpresb.WriteString(strconv.Itoa(int(ch)))\n\t\t\tpresb.WriteRune(' ')\n\t\t}\n\t\tr.ddumpRequest(req, \"\", r.reqLog2, &presb)\n\t} else {\n\t\tr.reqLogger.Print(\"unknown ua: \", c.SanitiseSingleLine(req.UserAgent()))\n\t}\n}\n\nfunc (r *GenRouter) susp1(req *http.Request) bool {\n\tif !strings.Contains(req.URL.Path, \".\") {\n\t\treturn false\n\t}\n\tif strings.Contains(req.URL.Path, \"..\") /* || strings.Contains(req.URL.Path,\"--\")*/ {\n\t\treturn true\n\t}\n\tlp := strings.ToLower(req.URL.Path)\n\t// TODO: Flag any requests which has a dot with anything but a number after that\n\t// TODO: Use HasSuffix to avoid over-scanning?\n\treturn strings.Contains(lp, \".php\") || strings.Contains(lp, \".asp\") || strings.Contains(lp, \".cgi\") || strings.Contains(lp, \".py\") || strings.Contains(lp, \".sql\") || strings.Contains(lp, \".act\") //.action\n}\n\nfunc (r *GenRouter) suspScan(req *http.Request) {\n\tif c.Config.DisableSuspLog {\n\t\tif c.Dev.FullReqLog {\n\t\t\tr.DumpRequest(req, \"\")\n\t\t}\n\t\treturn\n\t}\n\n\t// TODO: Cover more suspicious strings and at a lower layer than this\n\tvar ch rune\n\tvar susp bool\n\tfor _, ch = range req.URL.Path { //char\n\t\tif ch != '&' && !(ch > 44 && ch < 58) && ch != '=' && ch != '?' && !(ch > 64 && ch < 91) && ch != '\\\\' && ch != '_' && !(ch > 96 && ch < 123) {\n\t\t\tsusp = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Avoid logging the same request multiple times\n\tsusp2 := r.susp1(req)\n\tif susp && susp2 {\n\t\tr.SuspiciousRequest(req, \"Bad char '\"+string(ch)+\"' in path\\nBad snippet in path\")\n\t} else if susp {\n\t\tr.SuspiciousRequest(req, \"Bad char '\"+string(ch)+\"' in path\")\n\t} else if susp2 {\n\t\tr.SuspiciousRequest(req, \"Bad snippet in path\")\n\t} else if c.Dev.FullReqLog {\n\t\tr.DumpRequest(req, \"\")\n\t}\n}\n\nfunc isLocalHost(h string) bool {\n\treturn h == \"localhost\" || h == \"127.0.0.1\" || h == \"::1\"\n}\n\n//var brPool = sync.Pool{}\nvar gzipPool = sync.Pool{}\n\n//var uaBufPool = sync.Pool{}\n\nfunc (r *GenRouter) responseWriter(w http.ResponseWriter) http.ResponseWriter {\n\t/*if bzw, ok := w.(c.BrResponseWriter); ok {\n\t\tw = bzw.ResponseWriter\n\t\tw.Header().Del(\"Content-Encoding\")\n\t} else */if gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\tw = gzw.ResponseWriter\n\t\tw.Header().Del(\"Content-Encoding\")\n\t}\n\treturn w\n}\n"
  },
  {
    "path": "router_gen/build.bat",
    "content": "@echo off\necho Building the router generator\ngo build -ldflags=\"-s -w\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho The router generator was successfully built\npause"
  },
  {
    "path": "router_gen/main.go",
    "content": "/* WIP Under Construction */\npackage main\n\nimport (\n\t\"bytes\"\n\t\"log\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"text/template\"\n)\n\ntype TmplVars struct {\n\tRouteList         []*RouteImpl\n\tRouteGroups       []*RouteGroup\n\tAllRouteNames     []RouteName\n\tAllRouteMap       map[string]int\n\tAllAgentNames     []string\n\tAllAgentMap       map[string]int\n\tAllAgentMarkNames []string\n\tAllAgentMarks     map[string]string\n\tAllAgentMarkIDs   map[string]int\n\tAllOSNames        []string\n\tAllOSMap          map[string]int\n}\n\ntype RouteName struct {\n\tPlain string\n\tShort string\n}\n\nfunc main() {\n\tlog.Println(\"Generating the router...\")\n\n\t// Load all the routes...\n\tr := &Router{}\n\troutes(r)\n\n\ttmplVars := TmplVars{\n\t\tRouteList:   r.routeList,\n\t\tRouteGroups: r.routeGroups,\n\t}\n\tvar allRouteNames []RouteName\n\tallRouteMap := make(map[string]int)\n\n\tvar out string\n\tmapIt := func(name string) {\n\t\tallRouteNames = append(allRouteNames, RouteName{name, strings.Replace(name, \"common.\", \"c.\", -1)})\n\t\tallRouteMap[name] = len(allRouteNames) - 1\n\t}\n\tmapIt(\"routes.Error\")\n\n\tvar indentCache [20]string\n\tcountToIndents := func(ind int) string {\n\t\tout := indentCache[ind]\n\t\tif out != \"\" {\n\t\t\treturn out\n\t\t}\n\t\tfor i := 0; i < ind; i++ {\n\t\t\tout += \"\\t\"\n\t\t}\n\t\tif ind < 20 {\n\t\t\tindentCache[ind] = out\n\t\t}\n\t\treturn out\n\t}\n\to := func(indent int, str string) {\n\t\tout += countToIndents(indent) + str\n\t}\n\ton := func(indent int, str string) {\n\t\tout += \"\\n\" + countToIndents(indent) + str\n\t}\n\tiferrn := func(indent int) {\n\t\tind := countToIndents(indent)\n\t\tind2 := countToIndents(indent + 1)\n\t\tout += \"\\n\" + ind + \"if err != nil {\"\n\t\tout += \"\\n\" + ind2 + \"return err\\n\" + ind + \"}\"\n\t}\n\n\trunBefore := func(runnables []Runnable, ind int) {\n\t\tif len(runnables) > 0 {\n\t\t\tfor _, runnable := range runnables {\n\t\t\t\tif runnable.Literal {\n\t\t\t\t\ton(ind, runnable.Contents)\n\t\t\t\t} else {\n\t\t\t\t\ton(ind, \"err = c.\"+runnable.Contents+\"(w,req,user)\")\n\t\t\t\t\tiferrn(ind)\n\t\t\t\t\to(ind, \"\\n\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tuserCheckNano := func(indent int, route *RouteImpl) {\n\t\ton(indent, \"h, err := c.UserCheckNano(w,req,user,cn)\")\n\t\tiferrn(indent)\n\t\tvcpy := route.Vars\n\t\troute.Vars = []string{\"h\"}\n\t\troute.Vars = append(route.Vars, vcpy...)\n\t}\n\twriteRoute := func(indent int, r *RouteImpl) {\n\t\ton(indent, \"err = \"+strings.Replace(r.Name, \"common.\", \"c.\", -1)+\"(w,req,user\")\n\t\tfor _, item := range r.Vars {\n\t\t\tout += \",\" + item\n\t\t}\n\t\tout += `)`\n\t}\n\n\tfor _, route := range r.routeList {\n\t\tmapIt(route.Name)\n\t\tend := len(route.Path) - 1\n\t\ton(2, \"case \\\"\"+route.Path[0:end]+\"\\\":\")\n\t\t//on(3,\"id = \" + strconv.Itoa(allRouteMap[route.Name]))\n\t\trunBefore(route.RunBefore, 3)\n\t\tif !route.Action && !route.NoHead {\n\t\t\tuserCheckNano(3, route)\n\t\t}\n\t\twriteRoute(3, route)\n\t\tif route.Name != \"common.RouteWebsockets\" {\n\t\t\ton(3, \"co.RouteViewCounter.Bump3(\"+strconv.Itoa(allRouteMap[route.Name])+\", cn)\")\n\t\t}\n\t}\n\n\tprec := NewPrec()\n\tprec.AddSet(\"MemberOnly\", \"SuperModOnly\", \"AdminOnly\", \"SuperAdminOnly\")\n\n\t// Hoist runnables which appear on every route to the route group to avoid code duplication\n\tdupeMap := make(map[string]int)\n\t//skipRunnableAntiDupe:\n\tfor _, g := range r.routeGroups {\n\t\tfor _, route := range g.RouteList {\n\t\t\tif len(route.RunBefore) == 0 {\n\t\t\t\tcontinue //skipRunnableAntiDupe\n\t\t\t}\n\t\t\t// TODO: What if there are duplicates of the same runnable on this route?\n\t\t\tfor _, runnable := range route.RunBefore {\n\t\t\t\tdupeMap[runnable.Contents] += 1\n\t\t\t}\n\t\t}\n\n\t\t// Unset entries which are already set on the route group\n\t\tfor _, gRunnable := range g.RunBefore {\n\t\t\tdelete(dupeMap, gRunnable.Contents)\n\t\t\tfor _, item := range prec.LessThanItem(gRunnable.Contents) {\n\t\t\t\tdelete(dupeMap, item)\n\t\t\t}\n\t\t}\n\n\t\tfor runnable, count := range dupeMap {\n\t\t\tif count == len(g.RouteList) {\n\t\t\t\tg.Before(runnable)\n\t\t\t}\n\t\t}\n\t\t// This method is optimised in the compiler to do a bulk delete\n\t\tfor name, _ := range dupeMap {\n\t\t\tdelete(dupeMap, name)\n\t\t}\n\t}\n\n\tfor _, group := range r.routeGroups {\n\t\tend := len(group.Path) - 1\n\t\ton(2, \"case \\\"\"+group.Path[0:end]+\"\\\":\")\n\t\trunBefore(group.RunBefore, 3)\n\t\ton(3, \"switch(req.URL.Path) {\")\n\n\t\tdefaultRoute := blankRoute()\n\t\tfor _, route := range group.RouteList {\n\t\t\tif group.Path == route.Path {\n\t\t\t\tdefaultRoute = route\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmapIt(route.Name)\n\n\t\t\ton(4, \"case \\\"\"+route.Path+\"\\\":\")\n\t\t\t//on(5,\"id = \" + strconv.Itoa(allRouteMap[route.Name]))\n\t\t\tif len(route.RunBefore) > 0 {\n\t\t\tskipRunnable:\n\t\t\t\tfor _, runnable := range route.RunBefore {\n\t\t\t\t\tfor _, gRunnable := range group.RunBefore {\n\t\t\t\t\t\tif gRunnable.Contents == runnable.Contents {\n\t\t\t\t\t\t\tcontinue skipRunnable\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif prec.GreaterThan(gRunnable.Contents, runnable.Contents) {\n\t\t\t\t\t\t\tcontinue skipRunnable\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif runnable.Literal {\n\t\t\t\t\t\ton(5, runnable.Contents)\n\t\t\t\t\t} else {\n\t\t\t\t\t\ton(5, \"err = c.\"+runnable.Contents+\"(w,req,user)\")\n\t\t\t\t\t\tiferrn(5)\n\t\t\t\t\t\ton(5, \"\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !route.Action && !route.NoHead && !group.NoHead {\n\t\t\t\tuserCheckNano(5, route)\n\t\t\t}\n\t\t\twriteRoute(5, route)\n\t\t\ton(5, \"co.RouteViewCounter.Bump3(\"+strconv.Itoa(allRouteMap[route.Name])+\", cn)\")\n\t\t}\n\n\t\tif defaultRoute.Name != \"\" {\n\t\t\tmapIt(defaultRoute.Name)\n\t\t\ton(4, \"default:\")\n\t\t\t//on(5,\"id = \" + strconv.Itoa(allRouteMap[defaultRoute.Name]))\n\t\t\trunBefore(defaultRoute.RunBefore, 4)\n\t\t\tif !defaultRoute.Action && !defaultRoute.NoHead && !group.NoHead {\n\t\t\t\tuserCheckNano(5, defaultRoute)\n\t\t\t}\n\t\t\twriteRoute(5, defaultRoute)\n\t\t\ton(5, \"co.RouteViewCounter.Bump3(\"+strconv.Itoa(allRouteMap[defaultRoute.Name])+\", cn)\")\n\t\t}\n\t\ton(3, \"}\")\n\t}\n\n\t// Stubs for us to refer to these routes through\n\tmapIt(\"routes.DynamicRoute\")\n\tmapIt(\"routes.UploadedFile\")\n\tmapIt(\"routes.StaticFile\")\n\tmapIt(\"routes.RobotsTxt\")\n\tmapIt(\"routes.SitemapXml\")\n\tmapIt(\"routes.OpenSearchXml\")\n\tmapIt(\"routes.Favicon\")\n\tmapIt(\"routes.BadRoute\")\n\tmapIt(\"routes.HTTPSRedirect\")\n\ttmplVars.AllRouteNames = allRouteNames\n\ttmplVars.AllRouteMap = allRouteMap\n\n\ttmplVars.AllOSNames = []string{\n\t\t\"unknown\",\n\t\t\"windows\",\n\t\t\"linux\",\n\t\t\"mac\",\n\t\t\"android\",\n\t\t\"iphone\",\n\t}\n\ttmplVars.AllOSMap = make(map[string]int)\n\tfor id, os := range tmplVars.AllOSNames {\n\t\ttmplVars.AllOSMap[os] = id\n\t}\n\n\ttmplVars.AllAgentNames = []string{\n\t\t\"unknown\",\n\t\t\"opera\",\n\t\t\"chrome\",\n\t\t\"firefox\",\n\t\t\"safari\",\n\t\t\"edge\",\n\t\t\"internetexplorer\",\n\t\t\"trident\", // Hack to support IE11\n\n\t\t\"androidchrome\",\n\t\t\"mobilesafari\",\n\t\t\"samsung\",\n\t\t\"ucbrowser\",\n\n\t\t\"googlebot\",\n\t\t\"yandex\",\n\t\t\"bing\",\n\t\t\"slurp\",\n\t\t\"exabot\",\n\t\t\"mojeek\",\n\t\t\"cliqz\",\n\t\t\"qwant\",\n\t\t\"datenbank\",\n\t\t\"baidu\",\n\t\t\"sogou\",\n\t\t\"toutiao\",\n\t\t\"haosou\",\n\t\t\"duckduckgo\",\n\t\t\"seznambot\",\n\t\t\"discord\",\n\t\t\"telegram\",\n\t\t\"twitter\",\n\t\t\"facebook\",\n\t\t\"cloudflare\",\n\t\t\"archive_org\",\n\t\t\"uptimebot\",\n\t\t\"slackbot\",\n\t\t\"apple\",\n\t\t\"discourse\",\n\t\t\"xenforo\",\n\t\t\"mattermost\",\n\t\t\"alexa\",\n\t\t\"lynx\",\n\t\t\"blank\",\n\t\t\"malformed\",\n\t\t\"suspicious\",\n\t\t\"semrush\",\n\t\t\"dotbot\",\n\t\t\"ahrefs\",\n\t\t\"proximic\",\n\t\t\"megaindex\",\n\t\t\"majestic\",\n\t\t\"cocolyze\",\n\t\t\"babbar\",\n\t\t\"surdotly\",\n\t\t\"domcop\",\n\t\t\"netcraft\",\n\t\t\"seostar\",\n\t\t\"pandalytics\",\n\t\t\"blexbot\",\n\t\t\"wappalyzer\",\n\t\t\"twingly\",\n\t\t\"linkfluence\",\n\t\t\"pagething\",\n\t\t\"burf\",\n\t\t\"aspiegel\",\n\t\t\"mail_ru\",\n\t\t\"ccbot\",\n\t\t\"yacy\",\n\t\t\"zgrab\",\n\t\t\"cloudsystemnetworks\",\n\t\t\"maui\",\n\t\t\"curl\",\n\t\t\"python\",\n\t\t//\"go\",\n\t\t\"headlesschrome\",\n\t\t\"awesome_bot\",\n\t}\n\n\ttmplVars.AllAgentMap = make(map[string]int)\n\tfor id, agent := range tmplVars.AllAgentNames {\n\t\ttmplVars.AllAgentMap[agent] = id\n\t}\n\n\ttmplVars.AllAgentMarkNames = []string{}\n\ttmplVars.AllAgentMarks = map[string]string{}\n\n\t// Add agent marks\n\ta := func(mark, agent string) {\n\t\ttmplVars.AllAgentMarkNames = append(tmplVars.AllAgentMarkNames, mark)\n\t\ttmplVars.AllAgentMarks[mark] = agent\n\t}\n\ta(\"OPR\", \"opera\")\n\ta(\"Chrome\", \"chrome\")\n\ta(\"Firefox\", \"firefox\")\n\ta(\"Safari\", \"safari\")\n\ta(\"MSIE\", \"internetexplorer\")\n\ta(\"Trident\", \"trident\") // Hack to support IE11\n\ta(\"Edge\", \"edge\")\n\ta(\"Lynx\", \"lynx\") // There's a rare android variant of lynx which isn't covered by this\n\ta(\"SamsungBrowser\", \"samsung\")\n\ta(\"UCBrowser\", \"ucbrowser\")\n\n\ta(\"Google\", \"googlebot\")\n\ta(\"Googlebot\", \"googlebot\")\n\ta(\"yandex\", \"yandex\") // from the URL\n\ta(\"DuckDuckBot\", \"duckduckgo\")\n\ta(\"DuckDuckGo\", \"duckduckgo\")\n\ta(\"Baiduspider\", \"baidu\")\n\ta(\"Sogou\", \"sogou\")\n\ta(\"ToutiaoSpider\", \"toutiao\")\n\ta(\"Bytespider\", \"toutiao\")\n\ta(\"360Spider\", \"haosou\")\n\ta(\"bingbot\", \"bing\")\n\ta(\"BingPreview\", \"bing\")\n\ta(\"msnbot\", \"bing\")\n\ta(\"Slurp\", \"slurp\")\n\ta(\"Exabot\", \"exabot\")\n\ta(\"MojeekBot\", \"mojeek\")\n\ta(\"Cliqzbot\", \"cliqz\")\n\ta(\"Qwantify\", \"qwant\")\n\ta(\"netEstate\", \"datenbank\")\n\ta(\"SeznamBot\", \"seznambot\")\n\ta(\"CloudFlare\", \"cloudflare\") // Track alwayson specifically in case there are other bots?\n\ta(\"archive\", \"archive_org\")   //archive.org_bot\n\ta(\"Uptimebot\", \"uptimebot\")\n\ta(\"Slackbot\", \"slackbot\")\n\ta(\"Slack\", \"slackbot\")\n\ta(\"Discordbot\", \"discord\")\n\ta(\"TelegramBot\", \"telegram\")\n\ta(\"Twitterbot\", \"twitter\")\n\ta(\"facebookexternalhit\", \"facebook\")\n\ta(\"Facebot\", \"facebook\")\n\ta(\"Applebot\", \"apple\")\n\ta(\"Discourse\", \"discourse\")\n\ta(\"XenForo\", \"xenforo\")\n\ta(\"mattermost\", \"mattermost\")\n\ta(\"ia_archiver\", \"alexa\")\n\n\ta(\"SemrushBot\", \"semrush\")\n\ta(\"DotBot\", \"dotbot\")\n\ta(\"AhrefsBot\", \"ahrefs\")\n\ta(\"proximic\", \"proximic\")\n\ta(\"MegaIndex\", \"megaindex\")\n\ta(\"MJ12bot\", \"majestic\") // TODO: This isn't matching bots out in the wild\n\ta(\"mj12bot\", \"majestic\")\n\ta(\"Cocolyzebot\", \"cocolyze\")\n\ta(\"Barkrowler\", \"babbar\")\n\ta(\"SurdotlyBot\", \"surdotly\")\n\ta(\"DomCopBot\", \"domcop\")\n\ta(\"NetcraftSurveyAgent\", \"netcraft\")\n\ta(\"seostar\", \"seostar\")\n\ta(\"Pandalytics\", \"pandalytics\")\n\ta(\"BLEXBot\", \"blexbot\")\n\ta(\"Wappalyzer\", \"wappalyzer\")\n\ta(\"Twingly\", \"twingly\")\n\ta(\"linkfluence\", \"linkfluence\")\n\ta(\"PageThing\", \"pagething\")\n\ta(\"Burf\", \"burf\")\n\ta(\"AspiegelBot\", \"aspiegel\")\n\ta(\"PetalBot\", \"aspiegel\")\n\ta(\"RU_Bot\", \"mail_ru\") // Mail.RU_Bot\n\ta(\"CCBot\", \"ccbot\")\n\ta(\"yacybot\", \"yacy\")\n\ta(\"zgrab\", \"zgrab\")\n\ta(\"Nimbostratus\", \"cloudsystemnetworks\")\n\ta(\"MauiBot\", \"maui\")\n\ta(\"curl\", \"curl\")\n\ta(\"python\", \"python\")\n\t//a(\"Go\", \"go\") // yacy has java as part of it's UA, try to avoid hitting crawlers written in go\n\ta(\"HeadlessChrome\", \"headlesschrome\")\n\ta(\"awesome_bot\", \"awesome_bot\")\n\t// TODO: Detect Adsbot/3.1, it has a similar user agent to Google's Adsbot, but it is different. No Google fragments.\n\n\ttmplVars.AllAgentMarkIDs = make(map[string]int)\n\tfor mark, agent := range tmplVars.AllAgentMarks {\n\t\ttmplVars.AllAgentMarkIDs[mark] = tmplVars.AllAgentMap[agent]\n\t}\n\n\tfileData := `// Code generated by Gosora's Router Generator. DO NOT EDIT.\n/* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */\npackage main\n\nimport (\n\t\"strings\"\n\t//\"bytes\"\n\t\"strconv\"\n\t\"compress/gzip\"\n\t\"sync/atomic\"\n\t\"errors\"\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/Azareal/Gosora/routes\"\n\t\"github.com/Azareal/Gosora/routes/panel\"\n\n\t//\"github.com/andybalholm/brotli\"\n)\n\nvar ErrNoRoute = errors.New(\"That route doesn't exist.\")\n// TODO: What about the /uploads/ route? x.x\nvar RouteMap = map[string]interface{}{ {{range .AllRouteNames}}\n\t\"{{.Plain}}\": {{.Short}},{{end}}\n}\n\n// ! NEVER RELY ON THESE REMAINING THE SAME BETWEEN COMMITS\nvar routeMapEnum = map[string]int{ {{range $index, $el := .AllRouteNames}}\n\t\"{{$el.Plain}}\": {{$index}},{{end}}\n}\nvar reverseRouteMapEnum = map[int]string{ {{range $index, $el := .AllRouteNames}}\n\t{{$index}}: \"{{$el.Plain}}\",{{end}}\n}\nvar osMapEnum = map[string]int{ {{range $index, $el := .AllOSNames}}\n\t\"{{$el}}\": {{$index}},{{end}}\n}\nvar reverseOSMapEnum = map[int]string{ {{range $index, $el := .AllOSNames}}\n\t{{$index}}: \"{{$el}}\",{{end}}\n}\nvar agentMapEnum = map[string]int{ {{range $index, $el := .AllAgentNames}}\n\t\"{{$el}}\": {{$index}},{{end}}\n}\nvar reverseAgentMapEnum = map[int]string{ {{range $index, $el := .AllAgentNames}}\n\t{{$index}}: \"{{$el}}\",{{end}}\n}\nvar markToAgent = map[string]string{ {{range $index, $el := .AllAgentMarkNames}}\n\t\"{{$el}}\": \"{{index $.AllAgentMarks $el}}\",{{end}}\n}\nvar markToID = map[string]int{ {{range $index, $el := .AllAgentMarkNames}}\n\t\"{{$el}}\": {{index $.AllAgentMarkIDs $el}},{{end}}\n}\n/*var agentRank = map[string]int{\n\t\"opera\":9,\n\t\"chrome\":8,\n\t\"safari\":1,\n}*/\n\n// HTTPSRedirect is a connection handler which redirects all HTTP requests to HTTPS\ntype HTTPSRedirect struct {}\n\nfunc (red *HTTPSRedirect) ServeHTTP(w http.ResponseWriter, req *http.Request) {\n\tw.Header().Set(\"Connection\", \"close\")\n\tco.RouteViewCounter.Bump({{index .AllRouteMap \"routes.HTTPSRedirect\"}})\n\tdest := \"https://\" + req.Host + req.URL.String()\n\thttp.Redirect(w, req, dest, http.StatusTemporaryRedirect)\n}\n\nfunc (r *GenRouter) SuspiciousRequest(req *http.Request, pre string) {\n\tif c.Config.DisableSuspLog {\n\t\treturn\n\t}\n\tvar sb strings.Builder\n\tif pre != \"\" {\n\t\tsb.WriteString(\"Suspicious Request\\n\")\n\t} else {\n\t\tpre = \"Suspicious Request\"\n\t}\n\tr.ddumpRequest(req,pre,r.suspLog,&sb)\n\tco.AgentViewCounter.Bump({{.AllAgentMap.suspicious}})\n}\n\n// TODO: Pass the default path or config struct to the router rather than accessing it via a package global\n// TODO: SetDefaultPath\n// TODO: GetDefaultPath\nfunc (r *GenRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {\n\tmalformedRequest := func(typ int) {\n\t\tw.WriteHeader(200) // 400\n\t\tw.Write([]byte(\"\"))\n\t\tr.DumpRequest(req,\"Malformed Request T\"+strconv.Itoa(typ))\n\t\tco.AgentViewCounter.Bump({{.AllAgentMap.malformed}})\n\t}\n\t\n\t// Split the Host and Port string\n\tvar shost, sport string\n\tif req.Host[0]=='[' {\n\t\tspl := strings.Split(req.Host,\"]\")\n\t\tif len(spl) > 2 {\n\t\t\tmalformedRequest(0)\n\t\t\treturn\n\t\t}\n\t\tshost = strings.TrimPrefix(spl[0],\"[\")\n\t\tsport = strings.TrimPrefix(spl[1],\":\")\n\t} else if strings.Contains(req.Host,\":\") {\n\t\tspl := strings.Split(req.Host,\":\")\n\t\tif len(spl) > 2 {\n\t\t\tmalformedRequest(1)\n\t\t\treturn\n\t\t}\n\t\tshost = spl[0]\n\t\t//if len(spl)==2 {\n\t\t\tsport = spl[1]\n\t\t//}\n\t} else {\n\t\tshost = req.Host\n\t}\n\t// TODO: Reject requests from non-local IPs, if the site host is set to localhost or a localhost IP\n\tif !c.Config.LoosePort && c.Site.PortInt != 80 && c.Site.PortInt != 443 && sport != c.Site.Port {\n\t\tmalformedRequest(2)\n\t\treturn\n\t}\n\t\n\t// Redirect www. and local IP requests to the right place\n\tif strings.HasPrefix(shost, \"www.\") || c.Site.LocalHost {\n\tif shost == \"www.\" + c.Site.Host || (c.Site.LocalHost && shost != c.Site.Host && isLocalHost(shost)) {\n\t\t// TODO: Abstract the redirect logic?\n\t\tw.Header().Set(\"Connection\", \"close\")\n\t\tvar s, p string\n\t\tif c.Config.SslSchema {\n\t\t\ts = \"s\"\n\t\t}\n\t\tif c.Site.PortInt != 80 && c.Site.PortInt != 443 {\n\t\t\tp = \":\"+c.Site.Port\n\t\t}\n\t\tdest := \"http\"+s+\"://\" + c.Site.Host+p + req.URL.Path\n\t\tif len(req.URL.RawQuery) > 0 {\n\t\t\tdest += \"?\" + req.URL.RawQuery\n\t\t}\n\t\thttp.Redirect(w, req, dest, http.StatusMovedPermanently)\n\t\treturn\n\t}\n\t}\n\n\t// Deflect malformed requests\n\tif len(req.URL.Path) == 0 || req.URL.Path[0] != '/' || (!c.Config.LooseHost && shost != c.Site.Host) {\n\t\tmalformedRequest(3)\n\t\treturn\n\t}\n\tr.suspScan(req)\n\n\t// Indirect the default route onto a different one\n\tif req.URL.Path == \"/\" {\n\t\treq.URL.Path = c.Config.DefaultPath\n\t}\n\t//log.Print(\"URL.Path: \", req.URL.Path)\n\tprefix := req.URL.Path[0:strings.IndexByte(req.URL.Path[1:],'/') + 1]\n\n\t// TODO: Use the same hook table as downstream\n\thTbl := c.GetHookTable()\n\tskip, ferr := c.H_router_after_filters_hook(hTbl, w, req, prefix)\n\tif skip || ferr != nil {\n\t\treturn\n\t}\n\n\tif prefix != \"/ws\" {\n\t\th := w.Header()\n\t\th.Set(\"X-Frame-Options\", \"deny\")\n\t\th.Set(\"X-XSS-Protection\", \"1; mode=block\") // TODO: Remove when we add a CSP? CSP's are horrendously glitchy things, tread with caution before removing\n\t\th.Set(\"X-Content-Type-Options\", \"nosniff\")\n\t\tif c.Config.RefNoRef || !c.Config.SslSchema {\n\t\t\th.Set(\"Referrer-Policy\",\"no-referrer\")\n\t\t} else {\n\t\t\th.Set(\"Referrer-Policy\",\"strict-origin\")\n\t\t}\n\t\th.Set(\"Permissions-Policy\",\"interest-cohort=()\")\n\t}\n\t\n\tif c.Dev.SuperDebug {\n\t\tr.DumpRequest(req,\"before routes.StaticFile\")\n\t}\n\t// Increment the request counter\n\tif !c.Config.DisableAnalytics {\n\t\tco.GlobalViewCounter.Bump()\n\t}\n\t\n\tif prefix == \"/s\" { //old prefix: /static\n\t\tif !c.Config.DisableAnalytics {\n\t\t\tco.RouteViewCounter.Bump({{index .AllRouteMap \"routes.StaticFile\"}})\n\t\t}\n\t\troutes.StaticFile(w, req)\n\t\treturn\n\t}\n\t// TODO: Handle JS routes\n\tif atomic.LoadInt32(&c.IsDBDown) == 1 {\n\t\tc.DatabaseError(w, req)\n\t\treturn\n\t}\n\tif c.Dev.SuperDebug {\n\t\tr.reqLogger.Print(\"before PreRoute\")\n\t}\n\n\t/*if c.Dev.QuicPort != 0 {\n\t\tsQuicPort := strconv.Itoa(c.Dev.QuicPort)\n\t\tw.Header().Set(\"Alt-Svc\", \"quic=\\\":\"+sQuicPort+\"\\\"; ma=2592000; v=\\\"44,43,39\\\", h3-23=\\\":\"+sQuicPort+\"\\\"; ma=3600, h3-24=\\\":\"+sQuicPort+\"\\\"; ma=3600, h2=\\\":443\\\"; ma=3600\")\n\t}*/\n\n\t// Track the user agents. Unfortunately, everyone pretends to be Mozilla, so this'll be a little less efficient than I would like.\n\t// TODO: Add a setting to disable this?\n\t// TODO: Use a more efficient detector instead of smashing every possible combination in\n\t// TODO: Make this testable\n\tvar agent int\n\tif !c.Config.DisableAnalytics {\n\t\n\tua := strings.TrimSpace(strings.Replace(strings.TrimPrefix(req.UserAgent(),\"Mozilla/5.0 \"),\" Safari/537.36\",\"\",-1)) // Noise, no one's going to be running this and it would require some sort of agent ranking system to determine which identifier should be prioritised over another\n\tif ua == \"\" {\n\t\tco.AgentViewCounter.Bump({{.AllAgentMap.blank}})\n\t\tr.unknownUA(req)\n\t} else {\t\t\n\t\t// WIP UA Parser\n\t\t//var ii = uaBufPool.Get()\n\t\tvar buf []byte\n\t\t//if ii != nil {\n\t\t//\tbuf = ii.([]byte)\n\t\t//}\n\t\tvar items []string\n\t\tvar os int\n\t\tfor _, it := range uutils.StringToBytes(ua) {\n\t\t\tif (it > 64 && it < 91) || (it > 96 && it < 123) || (it > 47 && it < 58) || it == '_' {\n\t\t\t\t// TODO: Store an index and slice that instead?\n\t\t\t\tbuf = append(buf, it)\n\t\t\t} else if it == ' ' || it == '(' || it == ')' || it == '-' || it == ';' || it == ':' || it == '.' || it == '+' || it == '~' || it == '@' /*|| (it == ':' && bytes.Equal(buf,[]byte(\"http\")))*/ || it == ',' || it == '/' {\n\t\t\t\t//log.Print(\"buf: \",string(buf))\n\t\t\t\t//log.Print(\"it: \",string(it))\n\t\t\t\tif len(buf) != 0 {\n\t\t\t\t\tif len(buf) > 2 {\n\t\t\t\t\t\t// Use an unsafe zero copy conversion here just to use the switch, it's not safe for this string to escape from here, as it will get mutated, so do a regular string conversion in append\n\t\t\t\t\t\tswitch(uutils.BytesToString(buf)) {\n\t\t\t\t\t\tcase \"Windows\":\n\t\t\t\t\t\t\tos = {{.AllOSMap.windows}}\n\t\t\t\t\t\tcase \"Linux\":\n\t\t\t\t\t\t\tos = {{.AllOSMap.linux}}\n\t\t\t\t\t\tcase \"Mac\":\n\t\t\t\t\t\t\tos = {{.AllOSMap.mac}}\n\t\t\t\t\t\tcase \"iPhone\":\n\t\t\t\t\t\t\tos = {{.AllOSMap.iphone}}\n\t\t\t\t\t\tcase \"Android\":\n\t\t\t\t\t\t\tos = {{.AllOSMap.android}}\n\t\t\t\t\t\tcase \"like\",\"compatible\",\"NT\",\"X\",\"com\",\"KHTML\":\n\t\t\t\t\t\t\t// Skip these words\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t//log.Print(\"append buf\")\n\t\t\t\t\t\t\titems = append(items, string(buf))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t//log.Print(\"reset buf\")\n\t\t\t\t\tbuf = buf[:0]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// TODO: Test this\n\t\t\t\titems = items[:0]\n\t\t\t\tif c.Config.DisableSuspLog {\n\t\t\t\t\tr.reqLogger.Print(\"Illegal char \"+strconv.Itoa(int(it))+\" in UA\\nUA Buf: \", buf,\"\\nUA Buf String: \", string(buf))\n\t\t\t\t} else {\n\t\t\t\t\tr.SuspiciousRequest(req,\"Illegal char \"+strconv.Itoa(int(it))+\" in UA\")\n\t\t\t\t\tr.reqLogger.Print(\"UA Buf: \", buf,\"\\nUA Buf String: \", string(buf))\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\t//uaBufPool.Put(buf)\n\n\t\t// Iterate over this in reverse as the real UA tends to be on the right side\n\t\tfor i := len(items) - 1; i >= 0; i-- {\n\t\t\t//fAgent, ok := markToAgent[items[i]]\n\t\t\tfAgent, ok := markToID[items[i]]\n\t\t\tif ok {\n\t\t\t\tagent = fAgent\n\t\t\t\tif agent != {{.AllAgentMap.safari}} {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif c.Dev.SuperDebug {\n\t\t\tr.reqLogger.Print(\"parsed agent: \", agent,\"\\nos: \", os)\n\t\t\tr.reqLogger.Printf(\"items: %+v\\n\",items)\n\t\t\t/*for _, it := range items {\n\t\t\t\tr.reqLogger.Printf(\"it: %+v\\n\",string(it))\n\t\t\t}*/\n\t\t}\n\t\t\n\t\t// Special handling\n\t\tswitch(agent) {\n\t\tcase {{.AllAgentMap.chrome}}:\n\t\t\tif os == {{.AllOSMap.android}} {\n\t\t\t\tagent = {{.AllAgentMap.androidchrome}}\n\t\t\t}\n\t\tcase {{.AllAgentMap.safari}}:\n\t\t\tif os == {{.AllOSMap.iphone}} {\n\t\t\t\tagent = {{.AllAgentMap.mobilesafari}}\n\t\t\t}\n\t\tcase {{.AllAgentMap.trident}}:\n\t\t\t// Hack to support IE11, change this after we start logging versions\n\t\t\tif strings.Contains(ua,\"rv:11\") {\n\t\t\t\tagent = {{.AllAgentMap.internetexplorer}}\n\t\t\t}\n\t\tcase {{.AllAgentMap.zgrab}}:\n\t\t\tw.WriteHeader(200) // 400\n\t\t\tw.Write([]byte(\"\"))\n\t\t\tr.DumpRequest(req,\"Blocked Scanner\")\n\t\t\tco.AgentViewCounter.Bump({{.AllAgentMap.zgrab}})\n\t\t\treturn\n\t\t}\n\t\t\n\t\tif agent == 0 {\n\t\t\t//co.AgentViewCounter.Bump({{.AllAgentMap.unknown}})\n\t\t\tr.unknownUA(req)\n\t\t}// else {\n\t\t\t//co.AgentViewCounter.Bump(agentMapEnum[agent])\n\t\t\tco.AgentViewCounter.Bump(agent)\n\t\t//}\n\t\tco.OSViewCounter.Bump(os)\n\t}\n\n\t// TODO: Do we want to track missing language headers too? Maybe as it's own type, e.g. \"noheader\"?\n\t// TODO: Default to anything other than en, if anything else is present, to avoid over-representing it for multi-linguals?\n\tlang := req.Header.Get(\"Accept-Language\")\n\tif lang != \"\" {\n\t\t// TODO: Reduce allocs here\n\t\tlLang := strings.Split(strings.TrimSpace(lang),\"-\")\n\t\ttLang := strings.Split(strings.Split(lLang[0],\";\")[0],\",\")\n\t\tc.DebugDetail(\"tLang:\", tLang)\n\t\tvar llLang string\n\t\tfor _, seg := range tLang {\n\t\t\tif seg == \"*\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tllLang = seg\n\t\t\tbreak\n\t\t}\n\t\tc.DebugDetail(\"llLang:\", llLang)\n\t\tif !co.LangViewCounter.Bump(llLang) {\n\t\t\tr.DumpRequest(req,\"Invalid ISO Code\")\n\t\t}\n\t} else {\n\t\tco.LangViewCounter.Bump2(0)\n\t}\n\n\tif !c.Config.RefNoTrack {\n\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\tlikelyBot := ae == \"gzip\" || ae == \"\"\n\t\tif !likelyBot {\n\t\t\tref := req.Header.Get(\"Referer\") // Check the 'referrer' header too? :P\n\t\t\t// TODO: Extend the effects of DNT elsewhere?\n\t\t\tif ref != \"\" && req.Header.Get(\"DNT\") != \"1\" {\n\t\t\t\t// ? Optimise this a little?\n\t\t\t\tref = strings.TrimPrefix(strings.TrimPrefix(ref,\"http://\"),\"https://\")\n\t\t\t\tref = strings.Split(ref,\"/\")[0]\n\t\t\t\tportless := strings.Split(ref,\":\")[0]\n\t\t\t\t// TODO: Handle c.Site.Host in uppercase too?\n\t\t\t\tif portless != \"localhost\" && portless != \"127.0.0.1\" && portless != c.Site.Host {\n\t\t\t\t\tr.DumpRequest(req,\"Ref Route\")\n\t\t\t\t\tco.ReferrerTracker.Bump(ref)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t}\n\t\n\t// Deal with the session stuff, etc.\n\tucpy, ok := c.PreRoute(w, req)\n\tif !ok {\n\t\treturn\n\t}\n\tuser := &ucpy\n\tuser.LastAgent = agent\n\tif c.Dev.SuperDebug {\n\t\tr.reqLogger.Print(\n\t\t\t\"after PreRoute\\n\" +\n\t\t\t\"routeMapEnum: \", routeMapEnum)\n\t}\n\t//log.Println(\"req: \", req)\n\n\t// Disable Gzip when SSL is disabled for security reasons?\n\tif prefix != \"/ws\" {\n\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\t/*if strings.Contains(ae, \"br\") {\n\t\t\th := w.Header()\n\t\t\th.Set(\"Content-Encoding\", \"br\")\n\t\t\tvar ii = brPool.Get()\n\t\t\tvar igzw *brotli.Writer\n\t\t\tif ii == nil {\n\t\t\t\tigzw = brotli.NewWriter(w)\n\t\t\t} else {\n\t\t\t\tigzw = ii.(*brotli.Writer)\n\t\t\t\tigzw.Reset(w)\n\t\t\t}\n\t\t\tgzw := c.BrResponseWriter{Writer: igzw, ResponseWriter: w}\n\t\t\tdefer func() {\n\t\t\t\t//h := w.Header()\n\t\t\t\tif h.Get(\"Content-Encoding\") == \"br\" && h.Get(\"X-I\") == \"\" {\n\t\t\t\t\t//log.Print(\"push br close\")\n\t\t\t\t\tigzw := gzw.Writer.(*brotli.Writer)\n\t\t\t\t\tigzw.Close()\n\t\t\t\t\tbrPool.Put(igzw)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tw = gzw\n\t\t} else */if strings.Contains(ae, \"gzip\") {\n\t\t\th := w.Header()\n\t\t\th.Set(\"Content-Encoding\", \"gzip\")\n\t\t\tvar ii = gzipPool.Get()\n\t\t\tvar igzw *gzip.Writer\n\t\t\tif ii == nil {\n\t\t\t\tigzw = gzip.NewWriter(w)\n\t\t\t} else {\n\t\t\t\tigzw = ii.(*gzip.Writer)\n\t\t\t\tigzw.Reset(w)\n\t\t\t}\n\t\t\tgzw := c.GzipResponseWriter{Writer: igzw, ResponseWriter: w}\n\t\t\tdefer func() {\n\t\t\t\t//h := w.Header()\n\t\t\t\tif h.Get(\"Content-Encoding\") == \"gzip\" && h.Get(\"X-I\") == \"\" {\n\t\t\t\t\t//log.Print(\"push gzip close\")\n\t\t\t\t\tigzw := gzw.Writer.(*gzip.Writer)\n\t\t\t\t\tigzw.Close()\n\t\t\t\t\tgzipPool.Put(igzw)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tw = gzw\n\t\t}\n\t}\n\n\tskip, ferr = c.H_router_pre_route_hook(hTbl, w, req, user, prefix)\n\tif skip || ferr != nil {\n\t\tr.handleError(ferr,w,req,user)\n\t\treturn\n\t}\n\tvar extraData string\n\tif req.URL.Path[len(req.URL.Path) - 1] != '/' {\n\t\textraData = req.URL.Path[strings.LastIndexByte(req.URL.Path,'/') + 1:]\n\t\treq.URL.Path = req.URL.Path[:strings.LastIndexByte(req.URL.Path,'/') + 1]\n\t}\n\tferr = r.routeSwitch(w, req, user, prefix, extraData)\n\tif ferr != nil {\n\t\tr.handleError(ferr,w,req,user)\n\t\treturn\n\t}\n\t/*if !c.Config.DisableAnalytics {\n\t\tco.RouteViewCounter.Bump(id)\n\t}*/\n\n\thTbl.VhookNoRet(\"router_end\", w, req, user, prefix, extraData)\n\t//c.StoppedServer(\"Profile end\")\n}\n\t\nfunc (r *GenRouter) routeSwitch(w http.ResponseWriter, req *http.Request, user *c.User, prefix, extraData string) /*(id int, orerr */c.RouteError/*)*/ {\n\tvar err c.RouteError\n\tcn := uutils.Nanotime()\n\tswitch(prefix) {` + out + `\n\t\t/*case \"/sitemaps\": // TODO: Count these views\n\t\t\treq.URL.Path += extraData\n\t\t\terr = sitemapSwitch(w,req)*/\n\t\t// ! Temporary fix for certain bots\n\t\tcase \"/static\":\n\t\t\tw.Header().Set(\"Connection\", \"close\")\n\t\t\thttp.Redirect(w, req, \"/s/\"+extraData, http.StatusTemporaryRedirect)\n\t\tcase \"/uploads\":\n\t\t\tif extraData == \"\" {\n\t\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.UploadedFile\"}}, cn)\n\t\t\t\treturn c.NotFound(w,req,nil)\n\t\t\t}\n\t\t\tw = r.responseWriter(w)\n\t\t\treq.URL.Path += extraData\n\t\t\t// TODO: Find a way to propagate errors up from this?\n\t\t\tr.UploadHandler(w,req) // TODO: Count these views\n\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.UploadedFile\"}}, cn)\n\t\t\treturn nil\n\t\tcase \"\":\n\t\t\t// Stop the favicons, robots.txt file, etc. resolving to the topics list\n\t\t\t// TODO: Add support for favicons and robots.txt files\n\t\t\tswitch(extraData) {\n\t\t\t\tcase \"robots.txt\":\n\t\t\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.RobotsTxt\"}}, cn)\n\t\t\t\t\treturn routes.RobotsTxt(w,req)\n\t\t\t\tcase \"favicon.ico\":\n\t\t\t\t\tw = r.responseWriter(w)\n\t\t\t\t\treq.URL.Path = \"/s/favicon.ico\"\n\t\t\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.Favicon\"}}, cn)\n\t\t\t\t\troutes.StaticFile(w,req)\n\t\t\t\t\treturn nil\n\t\t\t\tcase \"opensearch.xml\":\n\t\t\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.OpenSearchXml\"}}, cn)\n\t\t\t\t\treturn routes.OpenSearchXml(w,req)\n\t\t\t\t/*case \"sitemap.xml\":\n\t\t\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.SitemapXml\"}}, cn)\n\t\t\t\t\treturn routes.SitemapXml(w,req)*/\n\t\t\t}\n\t\t\tco.RouteViewCounter.Bump({{index .AllRouteMap \"routes.Error\"}})\n\t\t\treturn c.NotFound(w,req,nil)\n\t\tdefault:\n\t\t\t// A fallback for dynamic routes, e.g. ones declared by plugins\n\t\t\tr.RLock()\n\t\t\th, ok := r.extraRoutes[req.URL.Path]\n\t\t\tr.RUnlock()\n\t\t\treq.URL.Path += extraData\n\t\t\t\n\t\t\tif ok {\n\t\t\t\t// TODO: Be more specific about *which* dynamic route it is\n\t\t\t\tco.RouteViewCounter.Bump({{index .AllRouteMap \"routes.DynamicRoute\"}})\n\t\t\t\treturn h(w,req,user)\n\t\t\t}\n\t\t\tco.RouteViewCounter.Bump3({{index .AllRouteMap \"routes.BadRoute\"}}, cn)\n\n\t\t\tif !c.Config.DisableSuspLog {\n\t\t\tlp := strings.ToLower(req.URL.Path)\n\t\t\tif strings.Contains(lp,\"w\") {\n\t\t\t\tif strings.Contains(lp,\"wp\") || strings.Contains(lp,\"wordpress\") || strings.Contains(lp,\"wget\") || strings.Contains(lp,\"wp-\") {\n\t\t\t\t\tr.SuspiciousRequest(req,\"Bad Route\")\n\t\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif strings.Contains(lp,\"admin\") || strings.Contains(lp,\"sql\") || strings.Contains(lp,\"manage\") || strings.Contains(lp,\"//\") || strings.Contains(lp,\"\\\\\\\\\") || strings.Contains(lp,\"config\") || strings.Contains(lp,\"setup\") || strings.Contains(lp,\"install\") || strings.Contains(lp,\"update\") || strings.Contains(lp,\"php\") || strings.Contains(lp,\"pl\") || strings.Contains(lp,\"include\") || strings.Contains(lp,\"vendor\") || strings.Contains(lp,\"bin\") || strings.Contains(lp,\"system\") || strings.Contains(lp,\"eval\") || strings.Contains(lp,\"config\") {\n\t\t\t\tr.SuspiciousRequest(req,\"Bad Route\")\n\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t}\n\t\t\t}\n\n\t\t\tif !c.Config.DisableBadRouteLog {\n\t\t\t\tr.DumpRequest(req,\"Bad Route\")\n\t\t\t}\n\t\t\tae := req.Header.Get(\"Accept-Encoding\")\n\t\t\tlikelyBot := ae == \"gzip\" || ae == \"\"\n\t\t\tif likelyBot {\n\t\t\t\treturn c.MicroNotFound(w,req)\n\t\t\t}\n\t\t\treturn c.NotFound(w,req,nil)\n\t}\n\treturn err\n}\n`\n\ttmpl := template.Must(template.New(\"router\").Parse(fileData))\n\tvar b bytes.Buffer\n\tif err := tmpl.Execute(&b, tmplVars); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\twriteFile(\"./gen_router.go\", b.String())\n\tlog.Println(\"Successfully generated the router\")\n}\n\nfunc writeFile(name, content string) {\n\tf, e := os.Create(name)\n\tif e != nil {\n\t\tlog.Fatal(e)\n\t}\n\t_, e = f.WriteString(content)\n\tif e != nil {\n\t\tlog.Fatal(e)\n\t}\n\tif e = f.Sync(); e != nil {\n\t\tlog.Fatal(e)\n\t}\n\tif e = f.Close(); e != nil {\n\t\tlog.Fatal(e)\n\t}\n}\n"
  },
  {
    "path": "router_gen/misc_test.go",
    "content": "package main\n\nimport (\n\t\"runtime/debug\"\n\t\"testing\"\n)\n\nfunc exp(t *testing.T) func(bool, string) {\n\treturn func(val bool, msg string) {\n\t\tif !val {\n\t\t\tdebug.PrintStack()\n\t\t\tt.Error(msg)\n\t\t}\n\t}\n}\n\nfunc expf(t *testing.T) func(bool, string, ...interface{}) {\n\treturn func(val bool, msg string, params ...interface{}) {\n\t\tif !val {\n\t\t\tdebug.PrintStack()\n\t\t\tt.Errorf(msg, params...)\n\t\t}\n\t}\n}\n\nfunc TestPerc(t *testing.T) {\n\tex, _, prec := exp(t), expf(t), NewPrec()\n\tex(!prec.GreaterThan(\"MemberOnly\", \"AdminOnly\"), \"MemberOnly should not be greater then AdminOnly\")\n\tex(!prec.GreaterThan(\"AdminOnly\", \"MemberOnly\"), \"MemberOnly should not be greater then AdminOnly\")\n\tex(!prec.GreaterThan(\"NotInSet\", \"AdminOnly\"), \"NotInSet should not be greater then AdminOnly\")\n\tex(!prec.GreaterThan(\"AdminOnly\", \"NotInSet\"), \"AdminOnly should not be greater then NotInSet\")\n\tex(!prec.InAnySet(\"MemberOnly\"), \"MemberOnly should not be in any set\")\n\tex(!prec.InSameSet(\"MemberOnly\", \"AdminOnly\"), \"MemberOnly and AdminOnly should not be in the same set\")\n\tex(!prec.InSameSet(\"MemberOnly\", \"NotInSet\"), \"MemberOnly and NotInSet should not be in the same set\")\n\n\tprec.AddSet(\"MemberOnly\", \"SuperModOnly\", \"AdminOnly\", \"SuperAdminOnly\")\n\tex(!prec.GreaterThan(\"MemberOnly\", \"AdminOnly\"), \"MemberOnly should not be greater then AdminOnly\")\n\tex(prec.GreaterThan(\"AdminOnly\", \"MemberOnly\"), \"AdminOnly should be greater then MemberOnly\")\n\tex(!prec.GreaterThan(\"NotInSet\", \"AdminOnly\"), \"NotInSet should not be greater then AdminOnly\")\n\tex(!prec.GreaterThan(\"AdminOnly\", \"NotInSet\"), \"AdminOnly should not be greater then NotInSet\")\n\tex(prec.InAnySet(\"MemberOnly\"), \"MemberOnly should be in a set\")\n\tex(!prec.InAnySet(\"NotInSet\"), \"NotInSet should not be in any set\")\n\tex(prec.InSameSet(\"MemberOnly\", \"AdminOnly\"), \"MemberOnly and AdminOnly should be in the same set\")\n\tex(!prec.InSameSet(\"MemberOnly\", \"NotInSet\"), \"MemberOnly and NotInSet should not be in the same set\")\n\n\titems := prec.LessThanItem(\"AdminOnly\")\n\tex(len(items) > 0, \"There should be items which are of a lower precedence than AdminOnly\")\n\timap := make(map[string]bool)\n\tfor _, item := range items {\n\t\timap[item] = true\n\t}\n\tmex := func(n string, val bool, msg string) {\n\t\t_, ok := imap[n]\n\t\tex(ok == val, msg)\n\t}\n\tmex(\"SuperModOnly\", true, \"SuperModOnly should be returned in a list of lower precedence items than AdminOnly\")\n\tmex(\"MemberOnly\", true, \"MemberOnly should be returned in a list of lower precedence items than AdminOnly\")\n\tmex(\"SuperAdminOnly\", false, \"SuperAdminOnly should not be returned in a list of lower precedence items than AdminOnly\")\n\tmex(\"NotInSet\", false, \"NotInSet should not be returned in a list of lower precedence items than AdminOnly\")\n}\n"
  },
  {
    "path": "router_gen/prec.go",
    "content": "package main\n\ntype Prec struct {\n\tSets      []map[string]int\n\tNameToSet map[string]int\n}\n\nfunc NewPrec() *Prec {\n\treturn &Prec{NameToSet: make(map[string]int)}\n}\n\nfunc (p *Prec) AddSet(precs ...string) {\n\tset := make(map[string]int)\n\tsetIndex, i := len(p.Sets), 0\n\tfor _, prec := range precs {\n\t\tset[prec] = i\n\t\tp.NameToSet[prec] = setIndex\n\t\ti++\n\t}\n\tp.Sets = append(p.Sets, set)\n}\n\nfunc (p *Prec) InAnySet(name string) bool {\n\t_, ok := p.NameToSet[name]\n\treturn ok\n}\n\nfunc (p *Prec) InSameSet(n, n2 string) bool {\n\tok, ok2 := p.InAnySet(n), p.InAnySet(n2)\n\tif !ok || !ok2 {\n\t\treturn false\n\t}\n\tset1, set2 := p.NameToSet[n], p.NameToSet[n2]\n\treturn set1 == set2\n}\n\nfunc (p *Prec) GreaterThan(greater, lesser string) bool {\n\tif !p.InSameSet(greater, lesser) {\n\t\treturn false\n\t}\n\tset := p.Sets[p.NameToSet[greater]]\n\treturn set[greater] > set[lesser]\n}\n\nfunc (p *Prec) LessThanItem(greater string) (l []string) {\n\tif len(p.Sets) == 0 {\n\t\treturn nil\n\t}\n\n\tsetIndex := p.NameToSet[greater]\n\tset := p.Sets[setIndex]\n\tref := set[greater]\n\tfor name, value := range set {\n\t\tif value < ref {\n\t\t\tl = append(l, name)\n\t\t}\n\t}\n\n\treturn l\n}\n"
  },
  {
    "path": "router_gen/route_group.go",
    "content": "package main\n\nimport \"strings\"\n\ntype RouteGroup struct {\n\tPath      string\n\tRouteList []*RouteImpl\n\tRunBefore []Runnable\n\n\tNoHead bool\n}\n\nfunc newRouteGroup(path string, routes ...*RouteImpl) *RouteGroup {\n\tg := &RouteGroup{Path: path}\n\tfor _, route := range routes {\n\t\troute.Parent = g\n\t\tg.RouteList = append(g.RouteList, route)\n\t}\n\treturn g\n}\n\nfunc (g *RouteGroup) Not(path ...string) *RouteSubset {\n\troutes := make([]*RouteImpl, len(g.RouteList))\n\tcopy(routes, g.RouteList)\n\tfor i, route := range routes {\n\t\tif inStringList(route.Path, path) {\n\t\t\troutes = append(routes[:i], routes[i+1:]...)\n\t\t}\n\t}\n\treturn &RouteSubset{routes}\n}\n\nfunc inStringList(needle string, list []string) bool {\n\tfor _, item := range list {\n\t\tif item == needle {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (g *RouteGroup) NoHeader() *RouteGroup {\n\tg.NoHead = true\n\treturn g\n}\n\nfunc (g *RouteGroup) Before(lines ...string) *RouteGroup {\n\tfor _, line := range lines {\n\t\tg.RunBefore = append(g.RunBefore, Runnable{line, false})\n\t}\n\treturn g\n}\n\nfunc (g *RouteGroup) LitBefore(lines ...string) *RouteGroup {\n\tfor _, line := range lines {\n\t\tg.RunBefore = append(g.RunBefore, Runnable{line, true})\n\t}\n\treturn g\n}\n\n/*func (g *RouteGroup) Routes(routes ...*RouteImpl) *RouteGroup {\n\tfor _, route := range routes {\n\t\troute.Parent = g\n\t\tg.RouteList = append(g.RouteList, route)\n\t}\n\treturn g\n}*/\n\nfunc (g *RouteGroup) Routes(routes ...interface{}) *RouteGroup {\n\tfor _, route := range routes {\n\t\tswitch r := route.(type) {\n\t\tcase *RouteImpl:\n\t\t\tr.Parent = g\n\t\t\tg.RouteList = append(g.RouteList, r)\n\t\tcase RouteSet:\n\t\t\tfor _, rr := range r.Items {\n\t\t\t\trr.Name = r.Name + rr.Name\n\t\t\t\trr.Path = strings.TrimSuffix(r.Path, \"/\") + \"/\" + strings.TrimPrefix(rr.Path, \"/\")\n\t\t\t\trr.Parent = g\n\t\t\t\tg.RouteList = append(g.RouteList, rr)\n\t\t\t}\n\t\t}\n\t}\n\treturn g\n}\n"
  },
  {
    "path": "router_gen/route_impl.go",
    "content": "package main\n\nimport \"strings\"\n\ntype RouteImpl struct {\n\tName      string\n\tPath      string\n\tAction    bool\n\tNoHead    bool\n\tVars      []string\n\tRunBefore []Runnable\n\n\tParent *RouteGroup\n}\n\ntype Runnable struct {\n\tContents string\n\tLiteral  bool\n}\n\nfunc (r *RouteImpl) Before(items ...string) *RouteImpl {\n\tfor _, item := range items {\n\t\tr.RunBefore = append(r.RunBefore, Runnable{item, false})\n\t}\n\treturn r\n}\n\nfunc (r *RouteImpl) LitBefore(items ...string) *RouteImpl {\n\tfor _, item := range items {\n\t\tr.RunBefore = append(r.RunBefore, Runnable{item, true})\n\t}\n\treturn r\n}\n\nfunc (r *RouteImpl) LitBeforeMultiline(items ...string) *RouteImpl {\n\tfor _, item := range items {\n\t\tfor _, line := range strings.Split(item, \"\\n\") {\n\t\t\tr.LitBefore(strings.TrimSpace(line))\n\t\t}\n\t}\n\treturn r\n}\n\nfunc (r *RouteImpl) hasBefore(items ...string) bool {\n\tfor _, item := range items {\n\t\tif r.hasBeforeItem(item) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (r *RouteImpl) hasBeforeItem(item string) bool {\n\tfor _, before := range r.RunBefore {\n\t\tif before.Contents == item {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (r *RouteImpl) NoGzip() *RouteImpl {\n\treturn r.LitBefore(\"w = r.responseWriter(w)\")\n}\n\nfunc (r *RouteImpl) NoHeader() *RouteImpl {\n\tr.NoHead = true\n\treturn r\n}\n\nfunc blankRoute() *RouteImpl {\n\treturn &RouteImpl{\"\", \"\", false, false, []string{}, []Runnable{}, nil}\n}\n\nfunc route(fname, path string, action, special bool, args ...string) *RouteImpl {\n\treturn &RouteImpl{fname, path, action, special, args, []Runnable{}, nil}\n}\n\nfunc View(fname, path string, args ...string) *RouteImpl {\n\treturn route(fname, path, false, false, args...)\n}\n\nfunc MView(fname, path string, args ...string) *RouteImpl {\n\troute := route(fname, path, false, false, args...)\n\tif !route.hasBefore(\"SuperModOnly\", \"AdminOnly\") {\n\t\troute.Before(\"MemberOnly\")\n\t}\n\treturn route\n}\n\nfunc MemberView(fname, path string, args ...string) *RouteImpl {\n\troute := route(fname, path, false, false, args...)\n\tif !route.hasBefore(\"SuperModOnly\", \"AdminOnly\") {\n\t\troute.Before(\"MemberOnly\")\n\t}\n\treturn route\n}\n\nfunc ModView(fname, path string, args ...string) *RouteImpl {\n\troute := route(fname, path, false, false, args...)\n\tif !route.hasBefore(\"AdminOnly\") {\n\t\troute.Before(\"SuperModOnly\")\n\t}\n\treturn route\n}\n\nfunc Action(fname, path string, args ...string) *RouteImpl {\n\troute := route(fname, path, true, false, args...)\n\troute.Before(\"NoSessionMismatch\")\n\tif !route.hasBefore(\"SuperModOnly\", \"AdminOnly\") {\n\t\troute.Before(\"MemberOnly\")\n\t}\n\treturn route\n}\n\nfunc AnonAction(fname, path string, args ...string) *RouteImpl {\n\treturn route(fname, path, true, false, args...).Before(\"ParseForm\")\n}\n\nfunc Special(fname, path string, args ...string) *RouteImpl {\n\treturn route(fname, path, false, true, args...).LitBefore(\"req.URL.Path += extraData\")\n}\n\n// Make this it's own type to force the user to manipulate methods on it to set parameters\ntype uploadAction struct {\n\tRoute *RouteImpl\n}\n\nfunc UploadAction(fname, path string, args ...string) *uploadAction {\n\troute := route(fname, path, true, false, args...)\n\tif !route.hasBefore(\"SuperModOnly\", \"AdminOnly\") {\n\t\troute.Before(\"MemberOnly\")\n\t}\n\treturn &uploadAction{route}\n}\n\nfunc (a *uploadAction) MaxSizeVar(varName string) *RouteImpl {\n\ta.Route.LitBeforeMultiline(`err = c.HandleUploadRoute(w,req,user,` + varName + `)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}`)\n\ta.Route.Before(\"NoUploadSessionMismatch\")\n\treturn a.Route\n}\n\ntype RouteSet struct {\n\tName  string\n\tPath  string\n\tItems []*RouteImpl\n}\n\nfunc Set(name, path string, routes ...*RouteImpl) RouteSet {\n\treturn RouteSet{name, path, routes}\n}\n"
  },
  {
    "path": "router_gen/route_subset.go",
    "content": "package main\n\ntype RouteSubset struct {\n\tRouteList []*RouteImpl\n}\n\nfunc (set *RouteSubset) Before(lines ...string) *RouteSubset {\n\tfor _, line := range lines {\n\t\tfor _, r := range set.RouteList {\n\t\t\tr.RunBefore = append(r.RunBefore, Runnable{line, false})\n\t\t}\n\t}\n\treturn set\n}\n\nfunc (set *RouteSubset) LitBefore(lines ...string) *RouteSubset {\n\tfor _, line := range lines {\n\t\tfor _, r := range set.RouteList {\n\t\t\tr.RunBefore = append(r.RunBefore, Runnable{line, true})\n\t\t}\n\t}\n\treturn set\n}\n\nfunc (set *RouteSubset) Not(path ...string) *RouteSubset {\n\tfor i, route := range set.RouteList {\n\t\tif inStringList(route.Path, path) {\n\t\t\tset.RouteList = append(set.RouteList[:i], set.RouteList[i+1:]...)\n\t\t}\n\t}\n\treturn set\n}\n"
  },
  {
    "path": "router_gen/router.go",
    "content": "package main\n\ntype Router struct {\n\trouteList   []*RouteImpl\n\trouteGroups []*RouteGroup\n}\n\nfunc (r *Router) Add(route ...*RouteImpl) {\n\tr.routeList = append(r.routeList, route...)\n}\n\nfunc (r *Router) AddGroup(routeGroup ...*RouteGroup) {\n\tr.routeGroups = append(r.routeGroups, routeGroup...)\n}"
  },
  {
    "path": "router_gen/routes.go",
    "content": "package main\n\n// TODO: How should we handle *HeaderLite and *Header?\nfunc routes(r *Router) {\n\tr.Add(View(\"routes.Overview\", \"/overview/\"))\n\tr.Add(View(\"routes.CustomPage\", \"/pages/\", \"extraData\"))\n\tr.Add(View(\"routes.ForumList\", \"/forums/\" /*,\"&forums\"*/))\n\tr.Add(View(\"routes.ViewForum\", \"/forum/\", \"extraData\"))\n\tr.Add(AnonAction(\"routes.ChangeTheme\", \"/theme/\"))\n\tr.Add(\n\t\tView(\"routes.ShowAttachment\", \"/attachs/\", \"extraData\").Before(\"ParseForm\").NoGzip().NoHeader(),\n\t)\n\n\tapiGroup := newRouteGroup(\"/api/\",\n\t\tView(\"routeAPI\", \"/api/\"),\n\t\tView(\"routeAPIPhrases\", \"/api/phrases/\"), // TODO: Be careful with exposing the panel phrases here\n\t\tView(\"routes.APIMe\", \"/api/me/\"),\n\t\tView(\"routeJSAntispam\", \"/api/watches/\"),\n\t).NoHeader()\n\tr.AddGroup(apiGroup)\n\n\t// TODO: Reduce the number of Befores. With a new method, perhaps?\n\treportGroup := newRouteGroup(\"/report/\",\n\t\tAction(\"routes.ReportSubmit\", \"/report/submit/\", \"extraData\"),\n\t).Before(\"NoBanned\")\n\tr.AddGroup(reportGroup)\n\n\ttopicGroup := newRouteGroup(\"/topics/\",\n\t\tView(\"routes.TopicList\", \"/topics/\"),\n\t\tView(\"routes.TopicListMostViewed\", \"/topics/most-viewed/\"),\n\t\tView(\"routes.TopicListWeekViews\", \"/topics/week-views/\"),\n\t\tMView(\"routes.CreateTopic\", \"/topics/create/\", \"extraData\"),\n\t)\n\tr.AddGroup(topicGroup)\n\n\tr.AddGroup(panelRoutes())\n\tr.AddGroup(userRoutes())\n\tr.AddGroup(usersRoutes())\n\tr.AddGroup(topicRoutes())\n\tr.AddGroup(replyRoutes())\n\tr.AddGroup(profileReplyRoutes())\n\tr.AddGroup(pollRoutes())\n\tr.AddGroup(accountRoutes())\n\n\tr.Add(Special(\"common.RouteWebsockets\", \"/ws/\"))\n}\n\n// TODO: Test the email token route\nfunc userRoutes() *RouteGroup {\n\treturn newRouteGroup(\"/user/\").Routes(\n\t\tView(\"routes.ViewProfile\", \"/user/\").LitBefore(\"req.URL.Path += extraData\"),\n\n\t\tSet(\"routes.AccountEdit\", \"/user/edit/\",\n\t\t\tMView(\"\", \"/\"),\n\t\t\tMView(\"Password\", \"/password/\"),\n\t\t\tAction(\"PasswordSubmit\", \"/password/submit/\"), // TODO: Full test this\n\t\t\tUploadAction(\"AvatarSubmit\", \"/avatar/submit/\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\t\tAction(\"RevokeAvatarSubmit\", \"/avatar/revoke/submit/\"),\n\t\t\tAction(\"UsernameSubmit\", \"/username/submit/\"), // TODO: Full test this\n\t\t\tMView(\"Privacy\", \"/privacy/\"),\n\t\t\tAction(\"PrivacySubmit\", \"/privacy/submit/\"),\n\t\t\tMView(\"MFA\", \"/mfa/\"),\n\t\t\tMView(\"MFASetup\", \"/mfa/setup/\"),\n\t\t\tAction(\"MFASetupSubmit\", \"/mfa/setup/submit/\"),\n\t\t\tAction(\"MFADisableSubmit\", \"/mfa/disable/submit/\"),\n\t\t\tMView(\"Email\", \"/email/\"),\n\t\t\tView(\"EmailTokenSubmit\", \"/token/\", \"extraData\").NoHeader(),\n\t\t\t//Action(\"EmailAddSubmit\", \"/user/edit/email/add/submit/\"),\n\t\t\t//Action(\"EmailRemoveSubmit\", \"/user/edit/email/remove/submit/\"),\n\t\t),\n\n\t\t/*MView(\"routes.AccountEdit\", \"/user/edit/\"),\n\t\tMView(\"routes.AccountEditPassword\", \"/user/edit/password/\"),\n\t\tAction(\"routes.AccountEditPasswordSubmit\", \"/user/edit/password/submit/\"), // TODO: Full test this\n\t\tUploadAction(\"routes.AccountEditAvatarSubmit\", \"/user/edit/avatar/submit/\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\tAction(\"routes.AccountEditRevokeAvatarSubmit\", \"/user/edit/avatar/revoke/submit/\"),\n\t\tAction(\"routes.AccountEditUsernameSubmit\", \"/user/edit/username/submit/\"), // TODO: Full test this\n\t\tMView(\"routes.AccountEditMFA\", \"/user/edit/mfa/\"),\n\t\tMView(\"routes.AccountEditMFASetup\", \"/user/edit/mfa/setup/\"),\n\t\tAction(\"routes.AccountEditMFASetupSubmit\", \"/user/edit/mfa/setup/submit/\"),\n\t\tAction(\"routes.AccountEditMFADisableSubmit\", \"/user/edit/mfa/disable/submit/\"),\n\t\tMView(\"routes.AccountEditEmail\", \"/user/edit/email/\"),\n\t\tView(\"routes.AccountEditEmailTokenSubmit\", \"/user/edit/token/\", \"extraData\").NoHeader(),*/\n\n\t\tMView(\"routes.AccountLogins\", \"/user/edit/logins/\"),\n\t\tMView(\"routes.AccountBlocked\", \"/user/edit/blocked/\"),\n\n\t\tMView(\"routes.LevelList\", \"/user/levels/\"),\n\t\t//MView(\"routes.LevelRankings\", \"/user/rankings/\"),\n\t\t//MView(\"routes.Alerts\", \"/user/alerts/\"),\n\n\t\tMView(\"routes.Convos\", \"/user/convos/\"),\n\t\tMView(\"routes.ConvosCreate\", \"/user/convos/create/\"),\n\t\tMView(\"routes.Convo\", \"/user/convo/\", \"extraData\"),\n\t\tAction(\"routes.ConvosCreateSubmit\", \"/user/convos/create/submit/\"),\n\t\t//Action(\"routes.ConvosDeleteSubmit\", \"/user/convos/delete/submit/\", \"extraData\"),\n\t\tAction(\"routes.ConvosCreateReplySubmit\", \"/user/convo/create/submit/\", \"extraData\"),\n\t\tAction(\"routes.ConvosDeleteReplySubmit\", \"/user/convo/delete/submit/\", \"extraData\"),\n\t\tAction(\"routes.ConvosEditReplySubmit\", \"/user/convo/edit/submit/\", \"extraData\"),\n\n\t\tMView(\"routes.RelationsBlockCreate\", \"/user/block/create/\", \"extraData\"),\n\t\tAction(\"routes.RelationsBlockCreateSubmit\", \"/user/block/create/submit/\", \"extraData\"),\n\t\tMView(\"routes.RelationsBlockRemove\", \"/user/block/remove/\", \"extraData\"),\n\t\tAction(\"routes.RelationsBlockRemoveSubmit\", \"/user/block/remove/submit/\", \"extraData\"),\n\t)\n}\n\nfunc usersRoutes() *RouteGroup {\n\t// TODO: Auto test and manual test these routes\n\treturn newRouteGroup(\"/users/\").Routes(\n\t\tAction(\"routes.BanUserSubmit\", \"/users/ban/submit/\", \"extraData\"),\n\t\tAction(\"routes.UnbanUser\", \"/users/unban/\", \"extraData\"),\n\t\tAction(\"routes.ActivateUser\", \"/users/activate/\", \"extraData\"),\n\t\tMView(\"routes.IPSearch\", \"/users/ips/\"), // TODO: .Perms(\"ViewIPs\")?\n\t\tAction(\"routes.DeletePostsSubmit\", \"/users/delete-posts/submit/\", \"extraData\"),\n\t)\n}\n\nfunc topicRoutes() *RouteGroup {\n\treturn newRouteGroup(\"/topic/\").Routes(\n\t\tView(\"routes.ViewTopic\", \"/topic/\", \"extraData\"),\n\t\tUploadAction(\"routes.CreateTopicSubmit\", \"/topic/create/submit/\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\tAction(\"routes.EditTopicSubmit\", \"/topic/edit/submit/\", \"extraData\"),\n\t\tAction(\"routes.DeleteTopicSubmit\", \"/topic/delete/submit/\").LitBefore(\"req.URL.Path += extraData\"),\n\t\tAction(\"routes.StickTopicSubmit\", \"/topic/stick/submit/\", \"extraData\"),\n\t\tAction(\"routes.UnstickTopicSubmit\", \"/topic/unstick/submit/\", \"extraData\"),\n\t\tAction(\"routes.LockTopicSubmit\", \"/topic/lock/submit/\").LitBefore(\"req.URL.Path += extraData\"),\n\t\tAction(\"routes.UnlockTopicSubmit\", \"/topic/unlock/submit/\", \"extraData\"),\n\t\tAction(\"routes.MoveTopicSubmit\", \"/topic/move/submit/\", \"extraData\"),\n\t\tAction(\"routes.LikeTopicSubmit\", \"/topic/like/submit/\", \"extraData\"),\n\t\tAction(\"routes.UnlikeTopicSubmit\", \"/topic/unlike/submit/\", \"extraData\"),\n\t\tUploadAction(\"routes.AddAttachToTopicSubmit\", \"/topic/attach/add/submit/\", \"extraData\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\tAction(\"routes.RemoveAttachFromTopicSubmit\", \"/topic/attach/remove/submit/\", \"extraData\"),\n\t)\n}\n\nfunc replyRoutes() *RouteGroup {\n\treturn newRouteGroup(\"/reply/\").Routes(\n\t\t// TODO: Reduce this to 1MB for attachments for each file?\n\t\tUploadAction(\"routes.CreateReplySubmit\", \"/reply/create/\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"), // TODO: Rename the route so it's /reply/create/submit/\n\t\tAction(\"routes.ReplyEditSubmit\", \"/reply/edit/submit/\", \"extraData\"),\n\t\tAction(\"routes.ReplyDeleteSubmit\", \"/reply/delete/submit/\", \"extraData\"),\n\t\tAction(\"routes.ReplyLikeSubmit\", \"/reply/like/submit/\", \"extraData\"),\n\t\tAction(\"routes.ReplyUnlikeSubmit\", \"/reply/unlike/submit/\", \"extraData\"),\n\t\t//MemberView(\"routes.ReplyEdit\",\"/reply/edit/\",\"extraData\"), // No js fallback\n\t\t//MemberView(\"routes.ReplyDelete\",\"/reply/delete/\",\"extraData\"), // No js confirmation page? We could have a confirmation modal for the JS case\n\t\tUploadAction(\"routes.AddAttachToReplySubmit\", \"/reply/attach/add/submit/\", \"extraData\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\tAction(\"routes.RemoveAttachFromReplySubmit\", \"/reply/attach/remove/submit/\", \"extraData\"),\n\t)\n}\n\n// TODO: Move these into /user/?\nfunc profileReplyRoutes() *RouteGroup {\n\treturn newRouteGroup(\"/profile/\").Routes(\n\t\tAction(\"routes.ProfileReplyCreateSubmit\", \"/profile/reply/create/\"), // TODO: Add /submit/ to the end\n\t\tAction(\"routes.ProfileReplyEditSubmit\", \"/profile/reply/edit/submit/\", \"extraData\"),\n\t\tAction(\"routes.ProfileReplyDeleteSubmit\", \"/profile/reply/delete/submit/\", \"extraData\"),\n\t)\n}\n\nfunc pollRoutes() *RouteGroup {\n\treturn newRouteGroup(\"/poll/\").Routes(\n\t\tAction(\"routes.PollVote\", \"/poll/vote/\", \"extraData\"),\n\t\tView(\"routes.PollResults\", \"/poll/results/\", \"extraData\").NoHeader(),\n\t)\n}\n\nfunc accountRoutes() *RouteGroup {\n\t//router.HandleFunc(\"/accounts/list/\", routeLogin) // Redirect /accounts/ and /user/ to here.. // Get a list of all of the accounts on the forum\n\treturn newRouteGroup(\"/accounts/\").Routes(\n\t\tView(\"routes.AccountLogin\", \"/accounts/login/\"),\n\t\tView(\"routes.AccountRegister\", \"/accounts/create/\"),\n\t\tAction(\"routes.AccountLogout\", \"/accounts/logout/\"),\n\t\tAnonAction(\"routes.AccountLoginSubmit\", \"/accounts/login/submit/\"), // TODO: Guard this with a token, maybe the IP hashed with a rotated key?\n\t\tView(\"routes.AccountLoginMFAVerify\", \"/accounts/mfa_verify/\"),\n\t\tAnonAction(\"routes.AccountLoginMFAVerifySubmit\", \"/accounts/mfa_verify/submit/\"), // We have logic in here which filters out regular guests\n\t\tAnonAction(\"routes.AccountRegisterSubmit\", \"/accounts/create/submit/\"),\n\n\t\tView(\"routes.AccountPasswordReset\", \"/accounts/password-reset/\"),\n\t\tAnonAction(\"routes.AccountPasswordResetSubmit\", \"/accounts/password-reset/submit/\"),\n\t\tView(\"routes.AccountPasswordResetToken\", \"/accounts/password-reset/token/\"),\n\t\tAnonAction(\"routes.AccountPasswordResetTokenSubmit\", \"/accounts/password-reset/token/submit/\"),\n\t)\n}\n\nfunc panelRoutes() *RouteGroup {\n\t// TODO: Implement subgroups\n\treturn newRouteGroup(\"/panel/\").Before(\"SuperModOnly\").NoHeader().Routes(\n\t\tView(\"panel.Dashboard\", \"/panel/\"),\n\t\t//View(\"panel.StatsDisk\", \"/panel/stats/disk/\"),\n\n\t\tView(\"panel.Forums\", \"/panel/forums/\"),\n\t\tAction(\"panel.ForumsCreateSubmit\", \"/panel/forums/create/\"),\n\t\tAction(\"panel.ForumsDelete\", \"/panel/forums/delete/\", \"extraData\"),\n\t\tAction(\"panel.ForumsDeleteSubmit\", \"/panel/forums/delete/submit/\", \"extraData\"),\n\t\tAction(\"panel.ForumsOrderSubmit\", \"/panel/forums/order/edit/submit/\"),\n\t\tView(\"panel.ForumsEdit\", \"/panel/forums/edit/\", \"extraData\"),\n\t\tAction(\"panel.ForumsEditSubmit\", \"/panel/forums/edit/submit/\", \"extraData\"),\n\t\tAction(\"panel.ForumsEditPermsSubmit\", \"/panel/forums/edit/perms/submit/\", \"extraData\"),\n\t\tView(\"panel.ForumsEditPermsAdvance\", \"/panel/forums/edit/perms/\", \"extraData\"),\n\t\tAction(\"panel.ForumsEditPermsAdvanceSubmit\", \"/panel/forums/edit/perms/adv/submit/\", \"extraData\"),\n\t\tAction(\"panel.ForumsEditActionCreateSubmit\", \"/panel/forums/action/create/submit/\", \"extraData\"),\n\t\tAction(\"panel.ForumsEditActionDeleteSubmit\", \"/panel/forums/action/delete/submit/\", \"extraData\"),\n\n\t\tView(\"panel.Settings\", \"/panel/settings/\"),\n\t\tView(\"panel.SettingEdit\", \"/panel/settings/edit/\", \"extraData\"),\n\t\tAction(\"panel.SettingEditSubmit\", \"/panel/settings/edit/submit/\", \"extraData\"),\n\n\t\tView(\"panel.WordFilters\", \"/panel/settings/word-filters/\"),\n\t\tAction(\"panel.WordFiltersCreateSubmit\", \"/panel/settings/word-filters/create/\"),\n\t\tView(\"panel.WordFiltersEdit\", \"/panel/settings/word-filters/edit/\", \"extraData\"),\n\t\tAction(\"panel.WordFiltersEditSubmit\", \"/panel/settings/word-filters/edit/submit/\", \"extraData\"),\n\t\tAction(\"panel.WordFiltersDeleteSubmit\", \"/panel/settings/word-filters/delete/submit/\", \"extraData\"),\n\n\t\tView(\"panel.Pages\", \"/panel/pages/\").Before(\"AdminOnly\"),\n\t\tAction(\"panel.PagesCreateSubmit\", \"/panel/pages/create/submit/\").Before(\"AdminOnly\"),\n\t\tView(\"panel.PagesEdit\", \"/panel/pages/edit/\", \"extraData\").Before(\"AdminOnly\"),\n\t\tAction(\"panel.PagesEditSubmit\", \"/panel/pages/edit/submit/\", \"extraData\").Before(\"AdminOnly\"),\n\t\tAction(\"panel.PagesDeleteSubmit\", \"/panel/pages/delete/submit/\", \"extraData\").Before(\"AdminOnly\"),\n\n\t\tView(\"panel.Themes\", \"/panel/themes/\"),\n\t\tAction(\"panel.ThemesSetDefault\", \"/panel/themes/default/\", \"extraData\"),\n\t\tView(\"panel.ThemesMenus\", \"/panel/themes/menus/\"),\n\t\tView(\"panel.ThemesMenusEdit\", \"/panel/themes/menus/edit/\", \"extraData\"),\n\t\tView(\"panel.ThemesMenuItemEdit\", \"/panel/themes/menus/item/edit/\", \"extraData\"),\n\t\tAction(\"panel.ThemesMenuItemEditSubmit\", \"/panel/themes/menus/item/edit/submit/\", \"extraData\"),\n\t\tAction(\"panel.ThemesMenuItemCreateSubmit\", \"/panel/themes/menus/item/create/submit/\"),\n\t\tAction(\"panel.ThemesMenuItemDeleteSubmit\", \"/panel/themes/menus/item/delete/submit/\", \"extraData\"),\n\t\tAction(\"panel.ThemesMenuItemOrderSubmit\", \"/panel/themes/menus/item/order/edit/submit/\", \"extraData\"),\n\n\t\tView(\"panel.ThemesWidgets\", \"/panel/themes/widgets/\"),\n\t\t//View(\"panel.ThemesWidgetsEdit\", \"/panel/themes/widgets/edit/\", \"extraData\"),\n\t\tAction(\"panel.ThemesWidgetsEditSubmit\", \"/panel/themes/widgets/edit/submit/\", \"extraData\"),\n\t\tAction(\"panel.ThemesWidgetsCreateSubmit\", \"/panel/themes/widgets/create/submit/\"),\n\t\tAction(\"panel.ThemesWidgetsDeleteSubmit\", \"/panel/themes/widgets/delete/submit/\", \"extraData\"),\n\n\t\tView(\"panel.Plugins\", \"/panel/plugins/\"),\n\t\tAction(\"panel.PluginsActivate\", \"/panel/plugins/activate/\", \"extraData\"),\n\t\tAction(\"panel.PluginsDeactivate\", \"/panel/plugins/deactivate/\", \"extraData\"),\n\t\tAction(\"panel.PluginsInstall\", \"/panel/plugins/install/\", \"extraData\"),\n\n\t\tView(\"panel.Users\", \"/panel/users/\"),\n\t\tView(\"panel.UsersEdit\", \"/panel/users/edit/\", \"extraData\"),\n\t\tAction(\"panel.UsersEditSubmit\", \"/panel/users/edit/submit/\", \"extraData\"),\n\t\tUploadAction(\"panel.UsersAvatarSubmit\", \"/panel/users/avatar/submit/\", \"extraData\").MaxSizeVar(\"int(c.Config.MaxRequestSize)\"),\n\t\tAction(\"panel.UsersAvatarRemoveSubmit\", \"/panel/users/avatar/remove/submit/\", \"extraData\"),\n\n\t\tView(\"panel.AnalyticsViews\", \"/panel/analytics/views/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsRoutes\", \"/panel/analytics/routes/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsRoutesPerf\", \"/panel/analytics/routes-perf/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsAgents\", \"/panel/analytics/agents/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsSystems\", \"/panel/analytics/systems/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsLanguages\", \"/panel/analytics/langs/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsReferrers\", \"/panel/analytics/referrers/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsRouteViews\", \"/panel/analytics/route/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsAgentViews\", \"/panel/analytics/agent/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsForumViews\", \"/panel/analytics/forum/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsSystemViews\", \"/panel/analytics/system/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsLanguageViews\", \"/panel/analytics/lang/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsReferrerViews\", \"/panel/analytics/referrer/\", \"extraData\"),\n\t\tView(\"panel.AnalyticsPosts\", \"/panel/analytics/posts/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsMemory\", \"/panel/analytics/memory/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsActiveMemory\", \"/panel/analytics/active-memory/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsTopics\", \"/panel/analytics/topics/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsForums\", \"/panel/analytics/forums/\").Before(\"ParseForm\"),\n\t\tView(\"panel.AnalyticsPerf\", \"/panel/analytics/perf/\").Before(\"ParseForm\"),\n\n\t\tView(\"panel.Groups\", \"/panel/groups/\"),\n\t\tView(\"panel.GroupsEdit\", \"/panel/groups/edit/\", \"extraData\"),\n\t\tView(\"panel.GroupsEditPromotions\", \"/panel/groups/edit/promotions/\", \"extraData\"),\n\t\tAction(\"panel.GroupsPromotionsCreateSubmit\", \"/panel/groups/promotions/create/submit/\", \"extraData\"),\n\t\tAction(\"panel.GroupsPromotionsDeleteSubmit\", \"/panel/groups/promotions/delete/submit/\", \"extraData\"),\n\t\tView(\"panel.GroupsEditPerms\", \"/panel/groups/edit/perms/\", \"extraData\"),\n\t\tAction(\"panel.GroupsEditSubmit\", \"/panel/groups/edit/submit/\", \"extraData\"),\n\t\tAction(\"panel.GroupsEditPermsSubmit\", \"/panel/groups/edit/perms/submit/\", \"extraData\"),\n\t\tAction(\"panel.GroupsCreateSubmit\", \"/panel/groups/create/\"),\n\n\t\tView(\"panel.Backups\", \"/panel/backups/\", \"extraData\").Before(\"SuperAdminOnly\").NoGzip(), // TODO: Tests for this\n\t\tView(\"panel.LogsRegs\", \"/panel/logs/regs/\"),\n\t\tView(\"panel.LogsMod\", \"/panel/logs/mod/\"),\n\t\tView(\"panel.LogsAdmin\", \"/panel/logs/admin/\"),\n\t\tView(\"panel.Debug\", \"/panel/debug/\").Before(\"AdminOnly\"),\n\t\tView(\"panel.DebugTasks\", \"/panel/debug/tasks/\").Before(\"AdminOnly\"),\n\t)\n}\n"
  },
  {
    "path": "router_gen/run.bat",
    "content": "@echo off\necho Building the router generator\ngo build -ldflags=\"-s -w\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho The router generator was successfully built\nrouter_gen.exe\npause"
  },
  {
    "path": "routes/account.go",
    "content": "package routes\n\nimport (\n\t\"crypto/sha256\"\n\t\"crypto/subtle\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"html\"\n\t\"log\"\n\t\"math\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\n// A blank list to fill out that parameter in Page for routes which don't use it\nvar tList []interface{}\n\nfunc AccountLogin(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\th.Title = p.GetTitlePhrase(\"login\")\n\treturn renderTemplate(\"login\", w, r, h, c.Page{h, tList, nil})\n}\n\n// TODO: Log failed attempted logins?\n// TODO: Lock IPS out if they have too many failed attempts?\n// TODO: Log unusual countries in comparison to the country a user usually logs in from? Alert the user about this?\nfunc AccountLoginSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\n\tname := c.SanitiseSingleLine(r.PostFormValue(\"username\"))\n\tuid, e, requiresExtraAuth := c.Auth.Authenticate(name, r.PostFormValue(\"password\"))\n\tif e != nil {\n\t\t// TODO: uid is currently set to 0 as authenticate fetches the user by username and password. Get the actual uid, so we can alert the user of attempted logins? What if someone takes advantage of the response times to deduce if an account exists?\n\t\tif !c.Config.DisableLoginLog {\n\t\t\tli := &c.LoginLogItem{UID: uid, Success: false, IP: u.GetIP()}\n\t\t\tif _, ie := li.Create(); ie != nil {\n\t\t\t\treturn c.InternalError(ie, w, r)\n\t\t\t}\n\t\t}\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\n\t// TODO: Take 2FA into account\n\tif !c.Config.DisableLoginLog {\n\t\tli := &c.LoginLogItem{UID: uid, Success: true, IP: u.GetIP()}\n\t\tif _, e = li.Create(); e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t}\n\n\t// TODO: Do we want to slacken this by only doing it when the IP changes?\n\tif requiresExtraAuth {\n\t\tprovSession, signedSession, e := c.Auth.CreateProvisionalSession(uid)\n\t\tif e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t\t// TODO: Use the login log ID in the provisional cookie?\n\t\tc.Auth.SetProvisionalCookies(w, uid, provSession, signedSession)\n\t\thttp.Redirect(w, r, \"/accounts/mfa_verify/\", http.StatusSeeOther)\n\t\treturn nil\n\t}\n\n\treturn loginSuccess(uid, w, r, u)\n}\n\nfunc loginSuccess(uid int, w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tuserPtr, err := c.Users.Get(uid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Bad account\", w, r, u)\n\t}\n\t*u = *userPtr\n\n\tvar session string\n\tif u.Session == \"\" {\n\t\tsession, err = c.Auth.CreateSession(uid)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t} else {\n\t\tsession = u.Session\n\t}\n\n\tc.Auth.SetCookies(w, uid, session)\n\tif u.IsAdmin {\n\t\t// Is this error check redundant? We already check for the error in PreRoute for the same IP\n\t\t// TODO: Should we be logging this?\n\t\tlog.Printf(\"#%d has logged in with IP %s\", uid, u.GetIP())\n\t}\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc extractCookie(name string, r *http.Request) (string, error) {\n\tcookie, err := r.Cookie(name)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn cookie.Value, nil\n}\n\nfunc mfaGetCookies(r *http.Request) (uid int, provSession, signedSession string, err error) {\n\tsuid, err := extractCookie(\"uid\", r)\n\tif err != nil {\n\t\treturn 0, \"\", \"\", err\n\t}\n\tuid, err = strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn 0, \"\", \"\", err\n\t}\n\tprovSession, err = extractCookie(\"provSession\", r)\n\tif err != nil {\n\t\treturn 0, \"\", \"\", err\n\t}\n\tsignedSession, err = extractCookie(\"signedSession\", r)\n\treturn uid, provSession, signedSession, err\n}\n\nfunc mfaVerifySession(provSession, signedSession string, uid int) bool {\n\tbProvSession := []byte(provSession)\n\tbSignedSession := []byte(signedSession)\n\tbUid := []byte(strconv.Itoa(uid))\n\n\th := sha256.New()\n\th.Write([]byte(c.SessionSigningKeyBox.Load().(string)))\n\th.Write(bProvSession)\n\th.Write(bUid)\n\texpected := hex.EncodeToString(h.Sum(nil))\n\tif subtle.ConstantTimeCompare(bSignedSession, []byte(expected)) == 1 {\n\t\treturn true\n\t}\n\n\th = sha256.New()\n\th.Write([]byte(c.OldSessionSigningKeyBox.Load().(string)))\n\th.Write(bProvSession)\n\th.Write(bUid)\n\texpected = hex.EncodeToString(h.Sum(nil))\n\treturn subtle.ConstantTimeCompare(bSignedSession, []byte(expected)) == 1\n}\n\nfunc AccountLoginMFAVerify(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\th.Title = p.GetTitlePhrase(\"login_mfa_verify\")\n\n\tuid, provSession, signedSession, err := mfaGetCookies(r)\n\tif err != nil {\n\t\treturn c.LocalError(\"Invalid cookie\", w, r, u)\n\t}\n\tif !mfaVerifySession(provSession, signedSession, uid) {\n\t\treturn c.LocalError(\"Invalid session\", w, r, u)\n\t}\n\n\treturn renderTemplate(\"login_mfa_verify\", w, r, h, c.Page{h, tList, nil})\n}\n\nfunc AccountLoginMFAVerifySubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tuid, provSession, signedSession, err := mfaGetCookies(r)\n\tif err != nil {\n\t\treturn c.LocalError(\"Invalid cookie\", w, r, u)\n\t}\n\tif !mfaVerifySession(provSession, signedSession, uid) {\n\t\treturn c.LocalError(\"Invalid session\", w, r, u)\n\t}\n\ttoken := r.PostFormValue(\"mfa_token\")\n\n\terr = c.Auth.ValidateMFAToken(token, uid)\n\tif err != nil {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\n\treturn loginSuccess(uid, w, r, u)\n}\n\nfunc AccountLogout(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tc.Auth.Logout(w, u.ID)\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountRegister(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\th.Title = p.GetTitlePhrase(\"register\")\n\th.AddScriptAsync(\"register.js\")\n\n\tvar token string\n\tif c.Config.DisableJSAntispam {\n\t\th := sha256.New()\n\t\th.Write([]byte(c.JSTokenBox.Load().(string)))\n\t\th.Write([]byte(u.GetIP()))\n\t\ttoken = hex.EncodeToString(h.Sum(nil))\n\t}\n\n\treturn renderTemplate(\"register\", w, r, h, c.RegisterPage{h, h.Settings[\"activation_type\"] != 2, token, nil})\n}\n\nfunc isNumeric(data string) (numeric bool) {\n\tfor _, ch := range data {\n\t\tif ch < 48 || ch > 57 {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc AccountRegisterSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\theaderLite, _ := c.SimpleUserCheck(w, r, user)\n\n\t// TODO: Should we push multiple validation errors to the user instead of just one?\n\tregSuccess := true\n\tregErrMsg := \"\"\n\tregErrReason := \"\"\n\tregError := func(userMsg, reason string) {\n\t\tregSuccess = false\n\t\tif regErrMsg == \"\" {\n\t\t\tregErrMsg = userMsg\n\t\t}\n\t\tregErrReason += reason + \"|\"\n\t}\n\n\tif r.PostFormValue(\"tos\") != \"0\" {\n\t\tregError(p.GetErrorPhrase(\"register_might_be_machine\"), \"trap-question\")\n\t}\n\n\t{\n\t\th := sha256.New()\n\t\th.Write([]byte(c.JSTokenBox.Load().(string)))\n\t\th.Write([]byte(user.GetIP()))\n\t\tif !c.Config.DisableJSAntispam {\n\t\t\tif r.PostFormValue(\"golden-watch\") != hex.EncodeToString(h.Sum(nil)) {\n\t\t\t\tregError(p.GetErrorPhrase(\"register_might_be_machine\"), \"js-antispam\")\n\t\t\t}\n\t\t} else {\n\t\t\tif r.PostFormValue(\"areg\") != hex.EncodeToString(h.Sum(nil)) {\n\t\t\t\tregError(p.GetErrorPhrase(\"register_might_be_machine\"), \"token\")\n\t\t\t}\n\t\t}\n\t}\n\n\tname := c.SanitiseSingleLine(r.PostFormValue(\"name\"))\n\tif name == \"\" {\n\t\tregError(p.GetErrorPhrase(\"register_need_username\"), \"no-username\")\n\t}\n\t// This is so a numeric name won't interfere with mentioning a user by ID, there might be a better way of doing this like perhaps !@ to mean IDs and @ to mean usernames in the pre-parser\n\tnameBits := strings.Split(name, \" \")\n\tif isNumeric(nameBits[0]) {\n\t\tregError(p.GetErrorPhrase(\"register_first_word_numeric\"), \"numeric-name\")\n\t}\n\tif strings.Contains(name, \"http://\") || strings.Contains(name, \"https://\") || strings.Contains(name, \"ftp://\") || strings.Contains(name, \"ssh://\") {\n\t\tregError(p.GetErrorPhrase(\"register_url_username\"), \"url-name\")\n\t}\n\n\t// TODO: Add a dedicated function for validating emails\n\temail := c.SanitiseSingleLine(r.PostFormValue(\"email\"))\n\tif headerLite.Settings[\"activation_type\"] == 2 && email == \"\" {\n\t\tregError(p.GetErrorPhrase(\"register_need_email\"), \"no-email\")\n\t}\n\tif c.HasSuspiciousEmail(email) {\n\t\tregError(p.GetErrorPhrase(\"register_suspicious_email\"), \"suspicious-email\")\n\t}\n\n\tpassword := r.PostFormValue(\"password\")\n\t// ?  Move this into Create()? What if we want to programatically set weak passwords for tests?\n\terr := c.WeakPassword(password, name, email)\n\tif err != nil {\n\t\tregError(err.Error(), \"weak-password\")\n\t} else {\n\t\t// Do the two inputted passwords match..?\n\t\tconfirmPassword := r.PostFormValue(\"confirm_password\")\n\t\tif password != confirmPassword {\n\t\t\tregError(p.GetErrorPhrase(\"register_password_mismatch\"), \"password-mismatch\")\n\t\t}\n\t}\n\n\tregLog := c.RegLogItem{Username: name, Email: email, FailureReason: regErrReason, Success: regSuccess, IP: user.GetIP()}\n\tif !c.Config.DisableRegLog && regSuccess {\n\t\tif _, e := regLog.Create(); e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t}\n\tif !regSuccess {\n\t\treturn c.LocalError(regErrMsg, w, r, user)\n\t}\n\n\tvar active bool\n\tvar group int\n\tswitch headerLite.Settings[\"activation_type\"] {\n\tcase 1: // Activate All\n\t\tactive = true\n\t\tgroup = c.Config.DefaultGroup\n\tdefault: // Anything else. E.g. Admin Activation or Email Activation.\n\t\tgroup = c.Config.ActivationGroup\n\t}\n\n\tpushLog := func(reason string) error {\n\t\tif !c.Config.DisableRegLog {\n\t\t\tregLog.FailureReason += reason + \"|\"\n\t\t\t_, e := regLog.Create()\n\t\t\treturn e\n\t\t}\n\t\treturn nil\n\t}\n\n\tcanonEmail := c.CanonEmail(email)\n\tuid, err := c.Users.Create(name, password, canonEmail, group, active)\n\tif err != nil {\n\t\tregLog.Success = false\n\t\tif err == c.ErrAccountExists {\n\t\t\terr = pushLog(\"username-exists\")\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"register_username_unavailable\"), w, r, user)\n\t\t} else if err == c.ErrLongUsername {\n\t\t\terr = pushLog(\"username-too-long\")\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"register_username_too_long_prefix\")+strconv.Itoa(c.Config.MaxUsernameLength), w, r, user)\n\t\t}\n\t\terr2 := pushLog(\"internal-error\")\n\t\tif err2 != nil {\n\t\t\treturn c.InternalError(err2, w, r)\n\t\t}\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tu, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"You no longer exist.\", w, r, user)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.GroupPromotions.PromoteIfEligible(u, u.Level, u.Posts, u.CreatedAt)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tu.CacheRemove()\n\n\tsession, err := c.Auth.CreateSession(uid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tc.Auth.SetCookies(w, uid, session)\n\n\t// Check if this user actually owns this email, if email activation is on, automatically flip their account to active when the email is validated. Validation is also useful for determining whether this user should receive any alerts, etc. via email\n\tif c.Site.EnableEmails && canonEmail != \"\" {\n\t\ttoken, err := c.GenerateSafeString(80)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t// TODO: Add an EmailStore and move this there\n\t\t_, err = qgen.NewAcc().Insert(\"emails\").Columns(\"email,uid,validated,token\").Fields(\"?,?,?,?\").Exec(canonEmail, uid, 0, token)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\terr = c.SendActivationEmail(name, canonEmail, token)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"register_email_fail\"), w, r, user)\n\t\t}\n\t}\n\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n\n// TODO: Figure a way of making this into middleware?\nfunc accountEditHead(titlePhrase string, w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) {\n\th.Title = p.GetTitlePhrase(titlePhrase)\n\th.Path = \"/user/edit/\"\n\th.AddSheet(h.Theme.Name + \"/account.css\")\n\th.AddScriptAsync(\"account.js\")\n}\n\nfunc AccountEdit(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account\", w, r, u, h)\n\tswitch {\n\tcase r.FormValue(\"avatar_updated\") == \"1\":\n\t\th.AddNotice(\"account_avatar_updated\")\n\tcase r.FormValue(\"name_updated\") == \"1\":\n\t\th.AddNotice(\"account_name_updated\")\n\tcase r.FormValue(\"mfa_setup_success\") == \"1\":\n\t\th.AddNotice(\"account_mfa_setup_success\")\n\t}\n\n\t// TODO: Find a more efficient way of doing this\n\tmfaSetup := false\n\t_, err := c.MFAstore.Get(u.ID)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t} else if err != sql.ErrNoRows {\n\t\tmfaSetup = true\n\t}\n\n\t// Normalise the score so that the user sees their relative progress to the next level rather than showing them their total score\n\tprevScore := c.GetLevelScore(u.Level)\n\tscore := u.Score\n\t//score = 23\n\tcurrentScore := score - prevScore\n\tnextScore := c.GetLevelScore(u.Level+1) - prevScore\n\t//perc := int(math.Ceil((float64(nextScore) / float64(currentScore)) * 100)) * 2\n\tperc := int(math.Floor((float64(currentScore) / float64(nextScore)) * 100)) // * 2\n\n\tpi := c.Account{h, \"dashboard\", \"account_own_edit\", c.AccountDashPage{h, mfaSetup, currentScore, nextScore, u.Level + 1, perc}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\n//edit_password\nfunc AccountEditPassword(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_password\", w, r, u, h)\n\treturn renderTemplate(\"account_own_edit_password\", w, r, h, c.Page{h, tList, nil})\n}\n\n// TODO: Require re-authentication if the user hasn't logged in in a while\nfunc AccountEditPasswordSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tvar realPassword, salt string\n\tcurrentPassword := r.PostFormValue(\"current-password\")\n\tnewPassword := r.PostFormValue(\"new-password\")\n\tconfirmPassword := r.PostFormValue(\"confirm-password\")\n\n\t// TODO: Use a reusable statement\n\terr := qgen.NewAcc().Select(\"users\").Columns(\"password,salt\").Where(\"uid=?\").QueryRow(u.ID).Scan(&realPassword, &salt)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"Your account no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\terr = c.CheckPassword(realPassword, currentPassword, salt)\n\tif err == c.ErrMismatchedHashAndPassword {\n\t\treturn c.LocalError(\"That's not the correct password.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif newPassword != confirmPassword {\n\t\treturn c.LocalError(\"The two passwords don't match.\", w, r, u)\n\t}\n\tc.SetPassword(u.ID, newPassword) // TODO: Limited version of WeakPassword()\n\n\t// Log the user out as a safety precaution\n\tc.Auth.ForceLogout(u.ID)\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditAvatarSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.UploadAvatars {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\text, ferr := c.UploadAvatar(w, r, u, u.ID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tferr = c.ChangeAvatar(\".\"+ext, w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\t// TODO: Only schedule a resize if the avatar isn't tiny\n\terr := u.ScheduleAvatarResize()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\thttp.Redirect(w, r, \"/user/edit/?avatar_updated=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditRevokeAvatarSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tferr = c.ChangeAvatar(\"\", w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/?avatar_updated=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditUsernameSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tnewName := c.SanitiseSingleLine(r.PostFormValue(\"new-name\"))\n\tif newName == \"\" {\n\t\treturn c.LocalError(\"You can't leave your username blank\", w, r, u)\n\t}\n\terr := u.ChangeName(newName)\n\tif err != nil {\n\t\treturn c.LocalError(\"Unable to change names. Does someone else already have this name?\", w, r, u)\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/?name_updated=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditMFA(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_mfa\", w, r, u, h)\n\n\tmfaItem, err := c.MFAstore.Get(u.ID)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t} else if err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"Two-factor authentication hasn't been setup on your account\", w, r, u)\n\t}\n\n\tpi := c.Page{h, tList, mfaItem.Scratch}\n\treturn renderTemplate(\"account_own_edit_mfa\", w, r, h, pi)\n}\n\n// If not setup, generate a string, otherwise give an option to disable mfa given the right code\nfunc AccountEditMFASetup(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_mfa_setup\", w, r, u, h)\n\n\t// Flash an error if mfa is already setup\n\t_, e := c.MFAstore.Get(u.ID)\n\tif e != sql.ErrNoRows && e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t} else if e != sql.ErrNoRows {\n\t\treturn c.LocalError(\"You have already setup two-factor authentication\", w, r, u)\n\t}\n\n\t// TODO: Entitise this?\n\tcode, e := c.GenerateGAuthSecret()\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tpi := c.Page{h, tList, c.FriendlyGAuthSecret(code)}\n\treturn renderTemplate(\"account_own_edit_mfa_setup\", w, r, h, pi)\n}\n\n// Form should bounce the random mfa secret back and the otp to be verified server-side to reduce the chances of a bug arising on the JS side which makes every code mismatch\nfunc AccountEditMFASetupSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\t// Flash an error if mfa is already setup\n\t_, err := c.MFAstore.Get(u.ID)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t} else if err != sql.ErrNoRows {\n\t\treturn c.LocalError(\"You have already setup two-factor authentication\", w, r, u)\n\t}\n\n\tcode := r.PostFormValue(\"code\")\n\totp := r.PostFormValue(\"otp\")\n\tok, err := c.VerifyGAuthToken(code, otp)\n\tif err != nil {\n\t\t//fmt.Println(\"err: \", err)\n\t\treturn c.LocalError(\"Something weird happened\", w, r, u) // TODO: Log this error?\n\t}\n\t// TODO: Use AJAX for this\n\tif !ok {\n\t\treturn c.LocalError(\"The token isn't right\", w, r, u)\n\t}\n\n\t// TODO: How should we handle races where a mfa key is already setup? Right now, it's a fairly generic error, maybe try parsing the error message?\n\terr = c.MFAstore.Create(code, u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/?mfa_setup_success=1\", http.StatusSeeOther)\n\treturn nil\n}\n\n// TODO: Implement this\nfunc AccountEditMFADisableSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\t// Flash an error if mfa is already setup\n\tmfaItem, err := c.MFAstore.Get(u.ID)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t} else if err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"You don't have two-factor enabled on your account\", w, r, u)\n\t}\n\terr = mfaItem.Delete()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/?mfa_disabled=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditPrivacy(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_privacy\", w, r, u, h)\n\tprofileComments := u.Privacy.ShowComments\n\treceiveConvos := u.Privacy.AllowMessage\n\tenableEmbeds := !c.DefaultParseSettings.NoEmbed\n\tif u.ParseSettings != nil {\n\t\tenableEmbeds = !u.ParseSettings.NoEmbed\n\t}\n\tpi := c.Account{h, \"privacy\", \"account_own_edit_privacy\", c.AccountPrivacyPage{h, profileComments, receiveConvos, enableEmbeds}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc AccountEditPrivacySubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t//headerLite, _ := c.SimpleUserCheck(w, r, u)\n\tsProfileComments := r.FormValue(\"profile_comments\")\n\tsEnableEmbeds := r.FormValue(\"enable_embeds\")\n\toProfileComments := r.FormValue(\"o_profile_comments\")\n\toEnableEmbeds := r.FormValue(\"o_enable_embeds\")\n\n\tif sProfileComments != oProfileComments || sEnableEmbeds != oEnableEmbeds {\n\t\tprofileComments, e := strconv.Atoi(sProfileComments)\n\t\tenableEmbeds, e2 := strconv.Atoi(sEnableEmbeds)\n\t\tif e != nil || e2 != nil {\n\t\t\treturn c.LocalError(\"malformed integer\", w, r, u)\n\t\t}\n\t\te = u.UpdatePrivacy(profileComments, enableEmbeds)\n\t\tif e == c.ErrProfileCommentsOutOfBounds || e == c.ErrEnableEmbedsOutOfBounds {\n\t\t\treturn c.LocalError(e.Error(), w, r, u)\n\t\t} else if e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/privacy/?updated=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditEmail(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_email\", w, r, u, h)\n\temails, err := c.Emails.GetEmailsByUser(u)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// Was this site migrated from another forum software? Most of them don't have multiple emails for a single user.\n\t// This also applies when the admin switches site.EnableEmails on after having it off for a while.\n\tif len(emails) == 0 && u.Email != \"\" {\n\t\temails = append(emails, c.Email{UserID: u.ID, Email: u.Email, Validated: false, Primary: true})\n\t}\n\n\tif !c.Site.EnableEmails {\n\t\th.AddNotice(\"account_mail_disabled\")\n\t}\n\tif r.FormValue(\"verified\") == \"1\" {\n\t\th.AddNotice(\"account_mail_verify_success\")\n\t}\n\n\tpi := c.Account{h, \"edit_emails\", \"account_own_edit_email\", c.EmailListPage{h, emails}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc AccountEditEmailAddSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\temail := c.SanitiseSingleLine(r.PostFormValue(\"email\"))\n\tcanonEmail := c.CanonEmail(email)\n\t_, err := c.Emails.Get(u, canonEmail)\n\tif err == nil {\n\t\treturn c.LocalError(\"You have already added this email.\", w, r, u)\n\t} else if err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar token string\n\tif c.Site.EnableEmails {\n\t\ttoken, err = c.GenerateSafeString(80)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\terr = c.Emails.Add(u.ID, canonEmail, token)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif c.Site.EnableEmails {\n\t\terr = c.SendValidationEmail(u.Name, canonEmail, token)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"register_email_fail\"), w, r, u)\n\t\t}\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/email/?added=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountEditEmailRemoveSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\theaderLite, _ := c.SimpleUserCheck(w, r, u)\n\temail := c.SanitiseSingleLine(r.PostFormValue(\"email\"))\n\tcanonEmail := c.CanonEmail(email)\n\n\t// Quick and dirty check\n\t_, err := c.Emails.Get(u, canonEmail)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"This email isn't set on this user.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif headerLite.Settings[\"activation_type\"] == 2 && u.Email == canonEmail {\n\t\treturn c.LocalError(\"You can't remove your primary email when mandatory email activation is enabled.\", w, r, u)\n\t}\n\n\terr = c.Emails.Delete(u.ID, canonEmail)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/edit/email/?removed=1\", http.StatusSeeOther)\n\treturn nil\n}\n\n// TODO: Should we make this an AnonAction so someone can do this without being logged in?\nfunc AccountEditEmailTokenSubmit(w http.ResponseWriter, r *http.Request, user *c.User, token string) c.RouteError {\n\theader, ferr := c.UserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !c.Site.EnableEmails {\n\t\thttp.Redirect(w, r, \"/user/edit/email/\", http.StatusSeeOther)\n\t\treturn nil\n\t}\n\n\ttargetEmail := c.Email{UserID: user.ID}\n\temails, err := c.Emails.GetEmailsByUser(user)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"A verification email was never sent for you!\", w, r, user)\n\t} else if err != nil {\n\t\t// TODO: Better error if we don't have an email or it's not in the emails table for some reason\n\t\treturn c.LocalError(\"You are not logged in\", w, r, user)\n\t}\n\tfor _, email := range emails {\n\t\tif subtle.ConstantTimeCompare([]byte(email.Token), []byte(token)) == 1 {\n\t\t\ttargetEmail = email\n\t\t}\n\t}\n\n\tif len(emails) == 0 {\n\t\treturn c.LocalError(\"A verification email was never sent for you!\", w, r, user)\n\t}\n\tif targetEmail.Token == \"\" {\n\t\treturn c.LocalError(\"That's not a valid token!\", w, r, user)\n\t}\n\n\terr = c.Emails.VerifyEmail(user.Email)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// If Email Activation is on, then activate the account while we're here\n\tif header.Settings[\"activation_type\"] == 2 {\n\t\tif err = user.Activate(); err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\tu2, err := c.Users.Get(user.ID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"The user no longer exists.\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\terr = c.GroupPromotions.PromoteIfEligible(u2, u2.Level, u2.Posts, u2.CreatedAt)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tu2.CacheRemove()\n\t}\n\thttp.Redirect(w, r, \"/user/edit/email/?verified=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountLogins(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_logins\", w, r, u, h)\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 12\n\toffset, page, lastPage := c.PageOffset(c.LoginLogs.CountUser(u.ID), page, perPage)\n\n\tlogs, err := c.LoginLogs.GetOffset(u.ID, offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.Account{h, \"logins\", \"account_logins\", c.AccountLoginsPage{h, logs, c.Paginator{pageList, page, lastPage}}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc AccountBlocked(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"account_blocked\", w, r, user, h)\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 12\n\toffset, page, lastPage := c.PageOffset(c.UserBlocks.BlockedByCount(user.ID), page, perPage)\n\n\tuids, err := c.UserBlocks.BlockedByOffset(user.ID, offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tvar blocks []*c.User\n\tfor _, uid := range uids {\n\t\tu, err := c.Users.Get(uid)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tblocks = append(blocks, u)\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.Account{h, \"blocked\", \"account_blocked\", c.AccountBlocksPage{h, blocks, c.Paginator{pageList, page, lastPage}}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc LevelList(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\th.Title = p.GetTitlePhrase(\"account_level_list\")\n\n\tfScores := c.GetLevels(21)\n\tlevels := make([]c.LevelListItem, len(fScores))\n\tfor i, fScore := range fScores {\n\t\tif i == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar status string\n\t\tif u.Level > (i - 1) {\n\t\t\tstatus = \"complete\"\n\t\t} else if u.Level < (i - 1) {\n\t\t\tstatus = \"future\"\n\t\t} else {\n\t\t\tstatus = \"inprogress\"\n\t\t}\n\t\tiScore := int(math.Ceil(fScore))\n\t\t//perc := int(math.Ceil((fScore/float64(u.Score))*100)) * 2\n\t\tperc := int(math.Ceil((float64(u.Score) / fScore) * 100))\n\t\tlevels[i] = c.LevelListItem{i - 1, iScore, status, perc}\n\t}\n\n\treturn renderTemplate(\"level_list\", w, r, h, c.LevelListPage{h, levels[1:]})\n}\n\nfunc Alerts(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\treturn nil\n}\n\nfunc AccountPasswordReset(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\tif !c.Site.EnableEmails {\n\t\treturn c.LocalError(p.GetNoticePhrase(\"account_mail_disabled\"), w, r, u)\n\t}\n\tif r.FormValue(\"email_sent\") == \"1\" {\n\t\th.AddNotice(\"password_reset_email_sent\")\n\t}\n\th.Title = p.GetTitlePhrase(\"password_reset\")\n\treturn renderTemplate(\"password_reset\", w, r, h, c.Page{h, tList, nil})\n}\n\n// TODO: Ratelimit this\nfunc AccountPasswordResetSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\tif user.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, user)\n\t}\n\tif !c.Site.EnableEmails {\n\t\treturn c.LocalError(p.GetNoticePhrase(\"account_mail_disabled\"), w, r, user)\n\t}\n\n\tusername := r.PostFormValue(\"username\")\n\ttuser, err := c.Users.GetByName(username)\n\tif err == sql.ErrNoRows {\n\t\t// Someone trying to stir up trouble?\n\t\thttp.Redirect(w, r, \"/accounts/password-reset/?email_sent=1\", http.StatusSeeOther)\n\t\treturn nil\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\ttoken, err := c.GenerateSafeString(80)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Move these queries somewhere else\n\tvar disc string\n\terr = qgen.NewAcc().Select(\"password_resets\").Columns(\"createdAt\").DateCutoff(\"createdAt\", 1, \"hour\").QueryRow().Scan(&disc)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif err == nil {\n\t\treturn c.LocalError(\"You can only send a password reset email for a user once an hour\", w, r, user)\n\t}\n\n\tcount, err := qgen.NewAcc().Count(\"password_resets\").DateCutoff(\"createdAt\", 6, \"hour\").Total()\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif count >= 3 {\n\t\treturn c.LocalError(\"You can only send a password reset email for a user three times every six hours\", w, r, user)\n\t}\n\n\tcount, err = qgen.NewAcc().Count(\"password_resets\").DateCutoff(\"createdAt\", 12, \"hour\").Total()\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif count >= 4 {\n\t\treturn c.LocalError(\"You can only send a password reset email for a user four times every twelve hours\", w, r, user)\n\t}\n\n\terr = c.PasswordResetter.Create(tuser.Email, tuser.ID, token)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar s string\n\tif c.Config.SslSchema {\n\t\ts = \"s\"\n\t}\n\n\terr = c.SendEmail(tuser.Email, p.GetTmplPhrase(\"password_reset_subject\"), p.GetTmplPhrasef(\"password_reset_body\", tuser.Name, \"http\"+s+\"://\"+c.Site.URL+\"/accounts/password-reset/token/?uid=\"+strconv.Itoa(tuser.ID)+\"&token=\"+token))\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"password_reset_email_fail\"), w, r, user)\n\t}\n\n\thttp.Redirect(w, r, \"/accounts/password-reset/?email_sent=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc AccountPasswordResetToken(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\t// TODO: Find a way to flash this notice\n\t/*if r.FormValue(\"token_verified\") == \"1\" {\n\t\th.AddNotice(\"password_reset_token_token_verified\")\n\t}*/\n\n\tuid, err := strconv.Atoi(r.FormValue(\"uid\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"Invalid uid\", w, r, u)\n\t}\n\ttoken := r.FormValue(\"token\")\n\terr = c.PasswordResetter.ValidateToken(uid, token)\n\tif err == sql.ErrNoRows || err == c.ErrBadResetToken {\n\t\treturn c.LocalError(\"This reset token has expired.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t_, err = c.MFAstore.Get(uid)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tmfa := err != sql.ErrNoRows\n\n\th.Title = p.GetTitlePhrase(\"password_reset_token\")\n\treturn renderTemplate(\"password_reset_token\", w, r, h, c.ResetPage{h, uid, html.EscapeString(token), mfa})\n}\n\nfunc AccountPasswordResetTokenSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tif u.Loggedin {\n\t\treturn c.LocalError(\"You're already logged in.\", w, r, u)\n\t}\n\tuid, err := strconv.Atoi(r.FormValue(\"uid\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"Invalid uid\", w, r, u)\n\t}\n\tif !c.Users.Exists(uid) {\n\t\treturn c.LocalError(\"This reset token has expired.\", w, r, u)\n\t}\n\n\terr = c.PasswordResetter.ValidateToken(uid, r.FormValue(\"token\"))\n\tif err == sql.ErrNoRows || err == c.ErrBadResetToken {\n\t\treturn c.LocalError(\"This reset token has expired.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tmfaToken := r.PostFormValue(\"mfa_token\")\n\terr = c.Auth.ValidateMFAToken(mfaToken, uid)\n\tif err != nil && err != c.ErrNoMFAToken {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\n\tnewPassword := r.PostFormValue(\"password\")\n\tconfirmPassword := r.PostFormValue(\"confirm_password\")\n\tif newPassword != confirmPassword {\n\t\treturn c.LocalError(\"The two passwords don't match.\", w, r, u)\n\t}\n\tc.SetPassword(uid, newPassword) // TODO: Limited version of WeakPassword()\n\n\terr = c.PasswordResetter.FlushTokens(uid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// Log the user out as a safety precaution\n\tc.Auth.ForceLogout(uid)\n\n\t//http.Redirect(w, r, \"/accounts/password-reset/token/?token_verified=1\", http.StatusSeeOther)\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/api.go",
    "content": "package routes\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\n// TODO: Make this a static file somehow? Is it possible for us to put this file somewhere else?\n// TODO: Add an API so that plugins can register disallowed areas. E.g. /guilds/join for plugin_guilds\nfunc RobotsTxt(w http.ResponseWriter, r *http.Request) c.RouteError {\n\t// TODO: Do we have to put * or something at the end of the paths?\n\t_, _ = w.Write([]byte(`User-agent: *\nDisallow: /panel/*\nDisallow: /topics/create/\nDisallow: /user/edit/*\nDisallow: /accounts/*\nDisallow: /report/*\n`))\n\treturn nil\n}\n\nvar sitemapPageCap = 40000 // 40k, bump it up to 50k once we gzip this? Does brotli work on sitemaps?\n\nfunc writeXMLHeader(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/xml\")\n\tw.Write([]byte(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\"?>\\n\"))\n}\n\n// TODO: Keep track of when a sitemap was last modifed and add a lastmod element for it\nfunc SitemapXml(w http.ResponseWriter, r *http.Request) c.RouteError {\n\tvar s string\n\tif c.Config.SslSchema {\n\t\ts = \"s\"\n\t}\n\tsitemapItem := func(path string) {\n\t\tw.Write([]byte(`<sitemap>\n\t<loc>http` + s + `://` + c.Site.URL + \"/\" + path + `</loc>\n</sitemap>\n`))\n\t}\n\twriteXMLHeader(w, r)\n\tw.Write([]byte(\"<sitemapindex xmlns=\\\"http://www.sitemaps.org/schemas/sitemap/0.9\\\">\\n\"))\n\tsitemapItem(\"sitemaps/topics.xml\")\n\t//sitemapItem(\"sitemaps/forums.xml\")\n\t//sitemapItem(\"sitemaps/users.xml\")\n\tw.Write([]byte(\"</sitemapindex>\"))\n\n\treturn nil\n}\n\ntype FuzzyRoute struct {\n\tPath   string\n\tHandle func(http.ResponseWriter, *http.Request, int) c.RouteError\n}\n\n// TODO: Add a sitemap API and clean things up\n// TODO: ^-- Make sure that the API is concurrent\n// TODO: Add a social group sitemap\nvar sitemapRoutes = map[string]func(http.ResponseWriter, *http.Request) c.RouteError{\n\t\"forums.xml\": SitemapForums,\n\t\"topics.xml\": SitemapTopics,\n}\n\n// TODO: Use a router capable of parsing this rather than hard-coding the logic in\nvar fuzzySitemapRoutes = map[string]FuzzyRoute{\n\t\"topics_page_\": {\"topics_page_(%d).xml\", SitemapTopic},\n}\n\nfunc sitemapSwitch(w http.ResponseWriter, r *http.Request) c.RouteError {\n\tpath := r.URL.Path[len(\"/sitemaps/\"):]\n\tfor name, fuzzy := range fuzzySitemapRoutes {\n\t\tif strings.HasPrefix(path, name) && strings.HasSuffix(path, \".xml\") {\n\t\t\tspath := strings.TrimPrefix(path, name)\n\t\t\tspath = strings.TrimSuffix(spath, \".xml\")\n\t\t\tpage, err := strconv.Atoi(spath)\n\t\t\tif err != nil {\n\t\t\t\t// ? What's this? Do we need it? Was it just a quick trace?\n\t\t\t\tc.DebugLogf(\"Unable to convert string '%s' to integer in fuzzy route\", spath)\n\t\t\t\treturn c.NotFound(w, r, nil)\n\t\t\t}\n\t\t\treturn fuzzy.Handle(w, r, page)\n\t\t}\n\t}\n\n\troute, ok := sitemapRoutes[path]\n\tif !ok {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\treturn route(w, r)\n}\n\nfunc SitemapForums(w http.ResponseWriter, r *http.Request) c.RouteError {\n\tvar s string\n\tif c.Config.SslSchema {\n\t\ts = \"s\"\n\t}\n\tsitemapItem := func(path string) {\n\t\tw.Write([]byte(`<url>\n\t<loc>http` + s + `://` + c.Site.URL + path + `</loc>\n</url>\n`))\n\t}\n\n\tgroup, err := c.Groups.Get(c.GuestUser.Group)\n\tif err != nil {\n\t\treturn c.SilentInternalErrorXML(errors.New(\"The guest group doesn't exist for some reason\"), w, r)\n\t}\n\n\twriteXMLHeader(w, r)\n\tw.Write([]byte(\"<urlset xmlns=\\\"http://www.sitemaps.org/schemas/sitemap/0.9\\\">\\n\"))\n\n\tfor _, fid := range group.CanSee {\n\t\t// Avoid data races by copying the struct into something we can freely mold without worrying about breaking something somewhere else\n\t\tf := c.Forums.DirtyGet(fid).Copy()\n\t\tif f.ParentID == 0 && f.Name != \"\" && f.Active {\n\t\t\tsitemapItem(c.BuildForumURL(c.NameToSlug(f.Name), f.ID))\n\t\t}\n\t}\n\n\tw.Write([]byte(\"</urlset>\"))\n\treturn nil\n}\n\n// TODO: Add a global ratelimit. 10 50MB files (smaller if compressed better) per minute?\n// ? We might have problems with banned users, if they have fewer ViewTopic permissions than guests as they'll be able to see this list. Then again, a banned user could just logout to see it\nfunc SitemapTopics(w http.ResponseWriter, r *http.Request) c.RouteError {\n\tvar s string\n\tif c.Config.SslSchema {\n\t\ts = \"s\"\n\t}\n\tsitemapItem := func(path string) {\n\t\tw.Write([]byte(`<sitemap>\n\t<loc>http` + s + `://` + c.Site.URL + \"/\" + path + `</loc>\n</sitemap>\n`))\n\t}\n\n\tgroup, err := c.Groups.Get(c.GuestUser.Group)\n\tif err != nil {\n\t\treturn c.SilentInternalErrorXML(errors.New(\"The guest group doesn't exist for some reason\"), w, r)\n\t}\n\n\tvar visibleForums []c.Forum\n\tfor _, fid := range group.CanSee {\n\t\tforum := c.Forums.DirtyGet(fid)\n\t\tif forum.Name != \"\" && forum.Active {\n\t\t\tvisibleForums = append(visibleForums, forum.Copy())\n\t\t}\n\t}\n\n\ttopicCount, err := c.TopicCountInForums(visibleForums)\n\tif err != nil {\n\t\treturn c.InternalErrorXML(err, w, r)\n\t}\n\n\tpageCount := topicCount / sitemapPageCap\n\t//log.Print(\"topicCount\", topicCount)\n\t//log.Print(\"pageCount\", pageCount)\n\twriteXMLHeader(w, r)\n\tw.Write([]byte(\"<sitemapindex xmlns=\\\"http://www.sitemaps.org/schemas/sitemap/0.9\\\">\\n\"))\n\tfor i := 0; i <= pageCount; i++ {\n\t\tsitemapItem(\"sitemaps/topics_page_\" + strconv.Itoa(i) + \".xml\")\n\t}\n\tw.Write([]byte(\"</sitemapindex>\"))\n\treturn nil\n}\n\nfunc SitemapTopic(w http.ResponseWriter, r *http.Request, page int) c.RouteError {\n\t/*var s string\n\tif c.Config.SslSchema {\n\t\ts = \"s\"\n\t}\n\tvar sitemapItem = func(path string) {\n\t\t\tw.Write([]byte(`<url>\n\t\t<loc>http` + s + `://` + c.Site.URL + \"/\" + path + `</loc>\n\t</url>\n\t`))\n\t\t}*/\n\n\tgroup, err := c.Groups.Get(c.GuestUser.Group)\n\tif err != nil {\n\t\treturn c.SilentInternalErrorXML(errors.New(\"The guest group doesn't exist for some reason\"), w, r)\n\t}\n\n\tvar visibleForums []c.Forum\n\tfor _, fid := range group.CanSee {\n\t\tforum := c.Forums.DirtyGet(fid)\n\t\tif forum.Name != \"\" && forum.Active {\n\t\t\tvisibleForums = append(visibleForums, forum.Copy())\n\t\t}\n\t}\n\n\targList, qlist := c.ForumListToArgQ(visibleForums)\n\ttopicCount, err := c.ArgQToTopicCount(argList, qlist)\n\tif err != nil {\n\t\treturn c.InternalErrorXML(err, w, r)\n\t}\n\n\tpageCount := topicCount / sitemapPageCap\n\t//log.Print(\"topicCount\", topicCount)\n\t//log.Print(\"pageCount\", pageCount)\n\t//log.Print(\"page\",page)\n\tif page > pageCount {\n\t\tpage = pageCount\n\t}\n\n\twriteXMLHeader(w, r)\n\tw.Write([]byte(\"<urlset xmlns=\\\"http://www.sitemaps.org/schemas/sitemap/0.9\\\">\\n\"))\n\n\tw.Write([]byte(\"</urlset>\"))\n\treturn nil\n}\n\nfunc SitemapUsers(w http.ResponseWriter, r *http.Request) c.RouteError {\n\twriteXMLHeader(w, r)\n\tw.Write([]byte(\"<sitemapindex xmlns=\\\"http://www.sitemaps.org/schemas/sitemap/0.9\\\">\\n\"))\n\treturn nil\n}\n\ntype JsonMe struct {\n\tUser *c.MeUser\n\tSite MeSite\n}\n\n// We don't want to expose too much information about the site, so we'll make this a small subset of c.site\ntype MeSite struct {\n\tMaxReqSize   int\n\tStaticPrefix string\n}\n\n// APIMe returns information about the current logged-in user\n// TODO: Find some way to stop intermediaries from doing compression to avoid the BREACH attack\n// TODO: Decouple site settings into a different API? I'd like to avoid having too many requests, if possible, maybe we can use a different name for this?\nfunc APIMe(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t// TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats\n\th := w.Header()\n\th.Set(\"Content-Type\", \"application/json\")\n\t// We don't want an intermediary accidentally caching this\n\t// TODO: Use this header anywhere with a user check?\n\th.Set(\"Cache-Control\", \"private\")\n\n\tme := JsonMe{u.Me(), MeSite{c.Site.MaxRequestSize, c.StaticFiles.Prefix}}\n\tjsonBytes, err := json.Marshal(me)\n\tif err != nil {\n\t\treturn c.InternalErrorJS(err, w, r)\n\t}\n\tw.Write(jsonBytes)\n\n\treturn nil\n}\n\nfunc OpenSearchXml(w http.ResponseWriter, r *http.Request) c.RouteError {\n\tw.Header().Set(\"Content-Type\", \"application/xml\")\n\tfurl := \"http\"\n\tif c.Config.SslSchema {\n\t\tfurl += \"s\"\n\t}\n\tfurl += \"://\" + c.Site.URL\n\tw.Write([]byte(`<OpenSearchDescription xmlns=\"http://a9.com/-/spec/opensearch/1.1/\" xmlns:moz=\"http://www.mozilla.org/2006/browser/search/\">\n\t<ShortName>` + c.Site.Name + `</ShortName>\n\t<InputEncoding>UTF-8</InputEncoding>\n\t<Url type=\"text/html\" template=\"` + furl + `/topics/?q={searchTerms}\"/>\n\t<Url type=\"application/opensearchdescription+xml\" rel=\"self\" template=\"` + furl + `/opensearch.xml\"/>\n\t<moz:SearchForm>` + furl + `</moz:SearchForm>\n</OpenSearchDescription>`))\n\treturn nil\n}\n"
  },
  {
    "path": "routes/attachments.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nvar maxAgeYear = \"max-age=\" + strconv.Itoa(int(c.Year))\n\nfunc ShowAttachment(w http.ResponseWriter, r *http.Request, u *c.User, filename string) c.RouteError {\n\tsid, err := strconv.Atoi(r.FormValue(\"sid\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"The sid is not an integer\", w, r, u)\n\t}\n\tsectionTable := r.FormValue(\"stype\")\n\n\tfilename = c.Stripslashes(filename)\n\tif filename == \"\" {\n\t\treturn c.LocalError(\"Bad filename\", w, r, u)\n\t}\n\text := filepath.Ext(filename)\n\tif ext == \"\" || !c.AllowedFileExts.Contains(strings.TrimPrefix(ext, \".\")) {\n\t\treturn c.LocalError(\"Bad extension\", w, r, u)\n\t}\n\n\t// TODO: Use the same hook table as upstream\n\thTbl := c.GetHookTable()\n\tskip, rerr := c.H_route_attach_start_hook(hTbl, w, r, u, filename)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\ta, err := c.Attachments.GetForRenderRoute(filename, sid, sectionTable)\n\t// ErrCorruptAttachPath is a possibility now\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tskip, rerr = c.H_route_attach_post_get_hook(hTbl, w, r, u, a)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tif a.SectionTable == \"forums\" {\n\t\t_, ferr := c.SimpleForumUserCheck(w, r, u, sid)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\tif !u.Perms.ViewTopic {\n\t\t\treturn c.NoPermissions(w, r, u)\n\t\t}\n\t} else {\n\t\treturn c.LocalError(\"Unknown section\", w, r, u)\n\t}\n\n\tif a.OriginTable != \"topics\" && a.OriginTable != \"replies\" {\n\t\treturn c.LocalError(\"Unknown origin\", w, r, u)\n\t}\n\n\tif !u.Loggedin {\n\t\tw.Header().Set(\"Cache-Control\", maxAgeYear)\n\t} else {\n\t\tguest := c.GuestUser\n\t\t_, ferr := c.SimpleForumUserCheck(w, r, &guest, sid)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\th := w.Header()\n\t\tif guest.Perms.ViewTopic {\n\t\t\th.Set(\"Cache-Control\", maxAgeYear)\n\t\t} else {\n\t\t\th.Set(\"Cache-Control\", \"private\")\n\t\t}\n\t}\n\n\t// TODO: Fix the problem where non-existent files aren't greeted with custom 404s on ServeFile()'s side\n\thttp.ServeFile(w, r, \"./attachs/\"+filename)\n\treturn nil\n}\n\nfunc deleteAttachment(w http.ResponseWriter, r *http.Request, u *c.User, aid int, js bool) c.RouteError {\n\te := c.DeleteAttachment(aid)\n\tif e == sql.ErrNoRows {\n\t\treturn c.NotFoundJSQ(w, r, nil, js)\n\t} else if e != nil {\n\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t}\n\treturn nil\n}\n\n// TODO: Stop duplicating this code\n// TODO: Use a transaction here\n// TODO: Move this function to neutral ground\nfunc uploadAttachment(w http.ResponseWriter, r *http.Request, u *c.User, sid int, stable string, oid int, otable, extra string) (pathMap map[string]string, rerr c.RouteError) {\n\tpathMap = make(map[string]string)\n\tfiles, rerr := uploadFilesWithHash(w, r, u, \"./attachs/\")\n\tif rerr != nil {\n\t\treturn nil, rerr\n\t}\n\n\tfor _, filename := range files {\n\t\taid, err := c.Attachments.Add(sid, stable, oid, otable, u.ID, filename, extra)\n\t\tif err != nil {\n\t\t\treturn nil, c.InternalError(err, w, r)\n\t\t}\n\n\t\t_, ok := pathMap[filename]\n\t\tif ok {\n\t\t\tpathMap[filename] += \",\" + strconv.Itoa(aid)\n\t\t} else {\n\t\t\tpathMap[filename] = strconv.Itoa(aid)\n\t\t}\n\n\t\terr = c.Attachments.AddLinked(otable, oid)\n\t\tif err != nil {\n\t\t\treturn nil, c.InternalError(err, w, r)\n\t\t}\n\t}\n\n\treturn pathMap, nil\n}\n"
  },
  {
    "path": "routes/common.go",
    "content": "package routes\n\nimport (\n\t//\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\t\"github.com/Azareal/Gosora/uutils\"\n)\n\nvar successJSONBytes = []byte(`{\"success\":1}`)\n\nfunc ParseSEOURL(urlBit string) (slug string, id int, err error) {\n\thalves := strings.Split(urlBit, \".\")\n\tif len(halves) < 2 {\n\t\thalves = append(halves, halves[0])\n\t}\n\ttid, err := strconv.Atoi(halves[1])\n\treturn halves[0], tid, err\n}\n\nconst slen1 = len(\"</s/>;rel=preload;as=script,\") + 6\nconst slen2 = len(\"</s/>;rel=preload;as=style,\") + 7\n\nvar pushStrPool = sync.Pool{}\n\nfunc doPush(w http.ResponseWriter, h *c.Header) {\n\t//fmt.Println(\"in doPush\")\n\tif len(h.Scripts) == 0 && len(h.ScriptsAsync) == 0 && len(h.Stylesheets) == 0 {\n\t\treturn\n\t}\n\tif c.Config.EnableCDNPush {\n\t\tvar sb *strings.Builder = &strings.Builder{}\n\t\t/*ii := pushStrPool.Get()\n\t\tif ii == nil {\n\t\t\tsb = &strings.Builder{}\n\t\t} else {\n\t\t\tsb = ii.(*strings.Builder)\n\t\t\tsb.Reset()\n\t\t}*/\n\t\tsb.Grow((slen1 * (len(h.Scripts) + len(h.ScriptsAsync))) + ((slen2 + 7) * len(h.Stylesheets)))\n\t\tpush := func(in []c.HScript) {\n\t\t\tfor i, s := range in {\n\t\t\t\tif i != 0 {\n\t\t\t\t\tsb.WriteString(\",</s/\")\n\t\t\t\t} else {\n\t\t\t\t\tsb.WriteString(\"</s/\")\n\t\t\t\t}\n\t\t\t\tsb.WriteString(s.Name)\n\t\t\t\tsb.WriteString(\">;rel=preload;as=script\")\n\t\t\t}\n\t\t}\n\t\tpush(h.Scripts)\n\t\t//push(h.PreScriptsAsync)\n\t\tpush(h.ScriptsAsync)\n\n\t\tif len(h.Stylesheets) > 0 {\n\t\t\tfor i, s := range h.Stylesheets {\n\t\t\t\tif i != 0 {\n\t\t\t\t\tsb.WriteString(\",</s/\")\n\t\t\t\t} else {\n\t\t\t\t\tsb.WriteString(\"</s/\")\n\t\t\t\t}\n\t\t\t\tsb.WriteString(s.Name)\n\t\t\t\tsb.WriteString(\">;rel=preload;as=style\")\n\t\t\t}\n\t\t}\n\t\t// TODO: Push avatars?\n\n\t\tif sb.Len() > 0 {\n\t\t\tsbuf := sb.String()\n\t\t\tw.Header().Set(\"Link\", sbuf)\n\t\t\t//pushStrPool.Put(sb)\n\t\t}\n\t} else if !c.Config.DisableServerPush {\n\t\t//fmt.Println(\"push enabled\")\n\t\t/*if bzw, ok := w.(c.BrResponseWriter); ok {\n\t\t\tw = bzw.ResponseWriter\n\t\t} else */if gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\t\tw = gzw.ResponseWriter\n\t\t}\n\t\tpusher, ok := w.(http.Pusher)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\t//panic(\"has pusher\")\n\t\t//fmt.Println(\"has pusher\")\n\n\t\tvar sb *strings.Builder = &strings.Builder{}\n\t\t/*ii := pushStrPool.Get()\n\t\tif ii == nil {\n\t\t\tsb = &strings.Builder{}\n\t\t} else {\n\t\t\tsb = ii.(*strings.Builder)\n\t\t\tsb.Reset()\n\t\t}*/\n\t\tsb.Grow(6 * (len(h.Scripts) + len(h.ScriptsAsync) + len(h.Stylesheets)))\n\t\tpush := func(in []c.HScript) {\n\t\t\tfor _, s := range in {\n\t\t\t\t//fmt.Println(\"pushing /s/\" + path)\n\t\t\t\tsb.WriteString(\"/s/\")\n\t\t\t\tsb.WriteString(s.Name)\n\t\t\t\terr := pusher.Push(sb.String(), nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tsb.Reset()\n\t\t\t}\n\t\t}\n\t\tpush(h.Scripts)\n\t\t//push(h.PreScriptsAsync)\n\t\tpush(h.ScriptsAsync)\n\t\tpush(h.Stylesheets)\n\t\t// TODO: Push avatars?\n\t\t//pushStrPool.Put(sb)\n\t}\n}\n\nfunc renderTemplate(tmplName string, w http.ResponseWriter, r *http.Request, header *c.Header, pi interface{}) c.RouteError {\n\treturn renderTemplate2(tmplName, tmplName, w, r, header, pi)\n}\n\nfunc renderTemplate2(tmplName, hookName string, w http.ResponseWriter, r *http.Request, header *c.Header, pi interface{}) c.RouteError {\n\terr := renderTemplate3(tmplName, tmplName, w, r, header, pi)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn nil\n}\n\nfunc FootHeaders(w http.ResponseWriter, h *c.Header) {\n\t// TODO: Only set video domain when there is a video on the page\n\tif !h.LooseCSP {\n\t\the := w.Header()\n\t\tif c.Config.SslSchema {\n\t\t\tif h.ExternalMedia {\n\t\t\t\the.Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'\"+c.Config.ExtraCSPOrigins+\"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp;upgrade-insecure-requests\")\n\t\t\t} else {\n\t\t\t\the.Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'\"+c.Config.ExtraCSPOrigins+\"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self';upgrade-insecure-requests\")\n\t\t\t}\n\t\t} else {\n\t\t\tif h.ExternalMedia {\n\t\t\t\the.Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'\"+c.Config.ExtraCSPOrigins+\"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self' www.youtube-nocookie.com embed.nicovideo.jp\")\n\t\t\t} else {\n\t\t\t\the.Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'\"+c.Config.ExtraCSPOrigins+\"; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self'\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// Server pushes can backfire on certain browsers, so we want to make sure it's only triggered for ones where it'll help\n\tlastAgent := h.CurrentUser.LastAgent\n\t//fmt.Println(\"lastAgent:\", lastAgent)\n\tif lastAgent == c.Chrome || lastAgent == c.Firefox {\n\t\tdoPush(w, h)\n\t}\n}\n\nfunc renderTemplate3(tmplName, hookName string, w http.ResponseWriter, r *http.Request, h *c.Header, pi interface{}) error {\n\ts := h.Stylesheets\n\th.Stylesheets = nil\n\tnoDescSimpleBot := h.CurrentUser.LastAgent == c.SimpleBots[0] || h.CurrentUser.LastAgent == c.SimpleBots[1]\n\tvar simpleBot bool\n\tfor _, agent := range c.SimpleBots {\n\t\tif h.CurrentUser.LastAgent == agent {\n\t\t\tsimpleBot = true\n\t\t}\n\t}\n\tinner := r.FormValue(\"i\") == \"1\"\n\tif !inner && !simpleBot {\n\t\tc.PrepResources(h.CurrentUser, h, h.Theme)\n\t\tfor _, ss := range s {\n\t\t\th.Stylesheets = append(h.Stylesheets, ss)\n\t\t}\n\t\th.AddScript(\"global.js\")\n\t\tif h.CurrentUser.Loggedin {\n\t\t\th.AddScriptAsync(\"member.js\")\n\t\t}\n\t} else {\n\t\th.CurrentUser.LastAgent = 0\n\t}\n\n\tif h.CurrentUser.Loggedin || inner || noDescSimpleBot {\n\t\th.MetaDesc = \"\"\n\t\th.OGDesc = \"\"\n\t} else if h.MetaDesc != \"\" && h.OGDesc == \"\" {\n\t\th.OGDesc = h.MetaDesc\n\t}\n\n\tif !simpleBot {\n\t\tFootHeaders(w, h)\n\t} else {\n\t\th.GoogSiteVerify = \"\"\n\t}\n\tif h.Zone != \"error\" {\n\t\tsince := time.Duration(uutils.Nanotime() - h.StartedAt)\n\t\tif h.CurrentUser.IsAdmin {\n\t\t\th.Elapsed1 = since.String()\n\t\t}\n\t\tco.PerfCounter.Push(since /*, false*/)\n\t}\n\tif c.RunPreRenderHook(\"pre_render_\"+hookName, w, r, h.CurrentUser, pi) {\n\t\treturn nil\n\t}\n\t/*defer func() {\n\t\tc.StrSlicePool.Put(h.Scripts)\n\t\tc.StrSlicePool.Put(h.PreScriptsAsync)\n\t}()*/\n\treturn h.Theme.RunTmpl(tmplName, pi, w)\n}\n\n// TODO: Rename renderTemplate to RenderTemplate instead of using this hack to avoid breaking things\nvar RenderTemplate = renderTemplate3\n\nfunc actionSuccess(w http.ResponseWriter, r *http.Request, dest string, js bool) c.RouteError {\n\tif !js {\n\t\thttp.Redirect(w, r, dest, http.StatusSeeOther)\n\t} else {\n\t\t_, _ = w.Write(successJSONBytes)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "routes/convos.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"html\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t//\"log\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc convoNotice(h *c.Header) {\n\t//h.AddNotice(\"convo_dev\")\n\tc := rand.Intn(3)\n\th.AddNotice(\"convo_rand_\" + strconv.Itoa(c))\n}\n\nfunc Convos(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"convos\", w, r, user, h)\n\th.AddScript(\"convo.js\")\n\th.AddSheet(h.Theme.Name + \"/convo.css\")\n\tconvoNotice(h)\n\tccount := c.Convos.GetUserCount(user.ID)\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\toffset, page, lastPage := c.PageOffset(ccount, page, c.Config.ItemsPerPage)\n\tpageList := c.Paginate(page, lastPage, 5)\n\n\tconvos, err := c.Convos.GetUserExtra(user.ID, offset)\n\t//log.Printf(\"convos: %+v\\n\", convos)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar cRows []c.ConvoListRow\n\tfor _, convo := range convos {\n\t\tvar parti []*c.User\n\t\tnotMe := false\n\t\tfor _, u := range convo.Users {\n\t\t\tif u.ID == user.ID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tparti = append(parti, u)\n\t\t\tnotMe = true\n\t\t}\n\t\tif !notMe {\n\t\t\tparti = convo.Users\n\t\t}\n\t\tcRows = append(cRows, c.ConvoListRow{convo, parti, len(parti) == 1})\n\t}\n\n\tpi := c.Account{h, \"dashboard\", \"convos\", c.ConvoListPage{h, cRows, c.Paginator{pageList, page, lastPage}}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc Convo(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header, scid string) c.RouteError {\n\taccountEditHead(\"convo\", w, r, user, h)\n\th.AddSheet(h.Theme.Name + \"/convo.css\")\n\tconvoNotice(h)\n\tcid, err := strconv.Atoi(scid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\tconvo, err := c.Convos.Get(cid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tpcount := convo.PostsCount()\n\tif pcount == 0 {\n\t\treturn c.NotFound(w, r, h)\n\t}\n\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\toffset, page, lastPage := c.PageOffset(pcount, page, c.Config.ItemsPerPage)\n\tpageList := c.Paginate(page, lastPage, 5)\n\n\tposts, err := convo.Posts(offset, c.Config.ItemsPerPage)\n\t// TODO: Report a better error for no posts\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tuids, err := convo.Uids()\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tumap, err := c.Users.BulkGetMap(uids)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tusers := make([]*c.User, len(umap))\n\ti := 0\n\tfor _, user := range umap {\n\t\tusers[i] = user\n\t\ti++\n\t}\n\n\tpitems := make([]c.ConvoViewRow, len(posts))\n\tfor i, post := range posts {\n\t\tuuser, ok := umap[post.CreatedBy]\n\t\tif !ok {\n\t\t\treturn c.InternalError(errors.New(\"convo post creator not in umap\"), w, r)\n\t\t}\n\t\tcanModify := user.ID == post.CreatedBy || user.IsSuperMod\n\t\tpitems[i] = c.ConvoViewRow{post, uuser, \"\", 4, canModify}\n\t}\n\n\tcanReply := user.Perms.UseConvos || user.Perms.UseConvosOnlyWithMod\n\tif !user.Perms.UseConvos && user.Perms.UseConvosOnlyWithMod {\n\t\tu, err := c.Users.Get(convo.CreatedBy)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tif !u.IsSuperMod {\n\t\t\tcanReply = false\n\t\t}\n\t}\n\n\tpi := c.Account{h, \"dashboard\", \"convo\", c.ConvoViewPage{h, convo, pitems, users, canReply, c.Paginator{pageList, page, lastPage}}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc ConvosCreate(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header) c.RouteError {\n\taccountEditHead(\"create_convo\", w, r, user, h)\n\tif !user.Perms.UseConvos && !user.Perms.UseConvosOnlyWithMod {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tconvoNotice(h)\n\tuid, err := strconv.Atoi(r.FormValue(\"with\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"invalid integer in parameter with\", w, r, user)\n\t}\n\trecp, err := c.Users.Get(uid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Unable to fetch user\", w, r, user)\n\t}\n\t// TODO: Avoid potential double escape?\n\tpi := c.Account{h, \"dashboard\", \"create_convo\", c.ConvoCreatePage{h, html.EscapeString(recp.Name)}}\n\treturn renderTemplate(\"account\", w, r, h, pi)\n}\n\nfunc ConvosCreateSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.UseConvos && !user.Perms.UseConvosOnlyWithMod {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tsRecps := c.SanitiseSingleLine(r.PostFormValue(\"recp\"))\n\tbody := c.PreparseMessage(r.PostFormValue(\"body\"))\n\trlist := []int{}\n\n\t// De-dupe recipients\n\tvar recps []string\n\tunames := make(map[string]struct{})\n\tfor _, recp := range strings.Split(sRecps, \",\") {\n\t\trecp = strings.TrimSpace(recp)\n\t\t_, exists := unames[recp]\n\t\tif !exists {\n\t\t\trecps = append(recps, recp)\n\t\t\tunames[recp] = struct{}{}\n\t\t}\n\t}\n\n\tmax := 10 // max number of recipients that can be added at once\n\tfor i, recp := range recps {\n\t\tif i >= max {\n\t\t\tbreak\n\t\t}\n\n\t\tu, err := c.Users.GetByName(recp)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"One of the recipients doesn't exist\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t// TODO: Should we kick them out of existing conversations if they're moved into a group without permission or the permission is revoked from their group? We might want to give them a chance to delete their messages though to avoid privacy headaches here and it may only be temporarily to tackle a specific incident.\n\t\tif !u.Perms.UseConvos && !u.Perms.UseConvosOnlyWithMod {\n\t\t\treturn c.LocalError(\"One of the recipients doesn't have permission to use the conversations system\", w, r, user)\n\t\t}\n\t\tif !user.Perms.UseConvos && !u.IsSuperMod && user.Perms.UseConvosOnlyWithMod {\n\t\t\treturn c.LocalError(\"You are only allowed to message global moderators.\", w, r, user)\n\t\t}\n\t\tif !user.IsSuperMod && !u.Perms.UseConvos && u.Perms.UseConvosOnlyWithMod {\n\t\t\treturn c.LocalError(\"One of the recipients doesn't have permission to engage with conversation with you.\", w, r, user)\n\t\t}\n\t\tblocked, err := c.UserBlocks.IsBlockedBy(u.ID, user.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t// Supermods can bypass blocks so they can tell people off when they do something stupid or have to convey important information\n\t\tif blocked && !user.IsSuperMod {\n\t\t\treturn c.LocalError(\"You don't have permission to send messages to one of these users.\", w, r, user)\n\t\t}\n\n\t\trlist = append(rlist, u.ID)\n\t}\n\n\tcid, err := c.Convos.Create(body, user.ID, rlist)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Don't bother making the subscription if the convo creator is the only recipient?\n\tfor _, uid := range rlist {\n\t\tif uid == user.ID {\n\t\t\tcontinue\n\t\t}\n\t\terr := c.Subscriptions.Add(uid, cid, \"convo\")\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\terr = c.Subscriptions.Add(user.ID, cid, \"convo\")\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\terr = c.AddActivityAndNotifyAll(c.Alert{ActorID: user.ID, Event: \"create\", ElementType: \"convo\", ElementID: cid, Actor: user})\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/convo/\"+strconv.Itoa(cid), http.StatusSeeOther)\n\treturn nil\n}\n\n/*func ConvosDeleteSubmit(w http.ResponseWriter, r *http.Request, user *c.User, scid string) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tcid, err := strconv.Atoi(scid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\tif err := c.Convos.Delete(cid); err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\thttp.Redirect(w, r, \"/user/convos/\", http.StatusSeeOther)\n\treturn nil\n}*/\n\nfunc ConvosCreateReplySubmit(w http.ResponseWriter, r *http.Request, user *c.User, scid string) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.UseConvos && !user.Perms.UseConvosOnlyWithMod {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tcid, err := strconv.Atoi(scid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\n\tconvo, err := c.Convos.Get(cid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tpcount := convo.PostsCount()\n\tif pcount == 0 {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tif !convo.Has(user.ID) {\n\t\treturn c.LocalError(\"You are not in this conversation.\", w, r, user)\n\t}\n\t// TODO: Let the user reply if they're the convo creator in a convo with a mod\n\tif !user.Perms.UseConvos && user.Perms.UseConvosOnlyWithMod {\n\t\tu, err := c.Users.Get(convo.CreatedBy)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tif !u.IsSuperMod {\n\t\t\treturn c.LocalError(\"You're only allowed to talk to global moderators.\", w, r, user)\n\t\t}\n\t}\n\n\tbody := c.PreparseMessage(r.PostFormValue(\"content\"))\n\tpost := &c.ConversationPost{CID: cid, Body: body, CreatedBy: user.ID}\n\tpid, err := post.Create()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AddActivityAndNotifyAll(c.Alert{ActorID: user.ID, Event: \"reply\", ElementType: \"convo\", ElementID: cid, Actor: user, Extra: strconv.Itoa(pid)})\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/convo/\"+strconv.Itoa(convo.ID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ConvosDeleteReplySubmit(w http.ResponseWriter, r *http.Request, u *c.User, scpid string) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tcpid, err := strconv.Atoi(scpid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\n\tpost := &c.ConversationPost{ID: cpid}\n\terr = post.Fetch()\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tconvo, err := c.Convos.Get(post.CID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tpcount := convo.PostsCount()\n\tif pcount == 0 {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tif u.ID != post.CreatedBy && !u.IsSuperMod {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tposts, err := convo.Posts(0, c.Config.ItemsPerPage)\n\t// TODO: Report a better error for no posts\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tif post.ID == posts[0].ID {\n\t\terr = c.Convos.Delete(convo.ID)\n\t} else {\n\t\terr = post.Delete()\n\t}\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/convo/\"+strconv.Itoa(post.CID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ConvosEditReplySubmit(w http.ResponseWriter, r *http.Request, user *c.User, scpid string) c.RouteError {\n\t_, ferr := c.SimpleUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tcpid, err := strconv.Atoi(scpid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\tif !user.Perms.UseConvos {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tpost := &c.ConversationPost{ID: cpid}\n\terr = post.Fetch()\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tconvo, err := c.Convos.Get(post.CID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tpcount := convo.PostsCount()\n\tif pcount == 0 {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tif user.ID != post.CreatedBy && !user.IsSuperMod {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tif !convo.Has(user.ID) {\n\t\treturn c.LocalError(\"You are not in this conversation.\", w, r, user)\n\t}\n\n\tpost.Body = c.PreparseMessage(r.PostFormValue(\"edit_item\"))\n\terr = post.Update()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn actionSuccess(w, r, \"/user/convo/\"+strconv.Itoa(post.CID), js)\n}\n\nfunc RelationsBlockCreate(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, spid string) c.RouteError {\n\th.Title = p.GetTitlePhrase(\"create_block\")\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\tpuser, err := c.Users.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to block doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpi := c.Page{h, nil, c.AreYouSure{\"/user/block/create/submit/\" + strconv.Itoa(puser.ID), p.GetTmplPhrase(\"create_block_msg\")}}\n\treturn renderTemplate(\"are_you_sure\", w, r, h, pi)\n}\n\nfunc RelationsBlockCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User, spid string) c.RouteError {\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\tpuser, err := c.Users.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to block doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif u.ID == puser.ID {\n\t\treturn c.LocalError(\"You can't block yourself.\", w, r, u)\n\t}\n\n\terr = c.UserBlocks.Add(u.ID, puser.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(puser.ID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc RelationsBlockRemove(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, spid string) c.RouteError {\n\th.Title = p.GetTitlePhrase(\"remove_block\")\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\tpuser, err := c.Users.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to block doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpi := c.Page{h, nil, c.AreYouSure{\"/user/block/remove/submit/\" + strconv.Itoa(puser.ID), p.GetTmplPhrase(\"remove_block_msg\")}}\n\treturn renderTemplate(\"are_you_sure\", w, r, h, pi)\n}\n\nfunc RelationsBlockRemoveSubmit(w http.ResponseWriter, r *http.Request, u *c.User, spid string) c.RouteError {\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\tpuser, err := c.Users.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to unblock doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\terr = c.UserBlocks.Remove(u.ID, puser.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(puser.ID), http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/forum.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\n// TODO: Retire this in favour of an alias for /topics/?\nfunc ViewForum(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, sfid string) c.RouteError {\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\t_, fid, err := ParseSEOURL(sfid)\n\tif err != nil {\n\t\treturn c.SimpleError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, h)\n\t}\n\n\tferr := c.ForumUserCheck(h, w, r, u, fid)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\th.Path = \"/forums/\"\n\n\t// TODO: Fix this double-check\n\tforum, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\th.Title = forum.Name\n\th.OGDesc = forum.Desc\n\n\ttopicList, pagi, err := c.TopicList.GetListByForum(forum, page, 0)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\th.Zone = \"view_forum\"\n\th.ZoneID = forum.ID\n\n\t// TODO: Reduce the amount of boilerplate here\n\tif r.FormValue(\"js\") == \"1\" {\n\t\toutBytes, err := wsTopicList(topicList, pagi.LastPage).MarshalJSON()\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tw.Write(outBytes)\n\t\treturn nil\n\t}\n\n\ttopicList2 := make([]c.TopicsRowMut, len(topicList))\n\tcanMod := u.Perms.CloseTopic || u.Perms.MoveTopic\n\tfor i, t := range topicList {\n\t\ttopicList2[i] = c.TopicsRowMut{t, t.CreatedBy == u.ID || canMod}\n\t}\n\n\t//pageList := c.Paginate(page, lastPage, 5)\n\tpi := c.ForumPage{h, topicList2, forum, u.Perms.CloseTopic, u.Perms.MoveTopic, pagi}\n\ttmpl := forum.Tmpl\n\tif tmpl == \"\" {\n\t\tferr = renderTemplate(\"forum\", w, r, h, pi)\n\t} else {\n\t\ttmpl = \"forum_\" + tmpl\n\t\terr = renderTemplate3(tmpl, tmpl, w, r, h, pi)\n\t\tif err != nil {\n\t\t\tferr = renderTemplate(\"forum\", w, r, h, pi)\n\t\t}\n\t}\n\tco.ForumViewCounter.Bump(forum.ID)\n\treturn ferr\n}\n"
  },
  {
    "path": "routes/forum_list.go",
    "content": "package routes\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc ForumList(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\t/*skip, rerr := h.Hooks.VhookSkippable(\"route_forum_list_start\", w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}*/\n\tskip, rerr := c.H_route_forum_list_start_hook(h.Hooks, w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\th.Title = phrases.GetTitlePhrase(\"forums\")\n\th.Zone = \"forums\"\n\th.Path = \"/forums/\"\n\th.MetaDesc = h.Settings[\"meta_desc\"].(string)\n\n\tvar err error\n\tvar canSee []int\n\tif u.IsSuperAdmin {\n\t\tcanSee, err = c.Forums.GetAllVisibleIDs()\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t} else {\n\t\tg, err := c.Groups.Get(u.Group)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Group #%d doesn't exist despite being used by c.User #%d\", u.Group, u.ID)\n\t\t\treturn c.LocalError(\"Something weird happened\", w, r, u)\n\t\t}\n\t\tcanSee = g.CanSee\n\t}\n\n\tvar forumList []c.Forum\n\tfor _, fid := range canSee {\n\t\t// Avoid data races by copying the struct into something we can freely mold without worrying about breaking something somewhere else\n\t\tf := c.Forums.DirtyGet(fid).Copy()\n\t\tif f.ParentID == 0 && f.Name != \"\" && f.Active {\n\t\t\tif f.LastTopicID != 0 {\n\t\t\t\tif f.LastTopic.ID != 0 && f.LastReplyer.ID != 0 {\n\t\t\t\t\tf.LastTopicTime = c.RelativeTime(f.LastTopic.LastReplyAt)\n\t\t\t\t}\n\t\t\t}\n\t\t\t//h.Hooks.Hook(\"forums_frow_assign\", &f)\n\t\t\tc.H_forums_frow_assign_hook(h.Hooks, &f)\n\t\t\tforumList = append(forumList, f)\n\t\t}\n\t}\n\n\treturn renderTemplate(\"forums\", w, r, h, c.ForumsPage{h, forumList})\n}\n"
  },
  {
    "path": "routes/misc.go",
    "content": "package routes\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t//\"fmt\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n)\n\nvar cacheControlMaxAge = \"max-age=\" + strconv.Itoa(int(c.Day))      // TODO: Make this a c.Config value\nvar cacheControlMaxAgeWeek = \"max-age=\" + strconv.Itoa(int(c.Week)) // TODO: Make this a c.Config value\n\n// GET functions\nfunc StaticFile(w http.ResponseWriter, r *http.Request) {\n\tfile, ok := c.StaticFiles.Get(r.URL.Path)\n\tif !ok {\n\t\t//c.DebugLogf(\"Failed to find '%s'\", r.URL.Path) // TODO: Use MicroNotFound? Might be better than the unneccessary overhead of sprintf\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\treturn\n\t}\n\th := w.Header()\n\n\tif file.Length > 300 {\n\t\tif h.Get(\"Range\") != \"\" {\n\t\t\th.Set(\"Vary\", \"Accept-Encoding\")\n\t\t\tif len(file.Sha256) != 0 {\n\t\t\t\th.Set(\"Cache-Control\", cacheControlMaxAgeWeek)\n\t\t\t} else {\n\t\t\t\th.Set(\"Cache-Control\", cacheControlMaxAge) //Cache-Control: max-age=31536000\n\t\t\t}\n\t\t\tae := r.Header.Get(\"Accept-Encoding\")\n\n\t\t\tif file.BrLength > 300 && strings.Contains(ae, \"br\") {\n\t\t\t\th.Set(\"Content-Encoding\", \"br\")\n\t\t\t\th.Set(\"Content-Length\", file.StrBrLength)\n\t\t\t\thttp.ServeContent(w, r, r.URL.Path, file.Info.ModTime(), bytes.NewReader(file.BrData))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif file.GzipLength > 300 && strings.Contains(ae, \"gzip\") {\n\t\t\t\th.Set(\"Content-Encoding\", \"gzip\")\n\t\t\t\th.Set(\"Content-Length\", file.StrGzipLength)\n\t\t\t\thttp.ServeContent(w, r, r.URL.Path, file.Info.ModTime(), bytes.NewReader(file.GzipData))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\th.Set(\"Content-Length\", file.StrLength)\n\t\t\thttp.ServeContent(w, r, r.URL.Path, file.Info.ModTime(), bytes.NewReader(file.Data))\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Surely, there's a more efficient way of doing this?\n\tt, err := time.Parse(http.TimeFormat, r.Header.Get(\"If-Modified-Since\"))\n\tif err == nil && file.Info.ModTime().Before(t.Add(1*time.Second)) {\n\t\tw.WriteHeader(http.StatusNotModified)\n\t\treturn\n\t}\n\th.Set(\"Last-Modified\", file.FormattedModTime)\n\th.Set(\"Content-Type\", file.Mimetype)\n\tif len(file.Sha256) != 0 {\n\t\th.Set(\"Cache-Control\", cacheControlMaxAgeWeek)\n\t} else {\n\t\th.Set(\"Cache-Control\", cacheControlMaxAge) //Cache-Control: max-age=31536000\n\t}\n\th.Set(\"Vary\", \"Accept-Encoding\")\n\n\tif file.BrLength > 0 && strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"br\") {\n\t\th.Set(\"Content-Encoding\", \"br\")\n\t\th.Set(\"Content-Length\", file.StrBrLength)\n\t\tio.Copy(w, bytes.NewReader(file.BrData)) // Use w.Write instead?\n\t} else if file.GzipLength > 0 && strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\th.Set(\"Content-Encoding\", \"gzip\")\n\t\th.Set(\"Content-Length\", file.StrGzipLength)\n\t\tio.Copy(w, bytes.NewReader(file.GzipData)) // Use w.Write instead?\n\t} else {\n\t\th.Set(\"Content-Length\", file.StrLength)\n\t\tio.Copy(w, bytes.NewReader(file.Data))\n\t}\n\t// Other options instead of io.Copy: io.CopyN(), w.Write(), http.ServeContent()\n}\n\nfunc Overview(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\th.Title = phrases.GetTitlePhrase(\"overview\")\n\th.Zone = \"overview\"\n\treturn renderTemplate(\"overview\", w, r, h, c.Page{h, tList, nil})\n}\n\nfunc CustomPage(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, name string) c.RouteError {\n\th.Zone = \"custom_page\"\n\tname = c.SanitiseSingleLine(name)\n\tpage, err := c.Pages.GetByName(name)\n\tif err == nil {\n\t\th.Title = page.Title\n\t\treturn renderTemplate(\"custom_page\", w, r, h, c.CustomPagePage{h, page})\n\t} else if err != sql.ErrNoRows {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\th.Title = phrases.GetTitlePhrase(\"page\")\n\n\t// TODO: Pass the page name to the pre-render hook?\n\terr = renderTemplate3(\"page_\"+name, \"tmpl_page\", w, r, h, c.Page{h, tList, nil})\n\tif err == c.ErrBadDefaultTemplate {\n\t\treturn c.NotFound(w, r, h)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn nil\n}\n\n// TODO: Set the cookie domain\nfunc ChangeTheme(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t//headerLite, _ := SimpleUserCheck(w, r, u)\n\t// TODO: Rename js to something else, just in case we rewrite the JS side in WebAssembly?\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tnewTheme := c.SanitiseSingleLine(r.PostFormValue(\"theme\"))\n\t//fmt.Printf(\"newTheme: %+v\\n\", newTheme)\n\n\ttheme, ok := c.Themes[newTheme]\n\tif !ok || theme.HideFromThemes {\n\t\treturn c.LocalErrorJSQ(\"That theme doesn't exist\", w, r, u, js)\n\t}\n\n\tcookie := http.Cookie{Name: \"current_theme\", Value: newTheme, Path: \"/\", MaxAge: int(c.Year)}\n\thttp.SetCookie(w, &cookie)\n\treturn actionSuccess(w, r, \"/\", js)\n}\n"
  },
  {
    "path": "routes/moderate.go",
    "content": "package routes\n\nimport (\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc IPSearch(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\th.Title = phrases.GetTitlePhrase(\"ip_search\")\n\t// TODO: How should we handle the permissions if we extend this into an alt detector of sorts?\n\tif !u.Perms.ViewIPs {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\t// TODO: Reject IP Addresses with illegal characters\n\tip := c.SanitiseSingleLine(r.FormValue(\"ip\"))\n\tuids, err := c.IPSearch.Lookup(ip)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: What if a user is deleted via the Control Panel? We'll cross that bridge when we come to it, although we might lean towards blanking the account and removing the related data rather than purging it\n\tuserList, err := c.Users.BulkGetMap(uids)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn renderTemplate(\"ip_search\", w, r, h, c.IPSearchPage{h, userList, ip})\n}\n"
  },
  {
    "path": "routes/panel/analytics.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nfunc analyticsTimeRange(rawTimeRange string) (*c.AnalyticsTimeRange, error) {\n\ttr := &c.AnalyticsTimeRange{\n\t\tQuantity:   6,\n\t\tUnit:       \"hour\",\n\t\tSlices:     12,\n\t\tSliceWidth: 60 * 30,\n\t\tRange:      \"six-hours\",\n\t}\n\n\tswitch rawTimeRange {\n\t// This might be pushing it, we might want to come up with a more efficient scheme for dealing with large timeframes like this\n\tcase \"one-year\":\n\t\ttr.Quantity = 12\n\t\ttr.Unit = \"month\"\n\t\ttr.Slices = 12\n\t\ttr.SliceWidth = 60 * 60 * 24 * 30\n\tcase \"three-months\":\n\t\ttr.Quantity = 90\n\t\ttr.Unit = \"day\"\n\t\ttr.Slices = 30\n\t\ttr.SliceWidth = 60 * 60 * 24 * 3\n\tcase \"one-month\":\n\t\ttr.Quantity = 30\n\t\ttr.Unit = \"day\"\n\t\ttr.Slices = 30\n\t\ttr.SliceWidth = 60 * 60 * 24\n\tcase \"one-week\":\n\t\ttr.Quantity = 7\n\t\ttr.Unit = \"day\"\n\t\ttr.Slices = 14\n\t\ttr.SliceWidth = 60 * 60 * 12\n\tcase \"two-days\": // Two days is experimental\n\t\ttr.Quantity = 2\n\t\ttr.Unit = \"day\"\n\t\ttr.Slices = 24\n\t\ttr.SliceWidth = 60 * 60 * 2\n\tcase \"one-day\":\n\t\ttr.Quantity = 1\n\t\ttr.Unit = \"day\"\n\t\ttr.Slices = 24\n\t\ttr.SliceWidth = 60 * 60\n\tcase \"twelve-hours\":\n\t\ttr.Quantity = 12\n\t\ttr.Slices = 24\n\tcase \"six-hours\", \"\":\n\t\treturn tr, nil\n\tdefault:\n\t\treturn tr, errors.New(\"Unknown time range\")\n\t}\n\ttr.Range = rawTimeRange\n\treturn tr, nil\n}\n\ntype pAvg struct {\n\tAvg int64\n\tTot int64\n}\n\nfunc analyticsRowsToAverageMap(rows *sql.Rows, labelList []int64, avgMap map[int64]int64) (map[int64]int64, error) {\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar count int64\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&count, &createdAt)\n\t\tif e != nil {\n\t\t\treturn avgMap, e\n\t\t}\n\t\tunixCreatedAt := createdAt.Unix()\n\t\t// TODO: Bulk log this\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(\"count: \", count)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\t\tpAvgMap := make(map[int64]pAvg)\n\t\tfor _, value := range labelList {\n\t\t\tif unixCreatedAt > value {\n\t\t\t\tprev := pAvgMap[value]\n\t\t\t\tprev.Avg += count\n\t\t\t\tprev.Tot++\n\t\t\t\tpAvgMap[value] = prev\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tfor key, pAvg := range pAvgMap {\n\t\t\tavgMap[key] = pAvg.Avg / pAvg.Tot\n\t\t}\n\t}\n\treturn avgMap, rows.Err()\n}\n\nfunc analyticsRowsToAverageMap2(rows *sql.Rows, labelList []int64, avgMap map[int64]int64, typ int) (map[int64]int64, error) {\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar stack, heap int64\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&stack, &heap, &createdAt)\n\t\tif e != nil {\n\t\t\treturn avgMap, e\n\t\t}\n\t\tunixCreatedAt := createdAt.Unix()\n\t\t// TODO: Bulk log this\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(\"stack: \", stack)\n\t\t\tlog.Print(\"heap: \", heap)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\t\tif typ == 1 {\n\t\t\theap = 0\n\t\t} else if typ == 2 {\n\t\t\tstack = 0\n\t\t}\n\t\tpAvgMap := make(map[int64]pAvg)\n\t\tfor _, value := range labelList {\n\t\t\tif unixCreatedAt > value {\n\t\t\t\tprev := pAvgMap[value]\n\t\t\t\tprev.Avg += stack + heap\n\t\t\t\tprev.Tot++\n\t\t\t\tpAvgMap[value] = prev\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tfor key, pAvg := range pAvgMap {\n\t\t\tavgMap[key] = pAvg.Avg / pAvg.Tot\n\t\t}\n\t}\n\treturn avgMap, rows.Err()\n}\n\nfunc analyticsRowsToAverageMap3(rows *sql.Rows, labelList []int64, avgMap map[int64]int64, typ int) (map[int64]int64, error) {\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar low, high, avg int64\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&low, &high, &avg, &createdAt)\n\t\tif e != nil {\n\t\t\treturn avgMap, e\n\t\t}\n\t\tunixCreatedAt := createdAt.Unix()\n\t\t// TODO: Bulk log this\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(\"low: \", low)\n\t\t\tlog.Print(\"high: \", high)\n\t\t\tlog.Print(\"avg: \", avg)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\t\tvar dat int64\n\t\tswitch typ {\n\t\tcase 0:\n\t\t\tdat = low\n\t\tcase 1:\n\t\t\tdat = high\n\t\tdefault:\n\t\t\tdat = avg\n\t\t}\n\t\tpAvgMap := make(map[int64]pAvg)\n\t\tfor _, val := range labelList {\n\t\t\tif unixCreatedAt > val {\n\t\t\t\tprev := pAvgMap[val]\n\t\t\t\tprev.Avg += dat\n\t\t\t\tprev.Tot++\n\t\t\t\tpAvgMap[val] = prev\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tfor key, pAvg := range pAvgMap {\n\t\t\tavgMap[key] = pAvg.Avg / pAvg.Tot\n\t\t}\n\t}\n\treturn avgMap, rows.Err()\n}\n\nfunc PreAnalyticsDetail(w http.ResponseWriter, r *http.Request, u *c.User) (*c.BasePanelPage, c.RouteError) {\n\tbp, fe := buildBasePage(w, r, u, \"analytics\", \"analytics\")\n\tif fe != nil {\n\t\treturn nil, fe\n\t}\n\tbp.AddSheet(\"chartist/chartist.min.css\")\n\tbp.AddScript(\"chartist/chartist.min.js\")\n\tbp.AddScriptAsync(\"analytics.js\")\n\tbp.LooseCSP = true\n\treturn bp, nil\n}\n\nfunc createTimeGraph(series [][]int64, labelList []int64, legends ...[]string) c.PanelTimeGraph {\n\tvar llegends []string\n\tif len(legends) > 0 {\n\t\tllegends = legends[0]\n\t}\n\tgraph := c.PanelTimeGraph{Series: series, Labels: labelList, Legends: llegends}\n\tc.DebugLogf(\"graph: %+v\\n\", graph)\n\treturn graph\n}\n\nfunc CreateViewListItems(revLabelList []int64, viewMap map[int64]int64) ([]int64, []c.PanelAnalyticsItem) {\n\tviewList := make([]int64, len(revLabelList))\n\tviewItems := make([]c.PanelAnalyticsItem, len(revLabelList))\n\tfor i, val := range revLabelList {\n\t\tviewList[i] = viewMap[val]\n\t\tviewItems[i] = c.PanelAnalyticsItem{Time: val, Count: viewMap[val]}\n\t}\n\treturn viewList, viewItems\n}\n\nfunc AnalyticsViews(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, fe := PreAnalyticsDetail(w, r, u)\n\tif fe != nil {\n\t\treturn fe\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsViews\")\n\t// TODO: Add some sort of analytics store / iterator?\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks\", tr, labelList, viewMap, \"route\", \"\")\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList, viewItems := CreateViewListItems(revLabelList, viewMap)\n\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\tvar ttime string\n\tif tr.Range == \"six-hours\" || tr.Range == \"twelve-hours\" || tr.Range == \"one-day\" {\n\t\tttime = \"time\"\n\t}\n\n\tpi := c.PanelAnalyticsStd{graph, viewItems, tr.Range, tr.Unit, ttime}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_views\", pi})\n}\n\nfunc AnalyticsRouteViews(w http.ResponseWriter, r *http.Request, u *c.User, route string) c.RouteError {\n\tbp, fe := PreAnalyticsDetail(w, r, u)\n\tif fe != nil {\n\t\treturn fe\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsRouteViews\")\n\t// TODO: Validate the route is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks\", tr, labelList, viewMap, \"route\", route)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList, viewItems := CreateViewListItems(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tpi := c.PanelAnalyticsRoutePage{bp, c.SanitiseSingleLine(route), graph, viewItems, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_route_views\", pi})\n}\n\nfunc AnalyticsAgentViews(w http.ResponseWriter, r *http.Request, u *c.User, agent string) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\t// ? Only allow valid agents? The problem with this is that agents wind up getting renamed and it would take a migration to get them all up to snuff\n\tagent = c.SanitiseSingleLine(agent)\n\n\tc.DebugLog(\"in panel.AnalyticsAgentViews\")\n\t// TODO: Verify the agent is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks_agents\", tr, labelList, viewMap, \"browser\", agent)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList := CreateViewList(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tfriendlyAgent, ok := p.GetUserAgentPhrase(agent)\n\tif !ok {\n\t\tfriendlyAgent = agent\n\t}\n\n\tpi := c.PanelAnalyticsAgentPage{bp, agent, friendlyAgent, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_agent_views\", pi})\n}\n\nfunc AnalyticsForumViews(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tfid, e := strconv.Atoi(sfid)\n\tif e != nil {\n\t\treturn c.LocalError(\"Invalid integer\", w, r, u)\n\t}\n\n\tc.DebugLog(\"in panel.AnalyticsForumViews\")\n\t// TODO: Verify the agent is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks_forums\", tr, labelList, viewMap, \"forum\", fid)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList := CreateViewList(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tforum, e := c.Forums.Get(fid)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tpi := c.PanelAnalyticsAgentPage{bp, sfid, forum.Name, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_forum_views\", pi})\n}\n\nfunc AnalyticsSystemViews(w http.ResponseWriter, r *http.Request, u *c.User, system string) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\tsystem = c.SanitiseSingleLine(system)\n\n\tc.DebugLog(\"in panel.AnalyticsSystemViews\")\n\t// TODO: Verify the OS name is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks_systems\", tr, labelList, viewMap, \"system\", system)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList := CreateViewList(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tfriendlySystem, ok := p.GetOSPhrase(system)\n\tif !ok {\n\t\tfriendlySystem = system\n\t}\n\n\tpi := c.PanelAnalyticsAgentPage{bp, system, friendlySystem, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_system_views\", pi})\n}\n\nfunc CreateViewList(revLabelList []int64, viewMap map[int64]int64) []int64 {\n\tviewList := make([]int64, len(revLabelList))\n\tfor i, val := range revLabelList {\n\t\tviewList[i] = viewMap[val]\n\t}\n\treturn viewList\n}\n\nfunc AnalyticsLanguageViews(w http.ResponseWriter, r *http.Request, u *c.User, lang string) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\tlang = c.SanitiseSingleLine(lang)\n\n\tc.DebugLog(\"in panel.AnalyticsLanguageViews\")\n\t// TODO: Verify the language code is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks_langs\", tr, labelList, viewMap, \"lang\", lang)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList := CreateViewList(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tfriendlyLang, ok := p.GetHumanLangPhrase(lang)\n\tif !ok {\n\t\tfriendlyLang = lang\n\t}\n\n\tpi := c.PanelAnalyticsAgentPage{bp, lang, friendlyLang, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_lang_views\", pi})\n}\n\nfunc AnalyticsReferrerViews(w http.ResponseWriter, r *http.Request, u *c.User, domain string) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsReferrerViews\")\n\t// TODO: Verify the agent is valid\n\tviewMap, e = c.Analytics.FillViewMap(\"viewchunks_referrers\", tr, labelList, viewMap, \"domain\", domain)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList := CreateViewList(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tpi := c.PanelAnalyticsAgentPage{bp, c.SanitiseSingleLine(domain), \"\", graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_referrer_views\", pi})\n}\n\nfunc AnalyticsTopics(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsTopics\")\n\tviewMap, e = c.Analytics.FillViewMap(\"topicchunks\", tr, labelList, viewMap, \"\")\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList, viewItems := CreateViewListItems(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tpi := c.PanelAnalyticsStd{graph, viewItems, tr.Range, tr.Unit, \"time\"}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_topics\", pi})\n}\n\nfunc AnalyticsPosts(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, fe := PreAnalyticsDetail(w, r, u)\n\tif fe != nil {\n\t\treturn fe\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsPosts\")\n\tviewMap, e = c.Analytics.FillViewMap(\"postchunks\", tr, labelList, viewMap, \"\")\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tviewList, viewItems := CreateViewListItems(revLabelList, viewMap)\n\tgraph := createTimeGraph([][]int64{viewList}, labelList)\n\n\tpi := c.PanelAnalyticsStd{graph, viewItems, tr.Range, tr.Unit, \"time\"}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_posts\", pi})\n}\n\nfunc AnalyticsMemory(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, fe := PreAnalyticsDetail(w, r, u)\n\tif fe != nil {\n\t\treturn fe\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, avgMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsMemory\")\n\trows, e := qgen.NewAcc().Select(\"memchunks\").Columns(\"count,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tavgMap, e = analyticsRowsToAverageMap(rows, labelList, avgMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\t// TODO: Adjust for the missing chunks in week and month\n\tavgList := make([]int64, len(revLabelList))\n\tavgItems := make([]c.PanelAnalyticsItemUnit, len(revLabelList))\n\tfor i, value := range revLabelList {\n\t\tavgList[i] = avgMap[value]\n\t\tcv, cu := c.ConvertByteUnit(float64(avgMap[value]))\n\t\tavgItems[i] = c.PanelAnalyticsItemUnit{Time: value, Unit: cu, Count: int64(cv)}\n\t}\n\tgraph := createTimeGraph([][]int64{avgList}, labelList)\n\n\tpi := c.PanelAnalyticsStdUnit{graph, avgItems, tr.Range, tr.Unit, \"time\"}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_memory\", pi})\n}\n\n// TODO: Show stack and heap memory separately on the chart\nfunc AnalyticsActiveMemory(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, avgMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsActiveMemory\")\n\trows, e := qgen.NewAcc().Select(\"memchunks\").Columns(\"stack,heap,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tvar typ int\n\tswitch r.FormValue(\"mtype\") {\n\tcase \"1\":\n\t\ttyp = 1\n\tcase \"2\":\n\t\ttyp = 2\n\tdefault:\n\t\ttyp = 0\n\t}\n\tavgMap, e = analyticsRowsToAverageMap2(rows, labelList, avgMap, typ)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\t// TODO: Adjust for the missing chunks in week and month\n\tavgList := make([]int64, len(revLabelList))\n\tavgItems := make([]c.PanelAnalyticsItemUnit, len(revLabelList))\n\tfor i, value := range revLabelList {\n\t\tavgList[i] = avgMap[value]\n\t\tcv, cu := c.ConvertByteUnit(float64(avgMap[value]))\n\t\tavgItems[i] = c.PanelAnalyticsItemUnit{Time: value, Unit: cu, Count: int64(cv)}\n\t}\n\tgraph := createTimeGraph([][]int64{avgList}, labelList)\n\n\tpi := c.PanelAnalyticsActiveMemory{graph, avgItems, tr.Range, tr.Unit, \"time\", typ}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_active_memory\", pi})\n}\n\nfunc AnalyticsPerf(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, avgMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\tc.DebugLog(\"in panel.AnalyticsPerf\")\n\trows, e := qgen.NewAcc().Select(\"perfchunks\").Columns(\"low,high,avg,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tvar typ int\n\tswitch r.FormValue(\"type\") {\n\tcase \"0\":\n\t\ttyp = 0\n\tcase \"1\":\n\t\ttyp = 1\n\tdefault:\n\t\ttyp = 2\n\t}\n\tavgMap, e = analyticsRowsToAverageMap3(rows, labelList, avgMap, typ)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\t// TODO: Adjust for the missing chunks in week and month\n\tavgList := make([]int64, len(revLabelList))\n\tavgItems := make([]c.PanelAnalyticsItemUnit, len(revLabelList))\n\tfor i, value := range revLabelList {\n\t\tavgList[i] = avgMap[value]\n\t\tcv, cu := c.ConvertPerfUnit(float64(avgMap[value]))\n\t\tavgItems[i] = c.PanelAnalyticsItemUnit{Time: value, Unit: cu, Count: int64(cv)}\n\t}\n\tgraph := createTimeGraph([][]int64{avgList}, labelList)\n\n\tpi := c.PanelAnalyticsPerf{graph, avgItems, tr.Range, tr.Unit, \"time\", typ}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_performance\", pi})\n}\n\nfunc analyticsRowsToAvgDuoMap(rows *sql.Rows, labelList []int64, avgMap map[int64]int64) (map[string]map[int64]int64, map[string]int, error) {\n\taMap := make(map[string]map[int64]int64)\n\tnameMap := make(map[string]int)\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar count int64\n\t\tvar name string\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&count, &name, &createdAt)\n\t\tif e != nil {\n\t\t\treturn aMap, nameMap, e\n\t\t}\n\n\t\t// TODO: Bulk log this\n\t\tunixCreatedAt := createdAt.Unix()\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(\"count: \", count)\n\t\t\tlog.Print(\"name: \", name)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\n\t\tvvMap, ok := aMap[name]\n\t\tif !ok {\n\t\t\tvvMap = make(map[int64]int64)\n\t\t\tfor key, val := range avgMap {\n\t\t\t\tvvMap[key] = val\n\t\t\t}\n\t\t\taMap[name] = vvMap\n\t\t}\n\t\tfor _, value := range labelList {\n\t\t\tif unixCreatedAt > value {\n\t\t\t\tvvMap[value] = (vvMap[value] + count) / 2\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tnameMap[name] = (nameMap[name] + int(count)) / 2\n\t}\n\treturn aMap, nameMap, rows.Err()\n}\n\nfunc sortOVList(ovList []OVItem) []OVItem {\n\t// Use bubble sort for now as there shouldn't be too many items\n\tfor i := 0; i < len(ovList)-1; i++ {\n\t\tfor j := 0; j < len(ovList)-1; j++ {\n\t\t\tif ovList[j].count > ovList[j+1].count {\n\t\t\t\ttemp := ovList[j]\n\t\t\t\tovList[j] = ovList[j+1]\n\t\t\t\tovList[j+1] = temp\n\t\t\t}\n\t\t}\n\t}\n\n\t// Invert the direction\n\ttOVList := make([]OVItem, len(ovList))\n\tfor i, ii := len(ovList)-1, 0; i >= 0; i-- {\n\t\ttOVList[ii] = ovList[i]\n\t\tii++\n\t}\n\treturn tOVList\n}\n\nfunc analyticsAMapToOVList(aMap map[string]map[int64]int64) []OVItem {\n\t// Order the map\n\tovList, i := make([]OVItem, len(aMap)), 0\n\tfor name, avgMap := range aMap {\n\t\tvar totcount int\n\t\tfor _, count := range avgMap {\n\t\t\ttotcount = (totcount + int(count)) / 2\n\t\t}\n\t\tovList[i] = OVItem{name, totcount, avgMap}\n\t\ti++\n\t}\n\treturn sortOVList(ovList)\n}\n\nfunc AnalyticsRoutesPerf(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\t// avgMap contains timestamps but not the averages for those stamps\n\trevLabelList, labelList, avgMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks\").Columns(\"avg,route,createdAt\").Where(\"count!=0 AND route!=''\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\taMap, routeMap, e := analyticsRowsToAvgDuoMap(rows, labelList, avgMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\t//c.DebugLogf(\"aMap: %+v\\n\", aMap)\n\t//c.DebugLogf(\"routeMap: %+v\\n\", routeMap)\n\tovList := analyticsAMapToOVList(aMap)\n\t//c.DebugLogf(\"ovList: %+v\\n\", ovList)\n\n\tex := strings.Split(r.FormValue(\"ex\"), \",\")\n\tinEx := func(name string) bool {\n\t\tfor _, e := range ex {\n\t\t\tif e == name {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tif inEx(ovitem.name) {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasPrefix(ovitem.name, \"panel.\") {\n\t\t\tcontinue\n\t\t}\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor i, val := range revLabelList {\n\t\t\tviewList[i] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tshortName := strings.Replace(ovitem.name, \"routes.\", \"r.\", -1)\n\t\tlegendList = append(legendList, shortName)\n\t\tif i >= 7 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Sort this slice\n\tvar routeItems []c.PanelAnalyticsRoutesPerfItem\n\tfor route, count := range routeMap {\n\t\tif inEx(route) {\n\t\t\tcontinue\n\t\t}\n\t\tcv, cu := c.ConvertPerfUnit(float64(count))\n\t\trouteItems = append(routeItems, c.PanelAnalyticsRoutesPerfItem{\n\t\t\tRoute: route,\n\t\t\tUnit:  cu,\n\t\t\tCount: int(cv),\n\t\t})\n\t}\n\n\tpi := c.PanelAnalyticsRoutesPerfPage{bp, routeItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_routes_perf\", pi})\n}\n\nfunc analyticsRowsToRefMap(rows *sql.Rows) (map[string]int, error) {\n\tnameMap := make(map[string]int)\n\tdefer rows.Close()\n\tc.DebugDetail(\"name - count\")\n\tfor rows.Next() {\n\t\tvar count int\n\t\tvar name string\n\t\te := rows.Scan(&count, &name)\n\t\tif e != nil {\n\t\t\treturn nameMap, e\n\t\t}\n\t\t// TODO: Bulk log this\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(name, \" - \", count)\n\t\t}\n\t\tnameMap[name] += count\n\t}\n\treturn nameMap, rows.Err()\n}\n\nfunc analyticsRowsToDuoMap(rows *sql.Rows, labelList []int64, viewMap map[int64]int64) (map[string]map[int64]int64, map[string]int, error) {\n\tvMap := make(map[string]map[int64]int64)\n\tnameMap := make(map[string]int)\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar count int64\n\t\tvar name string\n\t\tvar createdAt time.Time\n\t\te := rows.Scan(&count, &name, &createdAt)\n\t\tif e != nil {\n\t\t\treturn vMap, nameMap, e\n\t\t}\n\n\t\t// TODO: Bulk log this\n\t\tunixCreatedAt := createdAt.Unix()\n\t\tif c.Dev.SuperDebug {\n\t\t\tlog.Print(\"count: \", count)\n\t\t\tlog.Print(\"name: \", name)\n\t\t\tlog.Print(\"createdAt: \", createdAt, \" - \", unixCreatedAt)\n\t\t}\n\n\t\tvvMap, ok := vMap[name]\n\t\tif !ok {\n\t\t\tvvMap = make(map[int64]int64)\n\t\t\tfor key, val := range viewMap {\n\t\t\t\tvvMap[key] = val\n\t\t\t}\n\t\t\tvMap[name] = vvMap\n\t\t}\n\t\tfor _, value := range labelList {\n\t\t\tif unixCreatedAt > value {\n\t\t\t\tvvMap[value] += count\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tnameMap[name] += int(count)\n\t}\n\treturn vMap, nameMap, rows.Err()\n}\n\ntype OVItem struct {\n\tname    string\n\tcount   int\n\tviewMap map[int64]int64\n}\n\nfunc analyticsVMapToOVList(vMap map[string]map[int64]int64) (ovList []OVItem) {\n\t// Order the map\n\tovList, i := make([]OVItem, len(vMap)), 0\n\tfor name, viewMap := range vMap {\n\t\tvar totcount int\n\t\tfor _, count := range viewMap {\n\t\t\ttotcount += int(count)\n\t\t}\n\t\tovList[i] = OVItem{name, totcount, viewMap}\n\t\ti++\n\t}\n\treturn sortOVList(ovList)\n}\n\nfunc AnalyticsForums(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks_forums\").Columns(\"count,forum,createdAt\").Where(\"forum!=''\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tvMap, forumMap, e := analyticsRowsToDuoMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tovList := analyticsVMapToOVList(vMap)\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor i, val := range revLabelList {\n\t\t\tviewList[i] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tfid, e := strconv.Atoi(ovitem.name)\n\t\tif e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t\tvar lName string\n\t\tforum, e := c.Forums.Get(fid)\n\t\tif e == sql.ErrNoRows {\n\t\t\tlName = \"Deleted Forum\" // TODO: Localise this\n\t\t} else if e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t} else {\n\t\t\tlName = forum.Name\n\t\t}\n\t\tlegendList = append(legendList, lName)\n\t\tif i >= 6 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Sort this slice\n\tforumItems, i := make([]c.PanelAnalyticsAgentsItem, len(forumMap)), 0\n\tfor sfid, count := range forumMap {\n\t\tfid, e := strconv.Atoi(sfid)\n\t\tif e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t}\n\t\tvar lName string\n\t\tforum, e := c.Forums.Get(fid)\n\t\tif e == sql.ErrNoRows {\n\t\t\t// TODO: Localise this\n\t\t\tlName = \"Deleted Forum\"\n\t\t} else if e != nil {\n\t\t\treturn c.InternalError(e, w, r)\n\t\t} else {\n\t\t\tlName = forum.Name\n\t\t}\n\t\tforumItems[i] = c.PanelAnalyticsAgentsItem{\n\t\t\tAgent:         sfid,\n\t\t\tFriendlyAgent: lName,\n\t\t\tCount:         count,\n\t\t}\n\t\ti++\n\t}\n\n\tpi := c.PanelAnalyticsDuoPage{bp, forumItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_forums\", pi})\n}\n\nfunc AnalyticsRoutes(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks\").Columns(\"count,route,createdAt\").Where(\"route!=''\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tvMap, routeMap, e := analyticsRowsToDuoMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\t//c.DebugLogf(\"vMap: %+v\\n\", vMap)\n\t//c.DebugLogf(\"routeMap: %+v\\n\", routeMap)\n\tovList := analyticsVMapToOVList(vMap)\n\t//c.DebugLogf(\"ovList: %+v\\n\", ovList)\n\n\tex := strings.Split(r.FormValue(\"ex\"), \",\")\n\tinEx := func(name string) bool {\n\t\tfor _, e := range ex {\n\t\t\tif e == name {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tif inEx(ovitem.name) {\n\t\t\tcontinue\n\t\t}\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor i, val := range revLabelList {\n\t\t\tviewList[i] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tshortName := strings.Replace(ovitem.name, \"routes.\", \"r.\", -1)\n\t\tlegendList = append(legendList, shortName)\n\t\tif i >= 7 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Sort this slice\n\tvar routeItems []c.PanelAnalyticsRoutesItem\n\tfor route, count := range routeMap {\n\t\tif inEx(route) {\n\t\t\tcontinue\n\t\t}\n\t\trouteItems = append(routeItems, c.PanelAnalyticsRoutesItem{\n\t\t\tRoute: route,\n\t\t\tCount: count,\n\t\t})\n\t}\n\n\tpi := c.PanelAnalyticsRoutesPage{bp, routeItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_routes\", pi})\n}\n\n// Trialling multi-series charts\nfunc AnalyticsAgents(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks_agents\").Columns(\"count,browser,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tvMap, agentMap, e := analyticsRowsToDuoMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tovList := analyticsVMapToOVList(vMap)\n\n\tex := strings.Split(r.FormValue(\"ex\"), \",\")\n\tinEx := func(name string) bool {\n\t\tfor _, e := range ex {\n\t\t\tif e == name {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tif inEx(ovitem.name) {\n\t\t\tcontinue\n\t\t}\n\t\tlName, ok := p.GetUserAgentPhrase(ovitem.name)\n\t\tif !ok {\n\t\t\tlName = ovitem.name\n\t\t}\n\t\tif inEx(lName) {\n\t\t\tcontinue\n\t\t}\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor i, val := range revLabelList {\n\t\t\tviewList[i] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tlegendList = append(legendList, lName)\n\t\tif i >= 7 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Sort this slice\n\tvar agentItems []c.PanelAnalyticsAgentsItem\n\tfor agent, count := range agentMap {\n\t\tif inEx(agent) {\n\t\t\tcontinue\n\t\t}\n\t\taAgent, ok := p.GetUserAgentPhrase(agent)\n\t\tif !ok {\n\t\t\taAgent = agent\n\t\t}\n\t\tif inEx(aAgent) {\n\t\t\tcontinue\n\t\t}\n\t\tagentItems = append(agentItems, c.PanelAnalyticsAgentsItem{\n\t\t\tAgent:         agent,\n\t\t\tFriendlyAgent: aAgent,\n\t\t\tCount:         count,\n\t\t})\n\t}\n\n\tpi := c.PanelAnalyticsDuoPage{bp, agentItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_agents\", pi})\n}\n\nfunc AnalyticsSystems(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks_systems\").Columns(\"count,system,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tvMap, osMap, e := analyticsRowsToDuoMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tovList := analyticsVMapToOVList(vMap)\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor ii, val := range revLabelList {\n\t\t\tviewList[ii] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tlName, ok := p.GetOSPhrase(ovitem.name)\n\t\tif !ok {\n\t\t\tlName = ovitem.name\n\t\t}\n\t\tlegendList = append(legendList, lName)\n\t\tif i >= 6 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Sort this slice\n\tsystemItems, i := make([]c.PanelAnalyticsAgentsItem, len(osMap)), 0\n\tfor system, count := range osMap {\n\t\tsSystem, ok := p.GetOSPhrase(system)\n\t\tif !ok {\n\t\t\tsSystem = system\n\t\t}\n\t\tsystemItems[i] = c.PanelAnalyticsAgentsItem{\n\t\t\tAgent:         system,\n\t\t\tFriendlyAgent: sSystem,\n\t\t\tCount:         count,\n\t\t}\n\t\ti++\n\t}\n\n\tpi := c.PanelAnalyticsDuoPage{bp, systemItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_systems\", pi})\n}\n\nfunc AnalyticsLanguages(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := PreAnalyticsDetail(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tbp.AddScript(\"chartist/chartist-plugin-legend.min.js\")\n\tbp.AddSheet(\"chartist/chartist-plugin-legend.css\")\n\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\trevLabelList, labelList, viewMap := c.AnalyticsTimeRangeToLabelList(tr)\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks_langs\").Columns(\"count,lang,createdAt\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tvMap, langMap, e := analyticsRowsToDuoMap(rows, labelList, viewMap)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tovList := analyticsVMapToOVList(vMap)\n\n\tex := strings.Split(r.FormValue(\"ex\"), \",\")\n\tinEx := func(name string) bool {\n\t\tfor _, e := range ex {\n\t\t\tif e == name {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\tvar vList [][]int64\n\tvar legendList []string\n\tvar i int\n\tfor _, ovitem := range ovList {\n\t\tif inEx(ovitem.name) {\n\t\t\tcontinue\n\t\t}\n\t\tlName, ok := p.GetHumanLangPhrase(ovitem.name)\n\t\tif !ok {\n\t\t\tlName = ovitem.name\n\t\t}\n\t\tif inEx(lName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tviewList := make([]int64, len(revLabelList))\n\t\tfor _, val := range revLabelList {\n\t\t\tviewList[i] = ovitem.viewMap[val]\n\t\t}\n\t\tvList = append(vList, viewList)\n\t\tlegendList = append(legendList, lName)\n\t\tif i >= 6 {\n\t\t\tbreak\n\t\t}\n\t\ti++\n\t}\n\tgraph := createTimeGraph(vList, labelList, legendList)\n\n\t// TODO: Can we de-duplicate these analytics functions further?\n\t// TODO: Sort this slice\n\tvar langItems []c.PanelAnalyticsAgentsItem\n\tfor lang, count := range langMap {\n\t\tif inEx(lang) {\n\t\t\tcontinue\n\t\t}\n\t\tlLang, ok := p.GetHumanLangPhrase(lang)\n\t\tif !ok {\n\t\t\tlLang = lang\n\t\t}\n\t\tif inEx(lLang) {\n\t\t\tcontinue\n\t\t}\n\t\tlangItems = append(langItems, c.PanelAnalyticsAgentsItem{\n\t\t\tAgent:         lang,\n\t\t\tFriendlyAgent: lLang,\n\t\t\tCount:         count,\n\t\t})\n\t}\n\n\tpi := c.PanelAnalyticsDuoPage{bp, langItems, graph, tr.Range}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_langs\", pi})\n}\n\nfunc AnalyticsReferrers(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"analytics\", \"analytics\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\ttr, e := analyticsTimeRange(r.FormValue(\"timeRange\"))\n\tif e != nil {\n\t\treturn c.LocalError(e.Error(), w, r, u)\n\t}\n\n\trows, e := qgen.NewAcc().Select(\"viewchunks_referrers\").Columns(\"count,domain\").DateCutoff(\"createdAt\", tr.Quantity, tr.Unit).Query()\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\trefMap, e := analyticsRowsToRefMap(rows)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tshowSpam := r.FormValue(\"spam\") == \"1\"\n\n\tisSpammy := func(domain string) bool {\n\t\tfor _, substr := range c.SpammyDomainBits {\n\t\t\tif strings.Contains(domain, substr) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\n\t// TODO: Sort this slice\n\tvar refItems []c.PanelAnalyticsAgentsItem\n\tfor domain, count := range refMap {\n\t\tsdomain := c.SanitiseSingleLine(domain)\n\t\tif !showSpam && isSpammy(sdomain) {\n\t\t\tcontinue\n\t\t}\n\t\trefItems = append(refItems, c.PanelAnalyticsAgentsItem{\n\t\t\tAgent: sdomain,\n\t\t\tCount: count,\n\t\t})\n\t}\n\n\tpi := c.PanelAnalyticsReferrersPage{bp, refItems, tr.Range, showSpam}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_analytics_right\", \"analytics\", \"panel_analytics_referrers\", pi})\n}\n"
  },
  {
    "path": "routes/panel/backups.go",
    "content": "package panel\n\nimport (\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc Backups(w http.ResponseWriter, r *http.Request, u *c.User, backupURL string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"backups\", \"backups\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tif backupURL != \"\" {\n\t\t// We don't want them trying to break out of this directory, it shouldn't hurt since it's a super admin, but it's always good to practice good security hygiene, especially if this is one of many instances on a managed server not controlled by the superadmin/s\n\t\tbackupURL = c.Stripslashes(backupURL)\n\n\t\text := filepath.Ext(\"./backups/\" + backupURL)\n\t\tif ext != \".sql\" && ext != \".zip\" {\n\t\t\treturn c.NotFound(w, r, basePage.Header)\n\t\t}\n\t\tinfo, err := os.Stat(\"./backups/\" + backupURL)\n\t\tif err != nil {\n\t\t\treturn c.NotFound(w, r, basePage.Header)\n\t\t}\n\n\t\th := w.Header()\n\t\th.Set(\"Content-Length\", strconv.FormatInt(info.Size(), 10))\n\t\tif ext == \".sql\" {\n\t\t\t// TODO: Change the served filename to gosora_backup_%timestamp%.sql, the time the file was generated, not when it was modified aka what the name of it should be\n\t\t\th.Set(\"Content-Disposition\", \"attachment; filename=gosora_backup.sql\")\n\t\t\th.Set(\"Content-Type\", \"application/sql\")\n\t\t} else {\n\t\t\t// TODO: Change the served filename to gosora_backup_%timestamp%.zip, the time the file was generated, not when it was modified aka what the name of it should be\n\t\t\th.Set(\"Content-Disposition\", \"attachment; filename=gosora_backup.zip\")\n\t\t\th.Set(\"Content-Type\", \"application/zip\")\n\t\t}\n\t\t// TODO: Fix the problem where non-existent files aren't greeted with custom 404s on ServeFile()'s side\n\t\thttp.ServeFile(w, r, \"./backups/\"+backupURL)\n\t\terr = c.AdminLogs.Create(\"download\", 0, \"backup\", u.GetIP(), u.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\treturn nil\n\t}\n\n\tvar backupList []c.BackupItem\n\tbackupFiles, err := ioutil.ReadDir(\"./backups\")\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tfor _, backupFile := range backupFiles {\n\t\text := filepath.Ext(backupFile.Name())\n\t\tif ext != \".sql\" {\n\t\t\tcontinue\n\t\t}\n\t\tbackupList = append(backupList, c.BackupItem{backupFile.Name(), backupFile.ModTime()})\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_backups\", c.PanelBackupPage{basePage, backupList}})\n}\n"
  },
  {
    "path": "routes/panel/common.go",
    "content": "package panel\n\nimport (\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\n// A blank list to fill out that parameter in Page for routes which don't use it\nvar tList []interface{}\nvar successJSONBytes = []byte(`{\"success\":1}`)\n\n// We're trying to reduce the amount of boilerplate in here, so I added these two functions, they might wind up circulating outside this file in the future\nfunc successRedirect(dest string, w http.ResponseWriter, r *http.Request, js bool) c.RouteError {\n\tif !js {\n\t\thttp.Redirect(w, r, dest, http.StatusSeeOther)\n\t} else {\n\t\tw.Write(successJSONBytes)\n\t}\n\treturn nil\n}\n\n// TODO: Prerender needs to handle dyntmpl templates better...\nfunc renderTemplate(tmplName string, w http.ResponseWriter, r *http.Request, h *c.Header, pi interface{}) c.RouteError {\n\tif !h.LooseCSP {\n\t\tif c.Config.SslSchema {\n\t\t\tw.Header().Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self';upgrade-insecure-requests\")\n\t\t} else {\n\t\t\tw.Header().Set(\"Content-Security-Policy\", \"default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; img-src * data: 'unsafe-eval' 'unsafe-inline'; connect-src * 'unsafe-eval' 'unsafe-inline'; frame-src 'self'\")\n\t\t}\n\t}\n\n\th.AddScript(\"global.js\")\n\tif c.RunPreRenderHook(\"pre_render_\"+tmplName, w, r, h.CurrentUser, pi) {\n\t\treturn nil\n\t}\n\t// TODO: Prepend this with panel_?\n\terr := h.Theme.RunTmpl(tmplName, pi, w)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\treturn nil\n}\n\nfunc buildBasePage(w http.ResponseWriter, r *http.Request, u *c.User, titlePhrase, zone string) (*c.BasePanelPage, c.RouteError) {\n\th, stats, ferr := c.PanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn nil, ferr\n\t}\n\th.Title = p.GetTitlePhrase(\"panel_\" + titlePhrase)\n\tdebugAdmin := true\n\n\treturn &c.BasePanelPage{h, stats, zone, c.ReportForumID, debugAdmin}, nil\n}\n"
  },
  {
    "path": "routes/panel/dashboard.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n\t\"github.com/Azareal/gopsutil/mem\"\n\t\"github.com/pkg/errors\"\n)\n\ntype dashStmts struct {\n\ttodaysPostCount         *sql.Stmt\n\ttodaysTopicCount        *sql.Stmt\n\ttodaysTopicCountByForum *sql.Stmt\n\ttodaysNewUserCount      *sql.Stmt\n\tweeklyTopicCountByForum *sql.Stmt\n}\n\n// TODO: Stop hard-coding these queries\nfunc dashMySQLStmts() (stmts dashStmts, err error) {\n\tdb := qgen.Builder.GetConn()\n\tprepStmt := func(table, ext, dur string) *sql.Stmt {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tstmt, ierr := db.Prepare(\"select count(*) from \" + table + \" where createdAt BETWEEN (utc_timestamp() - interval 1 \" + dur + \") and utc_timestamp() \" + ext)\n\t\terr = errors.WithStack(ierr)\n\t\treturn stmt\n\t}\n\n\tstmts.todaysPostCount = prepStmt(\"replies\", \"\", \"day\")\n\tstmts.todaysTopicCount = prepStmt(\"topics\", \"\", \"day\")\n\tstmts.todaysNewUserCount = prepStmt(\"users\", \"\", \"day\")\n\tstmts.todaysTopicCountByForum = prepStmt(\"topics\", \" and parentID=?\", \"day\")\n\tstmts.weeklyTopicCountByForum = prepStmt(\"topics\", \" and parentID=?\", \"week\")\n\n\treturn stmts, err\n}\n\n// TODO: Stop hard-coding these queries\nfunc dashMSSQLStmts() (stmts dashStmts, err error) {\n\tdb := qgen.Builder.GetConn()\n\tprepStmt := func(table, ext, dur string) *sql.Stmt {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tstmt, ierr := db.Prepare(\"select count(*) from \" + table + \" where createdAt >= DATEADD(\" + dur + \", -1, GETUTCDATE())\" + ext)\n\t\terr = errors.WithStack(ierr)\n\t\treturn stmt\n\t}\n\n\tstmts.todaysPostCount = prepStmt(\"replies\", \"\", \"DAY\")\n\tstmts.todaysTopicCount = prepStmt(\"topics\", \"\", \"DAY\")\n\tstmts.todaysNewUserCount = prepStmt(\"users\", \"\", \"DAY\")\n\tstmts.todaysTopicCountByForum = prepStmt(\"topics\", \" and parentID=?\", \"DAY\")\n\tstmts.weeklyTopicCountByForum = prepStmt(\"topics\", \" and parentID=?\", \"WEEK\")\n\n\treturn stmts, err\n}\n\ntype GE = c.GridElement\n\nfunc Dashboard(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, user, \"dashboard\", \"dashboard\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tunknown := p.GetTmplPhrase(\"panel_dashboard_unknown\")\n\n\t// We won't calculate this on the spot anymore, as the system doesn't seem to like it if we do multiple fetches simultaneously. Should we constantly calculate this on a background thread? Perhaps, the watchdog to scale back heavy features under load? One plus side is that we'd get immediate CPU percentages here instead of waiting it to kick in with WebSockets\n\tcpustr := unknown\n\tvar cpuColour string\n\n\tlessThanSwitch := func(number, lowerBound, midBound int) string {\n\t\tswitch {\n\t\tcase number < lowerBound:\n\t\t\treturn \"stat_green\"\n\t\tcase number < midBound:\n\t\t\treturn \"stat_orange\"\n\t\t}\n\t\treturn \"stat_red\"\n\t}\n\n\tvar ramstr, ramColour string\n\tmemres, err := mem.VirtualMemory()\n\tif err != nil {\n\t\tramstr = unknown\n\t} else {\n\t\ttotalCount, totalUnit := c.ConvertByteUnit(float64(memres.Total))\n\t\tusedCount := c.ConvertByteInUnit(float64(memres.Total-memres.Available), totalUnit)\n\n\t\t// Round totals with .9s up, it's how most people see it anyway. Floats are notoriously imprecise, so do it off 0.85\n\t\tvar totstr string\n\t\tif (totalCount - float64(int(totalCount))) > 0.85 {\n\t\t\tusedCount += 1.0 - (totalCount - float64(int(totalCount)))\n\t\t\ttotstr = strconv.Itoa(int(totalCount) + 1)\n\t\t} else {\n\t\t\ttotstr = fmt.Sprintf(\"%.1f\", totalCount)\n\t\t}\n\t\tif usedCount > totalCount {\n\t\t\tusedCount = totalCount\n\t\t}\n\t\tramstr = fmt.Sprintf(\"%.1f\", usedCount) + \" / \" + totstr + totalUnit\n\n\t\tramperc := ((memres.Total - memres.Available) * 100) / memres.Total\n\t\tramColour = lessThanSwitch(int(ramperc), 50, 75)\n\t}\n\n\tvar m runtime.MemStats\n\truntime.ReadMemStats(&m)\n\tmemCount, memUnit := c.ConvertByteUnit(float64(m.Sys))\n\n\tgreaterThanSwitch := func(number, lowerBound, midBound int) string {\n\t\tswitch {\n\t\tcase number > midBound:\n\t\t\treturn \"stat_green\"\n\t\tcase number > lowerBound:\n\t\t\treturn \"stat_orange\"\n\t\t}\n\t\treturn \"stat_red\"\n\t}\n\n\t// TODO: Add a stat store for this?\n\tvar intErr error\n\textractStat := func(stmt *sql.Stmt, args ...interface{}) (stat int) {\n\t\terr := stmt.QueryRow(args...).Scan(&stat)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\tintErr = err\n\t\t}\n\t\treturn stat\n\t}\n\n\tvar stmts dashStmts\n\tswitch qgen.Builder.GetAdapter().GetName() {\n\tcase \"mysql\":\n\t\tstmts, err = dashMySQLStmts()\n\tcase \"mssql\":\n\t\tstmts, err = dashMSSQLStmts()\n\tdefault:\n\t\treturn c.InternalError(errors.New(\"Unknown database adapter on dashboard\"), w, r)\n\t}\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Allow for more complex phrase structures than just suffixes\n\tpostCount := extractStat(stmts.todaysPostCount)\n\tpostInterval := p.GetTmplPhrase(\"panel_dashboard_day_suffix\")\n\tpostColour := greaterThanSwitch(postCount, 5, 25)\n\n\ttopicCount := extractStat(stmts.todaysTopicCount)\n\ttopicInterval := p.GetTmplPhrase(\"panel_dashboard_day_suffix\")\n\ttopicColour := greaterThanSwitch(topicCount, 0, 8)\n\n\treportCount := extractStat(stmts.weeklyTopicCountByForum, c.ReportForumID)\n\treportInterval := p.GetTmplPhrase(\"panel_dashboard_week_suffix\")\n\n\tnewUserCount := extractStat(stmts.todaysNewUserCount)\n\tnewUserInterval := p.GetTmplPhrase(\"panel_dashboard_week_suffix\")\n\n\t// Did any of the extractStats fail?\n\tif intErr != nil {\n\t\treturn c.InternalError(intErr, w, r)\n\t}\n\n\tgrid1 := []GE{}\n\taddElem1 := func(id, href, body string, order int, class, back, textColour, tooltip string) {\n\t\tgrid1 = append(grid1, GE{id, href, body, order, class, back, textColour, tooltip})\n\t}\n\tgridElements := []GE{}\n\taddElem := func(id, href, body string, order int, class, back, textColour, tooltip string) {\n\t\tgridElements = append(gridElements, GE{id, href, body, order, class, back, textColour, tooltip})\n\t}\n\n\t// TODO: Implement a check for new versions of Gosora\n\t// TODO: Localise this\n\t//addElem1(\"dash-version\", \"\", \"v\" + version.String(), 0, \"grid_istat stat_green\", \"\", \"\", \"Gosora is up-to-date :)\")\n\taddElem1(\"dash-version\", \"\", \"v\"+c.SoftwareVersion.String(), 0, \"grid_istat\", \"\", \"\", \"\")\n\n\taddElem1(\"dash-cpu\", \"\", p.GetTmplPhrasef(\"panel_dashboard_cpu\", cpustr), 1, \"grid_istat \"+cpuColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_cpu_desc\"))\n\taddElem1(\"dash-ram\", \"\", p.GetTmplPhrasef(\"panel_dashboard_ram\", ramstr), 2, \"grid_istat \"+ramColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_ram_desc\"))\n\taddElem1(\"dash-memused\", \"/panel/analytics/memory/\", p.GetTmplPhrasef(\"panel_dashboard_memused\", memCount, memUnit), 2, \"grid_istat\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_memused_desc\"))\n\n\t/*dirSize := getDirSize()\n\tif dirSize.Size != 0 {\n\t\tdirFloat, unit := c.ConvertByteUnit(float64(dirSize.Size))\n\t\taddElem1(\"dash-disk\",\"\", p.GetTmplPhrasef(\"panel_dashboard_disk\", dirFloat, unit), 2, \"grid_istat\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_disk_desc\"))\n\t\tdur := time.Since(dirSize.Time)\n\t\tif dur.Seconds() > 3 {\n\t\t\tstartDirSizeTask()\n\t\t}\n\t} else {\n\t\taddElem1(\"dash-disk\",\"\", p.GetTmplPhrase(\"panel_dashboard_disk_unknown\"), 2, \"grid_istat\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_disk_desc\"))\n\t\tstartDirSizeTask()\n\t}*/\n\n\tif c.EnableWebsockets {\n\t\tuonline := c.WsHub.UserCount()\n\t\tgonline := c.WsHub.GuestCount()\n\t\ttotonline := uonline + gonline\n\t\t//reqCount := 0\n\n\t\tonlineColour := greaterThanSwitch(totonline, 3, 10)\n\t\tonlineGuestsColour := greaterThanSwitch(gonline, 1, 10)\n\t\tonlineUsersColour := greaterThanSwitch(uonline, 1, 5)\n\n\t\ttotonline, totunit := c.ConvertFriendlyUnit(totonline)\n\t\tuonline, uunit := c.ConvertFriendlyUnit(uonline)\n\t\tgonline, gunit := c.ConvertFriendlyUnit(gonline)\n\n\t\taddElem(\"dash-totonline\", \"\", p.GetTmplPhrasef(\"panel_dashboard_online\", totonline, totunit), 3, \"grid_stat \"+onlineColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_online_desc\"))\n\t\taddElem(\"dash-gonline\", \"\", p.GetTmplPhrasef(\"panel_dashboard_guests_online\", gonline, gunit), 4, \"grid_stat \"+onlineGuestsColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_guests_online_desc\"))\n\t\taddElem(\"dash-uonline\", \"\", p.GetTmplPhrasef(\"panel_dashboard_users_online\", uonline, uunit), 5, \"grid_stat \"+onlineUsersColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_users_online_desc\"))\n\t\t//addElem(\"dash-reqs\",\"\", strconv.Itoa(reqCount) + \" reqs / second\", 7, \"grid_stat grid_end_group \" + topicColour, \"\", \"\", \"The number of requests over the last 24 hours\")\n\t}\n\n\taddElem(\"dash-postsperday\", \"\", p.GetTmplPhrasef(\"panel_dashboard_posts\", postCount, postInterval), 6, \"grid_stat \"+postColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_posts_desc\"))\n\taddElem(\"dash-topicsperday\", \"\", p.GetTmplPhrasef(\"panel_dashboard_topics\", topicCount, topicInterval), 7, \"grid_stat \"+topicColour, \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_topics_desc\"))\n\taddElem(\"dash-totonlineperday\", \"\", p.GetTmplPhrasef(\"panel_dashboard_online_day\"), 8, \"grid_stat stat_disabled\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_coming_soon\") /*, \"The people online over the last 24 hours\"*/)\n\n\taddElem(\"dash-searches\", \"\", p.GetTmplPhrasef(\"panel_dashboard_searches_day\"), 9, \"grid_stat stat_disabled\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_coming_soon\") /*\"The number of searches over the last 7 days\"*/)\n\taddElem(\"dash-newusers\", \"\", p.GetTmplPhrasef(\"panel_dashboard_new_users\", newUserCount, newUserInterval), 10, \"grid_stat\", \"\", \"\", p.GetTmplPhrasef(\"panel_dashboard_new_users_desc\"))\n\taddElem(\"dash-reports\", \"\", p.GetTmplPhrasef(\"panel_dashboard_reports\", reportCount, reportInterval), 11, \"grid_stat\", \"\", \"\", p.GetTmplPhrasef(\"panel_dashboard_reports_desc\"))\n\n\tif false {\n\t\taddElem(\"dash-minperuser\", \"\", \"?? minutes / user / week\", 12, \"grid_stat stat_disabled\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_coming_soon\") /*\"The average number of number of minutes spent by each active user over the last 7 days\"*/)\n\t\taddElem(\"dash-visitorsperweek\", \"\", \"?? visitors / week\", 13, \"grid_stat stat_disabled\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_coming_soon\") /*\"The number of unique visitors we've had over the last 7 days\"*/)\n\t\taddElem(\"dash-postsperuser\", \"\", \"?? posts / user / week\", 14, \"grid_stat stat_disabled\", \"\", \"\", p.GetTmplPhrase(\"panel_dashboard_coming_soon\") /*\"The average number of posts made by each active user over the past week\"*/)\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"panel_dashboard_right\", \"\", \"panel_dashboard\", c.DashGrids{grid1, gridElements}})\n}\n\ntype dirSize struct {\n\tSize int\n\tTime time.Time\n}\n\nfunc init() {\n\tcachedDirSize.Store(dirSize{0, time.Now()})\n}\n\nvar cachedDirSize atomic.Value\nvar dstMu sync.Mutex\nvar dstMuGuess = 0\n\nfunc startDirSizeTask() {\n\tif dstMuGuess == 1 {\n\t\treturn\n\t}\n\tdstMu.Lock()\n\tdstMuGuess = 1\n\tgo func() {\n\t\tdefer func() {\n\t\t\tdstMuGuess = 0\n\t\t\tdstMu.Unlock()\n\t\t}()\n\t\tdefer c.EatPanics()\n\t\tdDirSize, e := c.DirSize(\".\")\n\t\tif e != nil {\n\t\t\tc.LogWarning(e)\n\t\t}\n\t\tcachedDirSize.Store(dirSize{dDirSize, time.Now()})\n\t}()\n}\n\nfunc getDirSize() dirSize {\n\treturn cachedDirSize.Load().(dirSize)\n}\n\ntype StatsDiskJson struct {\n\tTotal string `json:\"total\"`\n}\n\nfunc StatsDisk(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\tdirSize := getDirSize()\n\tdirFloat, unit := c.ConvertByteUnit(float64(dirSize.Size))\n\tu := p.GetTmplPhrasef(\"unit\", dirFloat, unit)\n\toBytes, err := json.Marshal(StatsDiskJson{u})\n\tif err != nil {\n\t\treturn c.InternalErrorJS(err, w, r)\n\t}\n\tw.Write(oBytes)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/debug.go",
    "content": "package panel\n\nimport (\n\t\"net/http\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\nfunc Debug(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"debug\", \"debug\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tgoVersion := runtime.Version()\n\tdbVersion := qgen.Builder.DbVersion()\n\tupDur := time.Since(c.StartTime)\n\thours := int(upDur.Hours())\n\tmins := int(upDur.Minutes())\n\tsecs := int(upDur.Seconds())\n\tvar uptime string\n\tif hours > 24 {\n\t\tdays := hours / 24\n\t\thours -= days * 24\n\t\tuptime += strconv.Itoa(days) + \"d\"\n\t\tuptime += strconv.Itoa(hours) + \"h\"\n\t} else if hours >= 1 {\n\t\tmins -= hours * 60\n\t\tuptime += strconv.Itoa(hours) + \"h\"\n\t\tuptime += strconv.Itoa(mins) + \"m\"\n\t} else if mins >= 1 {\n\t\tsecs -= mins * 60\n\t\tuptime += strconv.Itoa(mins) + \"m\"\n\t\tuptime += strconv.Itoa(secs) + \"s\"\n\t}\n\n\tdbStats := qgen.Builder.GetConn().Stats()\n\topenConnCount := dbStats.OpenConnections\n\t// Disk I/O?\n\t// TODO: Fetch the adapter from Builder rather than getting it from a global?\n\tgoroutines := runtime.NumGoroutine()\n\tcpus := runtime.NumCPU()\n\thttpConns := c.ConnWatch.Count()\n\n\tdebugTasks := c.DebugPageTasks{c.Tasks.HalfSec.Count(), c.Tasks.Sec.Count(), c.Tasks.FifteenMin.Count(), c.Tasks.Hour.Count(), c.Tasks.Day.Count(), c.Tasks.Shutdown.Count()}\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\n\tvar tlen, ulen, rlen int\n\tvar tcap, ucap, rcap int\n\ttc := c.Topics.GetCache()\n\tif tc != nil {\n\t\ttlen, tcap = tc.Length(), tc.GetCapacity()\n\t}\n\tuc := c.Users.GetCache()\n\tif uc != nil {\n\t\tulen, ucap = uc.Length(), uc.GetCapacity()\n\t}\n\trc := c.Rstore.GetCache()\n\tif rc != nil {\n\t\trlen, rcap = rc.Length(), rc.GetCapacity()\n\t}\n\ttopicListThawed := c.TopicListThaw.Thawed()\n\n\tdebugCache := c.DebugPageCache{tlen, ulen, rlen, tcap, ucap, rcap, topicListThawed}\n\n\tvar fErr error\n\tacc := qgen.NewAcc()\n\tcount := func(tbl string) int {\n\t\tif fErr != nil {\n\t\t\treturn 0\n\t\t}\n\t\tc, err := acc.Count(tbl).Total()\n\t\tfErr = err\n\t\treturn c\n\t}\n\n\t// TODO: Call Count on an attachment store\n\tattachs := count(\"attachments\")\n\t// TODO: Implement a PollStore and call Count on that instead\n\t//polls := count(\"polls\")\n\tpolls := c.Polls.Count()\n\t//pollsOptions := count(\"polls_options\") // TODO: Add this\n\t//pollsVotes := count(\"polls_votes\") // TODO: Add this\n\n\t//loginLogs := count(\"login_logs\")\n\tloginLogs := c.LoginLogs.Count()\n\t//regLogs := count(\"registration_logs\")\n\tregLogs := c.RegLogs.Count()\n\t//modLogs := count(\"moderation_logs\")\n\tmodLogs := c.ModLogs.Count()\n\t//adminLogs := count(\"administration_logs\")\n\tadminLogs := c.AdminLogs.Count()\n\n\tviews := count(\"viewchunks\")\n\tviewsAgents := count(\"viewchunks_agents\")\n\tviewsForums := count(\"viewchunks_forums\")\n\tviewsLangs := count(\"viewchunks_langs\")\n\tviewsReferrers := count(\"viewchunks_referrers\")\n\tviewsSystems := count(\"viewchunks_systems\")\n\tpostChunks := count(\"postchunks\")\n\ttopicChunks := count(\"topicchunks\")\n\tif fErr != nil {\n\t\treturn c.InternalError(fErr, w, r)\n\t}\n\n\tdebugDatabase := c.DebugPageDatabase{c.Topics.Count(), c.Users.Count(), c.Rstore.Count(), c.Prstore.Count(), c.Activity.Count(), c.Likes.Count(), attachs, polls, loginLogs, regLogs, modLogs, adminLogs, views, viewsAgents, viewsForums, viewsLangs, viewsReferrers, viewsSystems, postChunks, topicChunks}\n\n\tdirSize := func(path string) int {\n\t\tif fErr != nil {\n\t\t\treturn 0\n\t\t}\n\t\tc, err := c.DirSize(path)\n\t\tfErr = err\n\t\treturn c\n\t}\n\n\tstaticSize := dirSize(\"./public/\")\n\tattachSize := dirSize(\"./attachs/\")\n\tuploadsSize := dirSize(\"./uploads/\")\n\tlogsSize := dirSize(c.Config.LogDir)\n\tbackupsSize := dirSize(\"./backups/\")\n\tif fErr != nil {\n\t\treturn c.InternalError(fErr, w, r)\n\t}\n\t// TODO: How can we measure this without freezing up the entire page?\n\t//gitSize, _ := c.DirSize(\"./.git\")\n\tgitSize := 0\n\n\tdebugDisk := c.DebugPageDisk{staticSize, attachSize, uploadsSize, logsSize, backupsSize, gitSize}\n\n\tpi := c.PanelDebugPage{bp, goVersion, dbVersion, uptime, openConnCount, qgen.Builder.GetAdapter().GetName(), goroutines, cpus, httpConns, debugTasks, memStats, debugCache, debugDatabase, debugDisk}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_dashboard_right\", \"debug_page\", \"panel_debug\", pi})\n}\n\nfunc DebugTasks(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"debug\", \"debug\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tvar tasks []c.PanelTaskTask\n\tvar taskTypes []c.PanelTaskType\n\n\tpi := c.PanelTaskPage{bp, tasks, taskTypes}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_dashboard_right\", \"debug_page\", \"panel_debug_task\", pi})\n}\n"
  },
  {
    "path": "routes/panel/forums.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc Forums(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"forums\", \"forums\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tbp.Header.AddScript(\"Sortable-1.4.0/Sortable.min.js\")\n\tbp.Header.AddScriptAsync(\"panel_forums.js\")\n\n\t// TODO: Paginate this?\n\tvar forumList []interface{}\n\tforums, err := c.Forums.GetAll()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// ? - Should we generate something similar to the forumView? It might be a little overkill for a page which is rarely loaded in comparison to /forums/\n\tfor _, f := range forums {\n\t\tif f.Name != \"\" && f.ParentID == 0 {\n\t\t\tfadmin := c.ForumAdmin{f.ID, f.Name, f.Desc, f.Active, f.Preset, f.TopicCount, c.PresetToLang(f.Preset)}\n\t\t\tif fadmin.Preset == \"\" {\n\t\t\t\tfadmin.Preset = \"custom\"\n\t\t\t}\n\t\t\tforumList = append(forumList, fadmin)\n\t\t}\n\t}\n\n\tif r.FormValue(\"created\") == \"1\" {\n\t\tbp.AddNotice(\"panel_forum_created\")\n\t} else if r.FormValue(\"deleted\") == \"1\" {\n\t\tbp.AddNotice(\"panel_forum_deleted\")\n\t} else if r.FormValue(\"updated\") == \"1\" {\n\t\tbp.AddNotice(\"panel_forum_updated\")\n\t}\n\n\tpi := c.PanelPage{bp, forumList, nil}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_forums\", &pi})\n}\n\nfunc ForumsCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tname := r.PostFormValue(\"name\")\n\tdesc := r.PostFormValue(\"desc\")\n\tpreset := c.StripInvalidPreset(r.PostFormValue(\"preset\"))\n\tfactive := r.PostFormValue(\"active\")\n\tactive := (factive == \"on\" || factive == \"1\")\n\n\tfid, err := c.Forums.Create(name, desc, active, preset)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"create\", fid, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/forums/?created=1\", http.StatusSeeOther)\n\treturn nil\n}\n\n// TODO: Revamp this\nfunc ForumsDelete(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"delete_forum\", \"forums\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided Forum ID is not a valid number.\", w, r, u)\n\t}\n\tforum, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The forum you're trying to delete doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tconfirmMsg := p.GetTmplPhrasef(\"panel_forum_delete_are_you_sure\", forum.Name)\n\tyouSure := c.AreYouSure{\"/panel/forums/delete/submit/\" + strconv.Itoa(fid), confirmMsg}\n\n\tpi := c.PanelPage{basePage, tList, youSure}\n\tif c.RunPreRenderHook(\"pre_render_panel_delete_forum\", w, r, u, &pi) {\n\t\treturn nil\n\t}\n\treturn renderTemplate(\"panel_are_you_sure\", w, r, basePage.Header, &pi)\n}\n\nfunc ForumsDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided Forum ID is not a valid number.\", w, r, u)\n\t}\n\terr = c.Forums.Delete(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The forum you're trying to delete doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", fid, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/forums/?deleted=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ForumsOrderSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\t// TODO: Move this even earlier?\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\tsitems := strings.TrimSuffix(strings.TrimPrefix(r.PostFormValue(\"items\"), \"{\"), \"}\")\n\t//fmt.Printf(\"sitems: %+v\\n\", sitems)\n\n\tupdateMap := make(map[int]int)\n\tfor index, sfid := range strings.Split(sitems, \",\") {\n\t\tfid, err := strconv.Atoi(sfid)\n\t\tif err != nil {\n\t\t\treturn c.LocalErrorJSQ(\"Invalid integer in forum list\", w, r, u, js)\n\t\t}\n\t\tupdateMap[fid] = index\n\t}\n\terr := c.Forums.UpdateOrder(updateMap)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\terr = c.AdminLogs.Create(\"reorder\", 0, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\treturn successRedirect(\"/panel/forums/\", w, r, js)\n}\n\nfunc ForumsEdit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"edit_forum\", \"forums\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.SimpleError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, basePage.Header)\n\t}\n\tbasePage.Header.AddScriptAsync(\"panel_forum_edit.js\")\n\n\tf, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The forum you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif f.Preset == \"\" {\n\t\tf.Preset = \"custom\"\n\t}\n\n\tglist, err := c.Groups.GetAll()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar gplist []c.GroupForumPermPreset\n\tfor gid, group := range glist {\n\t\tif gid == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tforumPerms, err := c.FPStore.Get(fid, group.ID)\n\t\tif err == sql.ErrNoRows {\n\t\t\tforumPerms = c.BlankForumPerms()\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tpreset := c.ForumPermsToGroupForumPreset(forumPerms)\n\t\tgplist = append(gplist, c.GroupForumPermPreset{group, preset, preset == \"default\"})\n\t}\n\n\tif r.FormValue(\"updated\") == \"1\" {\n\t\tbasePage.AddNotice(\"panel_forum_updated\")\n\t}\n\n\tfalist, e := c.ForumActionStore.GetInForum(f.ID)\n\tif err != sql.ErrNoRows && e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tafalist := make([]*c.ForumActionAction, len(falist))\n\tfor i, faitem := range falist {\n\t\tafalist[i] = &c.ForumActionAction{faitem, c.ConvActToString(faitem.Action)}\n\t}\n\n\tpi := c.PanelEditForumPage{basePage, f.ID, f.Name, f.Desc, f.Active, f.Preset, gplist, afalist}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_forum_edit\", &pi})\n}\n\nfunc ForumsEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The provided Forum ID is not a valid number.\", w, r, u, js)\n\t}\n\tforum, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"The forum you're trying to edit doesn't exist.\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tname := r.PostFormValue(\"forum_name\")\n\tdesc := r.PostFormValue(\"forum_desc\")\n\tpreset := c.StripInvalidPreset(r.PostFormValue(\"forum_preset\"))\n\tfactive := r.PostFormValue(\"forum_active\")\n\n\tactive := false\n\tif factive == \"\" {\n\t\tactive = forum.Active\n\t} else if factive == \"1\" || factive == \"Show\" {\n\t\tactive = true\n\t}\n\n\terr = forum.Update(name, desc, active, preset)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", fid, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// ? Should we redirect to the forum editor instead?\n\treturn successRedirect(\"/panel/forums/\", w, r, js)\n}\n\nfunc ForumsEditPermsSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The provided Forum ID is not a valid number.\", w, r, u, js)\n\t}\n\tgid, err := strconv.Atoi(r.PostFormValue(\"gid\"))\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"Invalid Group ID\", w, r, u, js)\n\t}\n\n\tf, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"This forum doesn't exist\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tpermPreset := c.StripInvalidGroupForumPreset(r.PostFormValue(\"perm_preset\"))\n\terr = f.SetPreset(permPreset, gid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(err.Error(), w, r, u, js)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", fid, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/forums/edit/\"+strconv.Itoa(fid)+\"?updated=1\", w, r, js)\n}\n\n// A helper function for the Advanced portion of the Forum Perms Editor\nfunc forumPermsExtractDash(paramList string) (fid, gid int, e error) {\n\tparams := strings.Split(paramList, \"-\")\n\tif len(params) != 2 {\n\t\treturn fid, gid, errors.New(\"Parameter count mismatch\")\n\t}\n\tfid, e = strconv.Atoi(params[0])\n\tif e != nil {\n\t\treturn fid, gid, errors.New(\"The provided Forum ID is not a valid number.\")\n\t}\n\tgid, e = strconv.Atoi(params[1])\n\tif e != nil {\n\t\te = errors.New(\"The provided Group ID is not a valid number.\")\n\t}\n\treturn fid, gid, e\n}\n\nfunc ForumsEditPermsAdvance(w http.ResponseWriter, r *http.Request, u *c.User, paramList string) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"edit_forum\", \"forums\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tfid, gid, err := forumPermsExtractDash(paramList)\n\tif err != nil {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\n\tf, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The forum you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif f.Preset == \"\" {\n\t\tf.Preset = \"custom\"\n\t}\n\n\tfp, err := c.FPStore.Get(fid, gid)\n\tif err == sql.ErrNoRows {\n\t\tfp = c.BlankForumPerms()\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar formattedPermList []c.NameLangToggle\n\t// TODO: Load the phrases in bulk for efficiency?\n\t// TODO: Reduce the amount of code duplication between this and the group editor. Also, can we grind this down into one line or use a code generator to stay current more easily?\n\taddToggle := func(permStr string, perm bool) {\n\t\tformattedPermList = append(formattedPermList, c.NameLangToggle{permStr, p.GetPermPhrase(permStr), perm})\n\t}\n\taddToggle(\"ViewTopic\", fp.ViewTopic)\n\taddToggle(\"LikeItem\", fp.LikeItem)\n\taddToggle(\"CreateTopic\", fp.CreateTopic)\n\t//<--\n\taddToggle(\"EditTopic\", fp.EditTopic)\n\taddToggle(\"DeleteTopic\", fp.DeleteTopic)\n\taddToggle(\"CreateReply\", fp.CreateReply)\n\taddToggle(\"EditReply\", fp.EditReply)\n\taddToggle(\"DeleteReply\", fp.DeleteReply)\n\taddToggle(\"PinTopic\", fp.PinTopic)\n\taddToggle(\"CloseTopic\", fp.CloseTopic)\n\taddToggle(\"MoveTopic\", fp.MoveTopic)\n\n\tif r.FormValue(\"updated\") == \"1\" {\n\t\tbp.AddNotice(\"panel_forum_perms_updated\")\n\t}\n\n\tpi := c.PanelEditForumGroupPage{bp, f.ID, gid, f.Name, f.Desc, f.Active, f.Preset, formattedPermList}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_forum_edit_perms\", &pi})\n}\n\nfunc ForumsEditPermsAdvanceSubmit(w http.ResponseWriter, r *http.Request, u *c.User, paramList string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tfid, gid, err := forumPermsExtractDash(paramList)\n\tif err != nil {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\n\tf, err := c.Forums.Get(fid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The forum you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tfp, err := c.FPStore.GetCopy(fid, gid)\n\tif err == sql.ErrNoRows {\n\t\tfp = *c.BlankForumPerms()\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tep := func(name string) bool {\n\t\tpvalue := r.PostFormValue(\"perm-\" + name)\n\t\treturn (pvalue == \"1\")\n\t}\n\t// TODO: Generate this code?\n\tfp.ViewTopic = ep(\"ViewTopic\")\n\tfp.LikeItem = ep(\"LikeItem\")\n\tfp.CreateTopic = ep(\"CreateTopic\")\n\tfp.EditTopic = ep(\"EditTopic\")\n\tfp.DeleteTopic = ep(\"DeleteTopic\")\n\tfp.CreateReply = ep(\"CreateReply\")\n\tfp.EditReply = ep(\"EditReply\")\n\tfp.DeleteReply = ep(\"DeleteReply\")\n\tfp.PinTopic = ep(\"PinTopic\")\n\tfp.CloseTopic = ep(\"CloseTopic\")\n\tfp.MoveTopic = ep(\"MoveTopic\")\n\n\terr = f.SetPerms(&fp, \"custom\", gid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(err.Error(), w, r, u, js)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", fid, \"forum\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/forums/edit/perms/\"+strconv.Itoa(fid)+\"-\"+strconv.Itoa(gid)+\"?updated=1\", w, r, js)\n}\n\nfunc ForumsEditActionDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfaid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\t// TODO: Should we split this permission?\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tfaid, e := strconv.Atoi(sfaid)\n\tif e != nil {\n\t\treturn c.LocalError(\"The forum action ID is not a valid integer.\", w, r, u)\n\t}\n\te = c.ForumActionStore.Delete(faid)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tfid, e := strconv.Atoi(r.FormValue(\"ret\"))\n\tif e != nil {\n\t\treturn c.LocalError(\"The forum action ID is not a valid integer.\", w, r, u)\n\t}\n\tif !c.Forums.Exists(fid) {\n\t\treturn c.LocalError(\"The target forum doesn't exist.\", w, r, u)\n\t}\n\n\treturn successRedirect(\"/panel/forums/edit/\"+strconv.Itoa(fid)+\"?updated=1\", w, r, js)\n}\n\nfunc ForumsEditActionCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\t// TODO: Should we split this permission?\n\tif !u.Perms.ManageForums {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\tfid, e := strconv.Atoi(sfid)\n\tif e != nil {\n\t\treturn c.LocalError(\"The provided Forum ID is not a valid number.\", w, r, u)\n\t}\n\tif !c.Forums.Exists(fid) {\n\t\treturn c.LocalError(\"This forum does not exist\", w, r, u)\n\t}\n\n\trunOnTopicCreation := r.PostFormValue(\"action_run_on_topic_creation\") == \"1\"\n\n\tf := func(s string) (int, c.RouteError) {\n\t\ti, e := strconv.Atoi(r.PostFormValue(s))\n\t\tif e != nil {\n\t\t\treturn i, c.LocalError(s+\" is not a valid integer.\", w, r, u)\n\t\t}\n\t\tif i < 0 {\n\t\t\treturn i, c.LocalError(s+\" cannot be less than 0\", w, r, u)\n\t\t}\n\t\treturn i, nil\n\t}\n\trunDaysAfterTopicCreation, re := f(\"action_run_days_after_topic_creation\")\n\tif re != nil {\n\t\treturn re\n\t}\n\trunDaysAfterTopicLastReply, re := f(\"action_run_days_after_topic_last_reply\")\n\tif re != nil {\n\t\treturn re\n\t}\n\n\taction := r.PostFormValue(\"action_action\")\n\taint := c.ConvStringToAct(action)\n\tif aint == -1 {\n\t\treturn c.LocalError(\"invalid action\", w, r, u)\n\t}\n\n\textra := r.PostFormValue(\"action_extra\")\n\tswitch aint {\n\tcase c.ForumActionMove:\n\t\tconv, e := strconv.Atoi(extra)\n\t\tif e != nil {\n\t\t\treturn c.LocalError(\"action_extra is not a valid integer.\", w, r, u)\n\t\t}\n\t\textra = strconv.Itoa(conv)\n\tdefault:\n\t\textra = \"\"\n\t}\n\n\t_, e = c.ForumActionStore.Add(&c.ForumAction{\n\t\tForum:                      fid,\n\t\tRunOnTopicCreation:         runOnTopicCreation,\n\t\tRunDaysAfterTopicCreation:  runDaysAfterTopicCreation,\n\t\tRunDaysAfterTopicLastReply: runDaysAfterTopicLastReply,\n\t\tAction:                     aint,\n\t\tExtra:                      extra,\n\t})\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/forums/edit/\"+strconv.Itoa(fid)+\"?updated=1\", w, r, js)\n}\n"
  },
  {
    "path": "routes/panel/groups.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc Groups(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbPage, ferr := buildBasePage(w, r, u, \"groups\", \"groups\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 15\n\toffset, page, lastPage := c.PageOffset(bPage.Stats.Groups, page, perPage)\n\n\t// Skip the 'Unknown' group\n\toffset++\n\n\tvar count int\n\tvar groupList []c.GroupAdmin\n\tgroups, _ := c.Groups.GetRange(offset, 0)\n\tfor _, g := range groups {\n\t\tif count == perPage {\n\t\t\tbreak\n\t\t}\n\t\tvar rank, rankClass string\n\t\tcanDelete := false\n\n\t\t// TODO: Localise this\n\t\tswitch {\n\t\tcase g.IsAdmin:\n\t\t\trank = \"Admin\"\n\t\t\trankClass = \"admin\"\n\t\tcase g.IsMod:\n\t\t\trank = \"Mod\"\n\t\t\trankClass = \"mod\"\n\t\tcase g.IsBanned:\n\t\t\trank = \"Banned\"\n\t\t\trankClass = \"banned\"\n\t\tcase g.ID == 6:\n\t\t\trank = \"Guest\"\n\t\t\trankClass = \"guest\"\n\t\tdefault:\n\t\t\trank = \"Member\"\n\t\t\trankClass = \"member\"\n\t\t}\n\n\t\tcanEdit := u.Perms.EditGroup && (!g.IsAdmin || u.Perms.EditGroupAdmin) && (!g.IsMod || u.Perms.EditGroupSuperMod)\n\t\tgroupList = append(groupList, c.GroupAdmin{g.ID, g.Name, rank, rankClass, canEdit, canDelete})\n\t\tcount++\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelGroupPage{bPage, groupList, c.Paginator{pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, bPage.Header, c.Panel{bPage, \"\", \"\", \"panel_groups\", &pi})\n}\n\nfunc GroupsEdit(w http.ResponseWriter, r *http.Request, user *c.User, sgid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, user, \"edit_group\", \"groups\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, user)\n\t}\n\tg, err := c.Groups.Get(gid)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"aaaaa monsters\")\n\t\treturn c.NotFound(w, r, basePage.Header)\n\t}\n\tferr = groupCheck(w, r, user, g, err)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tvar rank string\n\tswitch {\n\tcase g.IsAdmin:\n\t\trank = \"Admin\"\n\tcase g.IsMod:\n\t\trank = \"Mod\"\n\tcase g.IsBanned:\n\t\trank = \"Banned\"\n\tcase g.ID == 6:\n\t\trank = \"Guest\"\n\tdefault:\n\t\trank = \"Member\"\n\t}\n\tdisableRank := !user.Perms.EditGroupGlobalPerms || (g.ID == 6)\n\n\tpi := c.PanelEditGroupPage{basePage, g.ID, g.Name, g.Tag, rank, disableRank}\n\treturn renderTemplate(\"panel_group_edit\", w, r, basePage.Header, pi)\n}\n\nfunc GroupsEditPromotions(w http.ResponseWriter, r *http.Request, user *c.User, sgid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, user, \"edit_group\", \"groups\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, user)\n\t}\n\tg, err := c.Groups.Get(gid)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"aaaaa monsters\")\n\t\treturn c.NotFound(w, r, basePage.Header)\n\t}\n\tferr = groupCheck(w, r, user, g, err)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tpromotions, err := c.GroupPromotions.GetByGroup(g.ID)\n\tif err != sql.ErrNoRows && err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tpromoteExt := make([]*c.GroupPromotionExtend, len(promotions))\n\tfor i, promote := range promotions {\n\t\tfg, err := c.Groups.Get(promote.From)\n\t\tif err == sql.ErrNoRows {\n\t\t\tfg = &c.Group{Name: \"Deleted Group\"}\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\ttg, err := c.Groups.Get(promote.To)\n\t\tif err == sql.ErrNoRows {\n\t\t\ttg = &c.Group{Name: \"Deleted Group\"}\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tpromoteExt[i] = &c.GroupPromotionExtend{promote, fg, tg}\n\t}\n\n\t// ? - Should we stop admins from deleting all the groups? Maybe, protect the group they're currently using?\n\tgroups, err := c.Groups.GetRange(1, 0) // ? - 0 = Go to the end\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar groupList []*c.Group\n\tfor _, group := range groups {\n\t\tif !user.Perms.EditUserGroupAdmin && group.IsAdmin {\n\t\t\tcontinue\n\t\t}\n\t\tif !user.Perms.EditUserGroupSuperMod && group.IsMod {\n\t\t\tcontinue\n\t\t}\n\t\tgroupList = append(groupList, group)\n\t}\n\n\tpi := c.PanelEditGroupPromotionsPage{basePage, g.ID, g.Name, promoteExt, groupList}\n\treturn renderTemplate(\"panel_group_edit_promotions\", w, r, basePage.Header, pi)\n}\n\nfunc groupCheck(w http.ResponseWriter, r *http.Request, u *c.User, g *c.Group, err error) c.RouteError {\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"No such group.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif g.IsAdmin && !u.Perms.EditGroupAdmin {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_edit_admin\"), w, r, u)\n\t}\n\tif g.IsMod && !u.Perms.EditGroupSuperMod {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_edit_supermod\"), w, r, u)\n\t}\n\treturn nil\n}\n\nfunc GroupsPromotionsCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sgid string) c.RouteError {\n\tif !u.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\n\tfrom, err := strconv.Atoi(r.FormValue(\"from\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"from must be integer\", w, r, u)\n\t}\n\tto, err := strconv.Atoi(r.FormValue(\"to\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"to must be integer\", w, r, u)\n\t}\n\tif from == to {\n\t\treturn c.LocalError(\"the from group and to group cannot be the same\", w, r, u)\n\t}\n\ttwoWay := r.FormValue(\"two-way\") == \"1\"\n\n\tlevel, err := strconv.Atoi(r.FormValue(\"level\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"level must be integer\", w, r, u)\n\t}\n\tposts, err := strconv.Atoi(r.FormValue(\"posts\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"posts must be integer\", w, r, u)\n\t}\n\n\tregHours, err := strconv.Atoi(r.FormValue(\"reg_hours\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"reg_hours must be integer\", w, r, u)\n\t}\n\tregDays, err := strconv.Atoi(r.FormValue(\"reg_days\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"reg_days must be integer\", w, r, u)\n\t}\n\tregMonths, err := strconv.Atoi(r.FormValue(\"reg_months\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"reg_months must be integer\", w, r, u)\n\t}\n\tregMinutes := (regHours * 60) + (regDays * 24 * 60) + (regMonths * 30 * 24 * 60)\n\n\tg, err := c.Groups.Get(from)\n\tferr := groupCheck(w, r, u, g, err)\n\tif err != nil {\n\t\treturn ferr\n\t}\n\tg, err = c.Groups.Get(to)\n\tferr = groupCheck(w, r, u, g, err)\n\tif err != nil {\n\t\treturn ferr\n\t}\n\tpid, err := c.GroupPromotions.Create(from, to, twoWay, level, posts, regMinutes)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"create\", pid, \"group_promotion\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/groups/edit/promotions/\"+strconv.Itoa(gid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc GroupsPromotionsDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sspl string) c.RouteError {\n\tif !u.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tspl := strings.Split(sspl, \"-\")\n\tif len(spl) < 2 {\n\t\treturn c.LocalError(\"need two params\", w, r, u)\n\t}\n\tgid, err := strconv.Atoi(spl[0])\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\tpid, err := strconv.Atoi(spl[1])\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\n\tpro, err := c.GroupPromotions.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"That group promotion doesn't exist\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tg, err := c.Groups.Get(pro.From)\n\tferr := groupCheck(w, r, u, g, err)\n\tif err != nil {\n\t\treturn ferr\n\t}\n\tg, err = c.Groups.Get(pro.To)\n\tferr = groupCheck(w, r, u, g, err)\n\tif err != nil {\n\t\treturn ferr\n\t}\n\terr = c.GroupPromotions.Delete(pid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", pid, \"group_promotion\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/groups/edit/promotions/\"+strconv.Itoa(gid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc GroupsEditPerms(w http.ResponseWriter, r *http.Request, u *c.User, sgid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"edit_group\", \"groups\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\n\tg, err := c.Groups.Get(gid)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"aaaaa monsters\")\n\t\treturn c.NotFound(w, r, basePage.Header)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif g.IsAdmin && !u.Perms.EditGroupAdmin {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_edit_admin\"), w, r, u)\n\t}\n\tif g.IsMod && !u.Perms.EditGroupSuperMod {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_edit_supermod\"), w, r, u)\n\t}\n\n\t// TODO: Load the phrases in bulk for efficiency?\n\tvar localPerms []c.NameLangToggle\n\taddPerm := func(permStr string, perm bool) {\n\t\tlocalPerms = append(localPerms, c.NameLangToggle{permStr, p.GetPermPhrase(permStr), perm})\n\t}\n\n\taddPerm(\"ViewTopic\", g.Perms.ViewTopic)\n\taddPerm(\"LikeItem\", g.Perms.LikeItem)\n\taddPerm(\"CreateTopic\", g.Perms.CreateTopic)\n\t//<--\n\taddPerm(\"EditTopic\", g.Perms.EditTopic)\n\taddPerm(\"DeleteTopic\", g.Perms.DeleteTopic)\n\taddPerm(\"CreateReply\", g.Perms.CreateReply)\n\taddPerm(\"EditReply\", g.Perms.EditReply)\n\taddPerm(\"DeleteReply\", g.Perms.DeleteReply)\n\taddPerm(\"PinTopic\", g.Perms.PinTopic)\n\taddPerm(\"CloseTopic\", g.Perms.CloseTopic)\n\taddPerm(\"MoveTopic\", g.Perms.MoveTopic)\n\n\tvar globalPerms []c.NameLangToggle\n\taddPerm = func(permStr string, perm bool) {\n\t\tglobalPerms = append(globalPerms, c.NameLangToggle{permStr, p.GetPermPhrase(permStr), perm})\n\t}\n\n\taddPerm(\"UploadFiles\", g.Perms.UploadFiles)\n\taddPerm(\"UploadAvatars\", g.Perms.UploadAvatars)\n\taddPerm(\"UseConvos\", g.Perms.UseConvos)\n\taddPerm(\"UseConvosOnlyWithMod\", g.Perms.UseConvosOnlyWithMod)\n\taddPerm(\"CreateProfileReply\", g.Perms.CreateProfileReply)\n\taddPerm(\"AutoEmbed\", g.Perms.AutoEmbed)\n\taddPerm(\"AutoLink\", g.Perms.AutoLink)\n\n\tvar modPerms []c.NameLangToggle\n\taddPerm = func(permStr string, perm bool) {\n\t\tmodPerms = append(modPerms, c.NameLangToggle{permStr, p.GetPermPhrase(permStr), perm})\n\t}\n\n\taddPerm(\"BanUsers\", g.Perms.BanUsers)\n\taddPerm(\"ActivateUsers\", g.Perms.ActivateUsers)\n\taddPerm(\"EditUser\", g.Perms.EditUser)\n\taddPerm(\"EditUserEmail\", g.Perms.EditUserEmail)\n\taddPerm(\"EditUserPassword\", g.Perms.EditUserPassword)\n\taddPerm(\"EditUserGroup\", g.Perms.EditUserGroup)\n\taddPerm(\"EditUserGroupSuperMod\", g.Perms.EditUserGroupSuperMod)\n\taddPerm(\"EditUserGroupAdmin\", g.Perms.EditUserGroupAdmin)\n\taddPerm(\"EditGroup\", g.Perms.EditGroup)\n\taddPerm(\"EditGroupLocalPerms\", g.Perms.EditGroupLocalPerms)\n\taddPerm(\"EditGroupGlobalPerms\", g.Perms.EditGroupGlobalPerms)\n\taddPerm(\"EditGroupSuperMod\", g.Perms.EditGroupSuperMod)\n\taddPerm(\"EditGroupAdmin\", g.Perms.EditGroupAdmin)\n\taddPerm(\"ManageForums\", g.Perms.ManageForums)\n\taddPerm(\"EditSettings\", g.Perms.EditSettings)\n\taddPerm(\"ManageThemes\", g.Perms.ManageThemes)\n\taddPerm(\"ManagePlugins\", g.Perms.ManagePlugins)\n\taddPerm(\"ViewAdminLogs\", g.Perms.ViewAdminLogs)\n\taddPerm(\"ViewIPs\", g.Perms.ViewIPs)\n\n\tpi := c.PanelEditGroupPermsPage{basePage, g.ID, g.Name, localPerms, globalPerms, modPerms}\n\treturn renderTemplate(\"panel_group_edit_perms\", w, r, basePage.Header, pi)\n}\n\nfunc GroupsEditSubmit(w http.ResponseWriter, r *http.Request, user *c.User, sgid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\tgroup, err := c.Groups.Get(gid)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"aaaaa monsters\")\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tferr = groupCheck(w, r, user, group, err)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tname := r.FormValue(\"name\")\n\tif name == \"\" {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_need_name\"), w, r, user)\n\t}\n\ttag := r.FormValue(\"tag\")\n\trank := r.FormValue(\"type\")\n\n\tvar originalRank string\n\t// TODO: Use a switch for this\n\tswitch {\n\tcase group.IsAdmin:\n\t\toriginalRank = \"Admin\"\n\tcase group.IsMod:\n\t\toriginalRank = \"Mod\"\n\tcase group.IsBanned:\n\t\toriginalRank = \"Banned\"\n\tcase group.ID == 6:\n\t\toriginalRank = \"Guest\"\n\tdefault:\n\t\toriginalRank = \"Member\"\n\t}\n\n\tif rank != originalRank && originalRank != \"Guest\" {\n\t\tif !user.Perms.EditGroupGlobalPerms {\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_edit_group_type\"), w, r, user)\n\t\t}\n\t\tswitch rank {\n\t\tcase \"Admin\":\n\t\t\tif !user.Perms.EditGroupAdmin {\n\t\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_edit_cannot_designate_admin\"), w, r, user)\n\t\t\t}\n\t\t\terr = group.ChangeRank(true, true, false)\n\t\tcase \"Mod\":\n\t\t\tif !user.Perms.EditGroupSuperMod {\n\t\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_edit_cannot_designate_supermod\"), w, r, user)\n\t\t\t}\n\t\t\terr = group.ChangeRank(false, true, false)\n\t\tcase \"Banned\":\n\t\t\terr = group.ChangeRank(false, false, true)\n\t\tcase \"Guest\":\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_cannot_be_guest\"), w, r, user)\n\t\tcase \"Member\":\n\t\t\terr = group.ChangeRank(false, false, false)\n\t\tdefault:\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_invalid_group_type\"), w, r, user)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\n\terr = group.Update(name, tag)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", group.ID, \"group\", user.GetIP(), user.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/groups/edit/\"+strconv.Itoa(gid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc GroupsEditPermsSubmit(w http.ResponseWriter, r *http.Request, user *c.User, sgid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\tgid, err := strconv.Atoi(sgid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, user)\n\t}\n\n\tgroup, err := c.Groups.Get(gid)\n\tif err == sql.ErrNoRows {\n\t\t//log.Print(\"aaaaa monsters o.o\")\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tferr = groupCheck(w, r, user, group, err)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\t// TODO: Don't unset perms we don't have permission to set?\n\tpmap := make(map[string]bool)\n\tpCheck := func(hasPerm bool, perms []string) {\n\t\tif hasPerm {\n\t\t\tfor _, perm := range perms {\n\t\t\t\tpvalue := r.PostFormValue(\"perm-\" + perm)\n\t\t\t\tpmap[perm] = (pvalue == \"1\")\n\t\t\t}\n\t\t}\n\t}\n\tpCheck(user.Perms.EditGroupLocalPerms, c.LocalPermList)\n\tpCheck(user.Perms.EditGroupGlobalPerms, c.GlobalPermList)\n\n\terr = group.UpdatePerms(pmap)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", group.ID, \"group\", user.GetIP(), user.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/groups/edit/perms/\"+strconv.Itoa(gid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc GroupsCreateSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditGroup {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tname := r.PostFormValue(\"name\")\n\tif name == \"\" {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_need_name\"), w, r, user)\n\t}\n\ttag := r.PostFormValue(\"tag\")\n\n\tvar admin, mod, banned bool\n\tif user.Perms.EditGroupGlobalPerms {\n\t\tswitch r.PostFormValue(\"type\") {\n\t\tcase \"Admin\":\n\t\t\tif !user.Perms.EditGroupAdmin {\n\t\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_create_cannot_designate_admin\"), w, r, user)\n\t\t\t}\n\t\t\tadmin = true\n\t\t\tmod = true\n\t\tcase \"Mod\":\n\t\t\tif !user.Perms.EditGroupSuperMod {\n\t\t\t\treturn c.LocalError(p.GetErrorPhrase(\"panel_groups_create_cannot_designate_supermod\"), w, r, user)\n\t\t\t}\n\t\t\tmod = true\n\t\tcase \"Banned\":\n\t\t\tbanned = true\n\t\t}\n\t}\n\n\tgid, err := c.Groups.Create(name, tag, admin, mod, banned)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"create\", gid, \"group\", user.GetIP(), user.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/groups/edit/\"+strconv.Itoa(gid), http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/logs.go",
    "content": "package panel\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\n// TODO: Link the usernames for successful registrations to the profiles\nfunc LogsRegs(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"registration_logs\", \"logs\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tlogCount := c.RegLogs.Count()\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 12\n\toffset, page, lastPage := c.PageOffset(logCount, page, perPage)\n\n\tlogs, err := c.RegLogs.GetOffset(offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tllist := make([]c.PageRegLogItem, len(logs))\n\tfor index, log := range logs {\n\t\tllist[index] = c.PageRegLogItem{log, strings.Replace(strings.TrimSuffix(log.FailureReason, \"|\"), \"|\", \" | \", -1)}\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelRegLogsPage{bp, llist, c.Paginator{pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_reglogs\", pi})\n}\n\n// TODO: Log errors when something really screwy is going on?\n// TODO: Base the slugs on the localised usernames?\nfunc handleUnknownUser(u *c.User, e error) *c.User {\n\tif e != nil {\n\t\treturn &c.User{Name: p.GetTmplPhrase(\"user_unknown\"), Link: c.BuildProfileURL(\"unknown\", 0)}\n\t}\n\treturn u\n}\nfunc handleUnknownTopic(t *c.Topic, e error) *c.Topic {\n\tif e != nil {\n\t\treturn &c.Topic{Title: p.GetTmplPhrase(\"topic_unknown\"), Link: c.BuildTopicURL(\"unknown\", 0)}\n\t}\n\treturn t\n}\n\n// TODO: Move the log building logic into /common/ and it's own abstraction\nfunc topicElementTypeAction(action, elementType string, elementID int, actor *c.User, topic *c.Topic) (out string) {\n\tif action == \"delete\" {\n\t\treturn p.GetTmplPhrasef(\"panel_logs_mod_action_topic_delete\", elementID, actor.Link, actor.Name)\n\t}\n\tvar tbit string\n\taarr := strings.Split(action, \"-\")\n\tswitch aarr[0] {\n\tcase \"lock\", \"unlock\", \"stick\", \"unstick\":\n\t\ttbit = aarr[0]\n\tcase \"move\":\n\t\tif len(aarr) == 2 {\n\t\t\tfid, _ := strconv.Atoi(aarr[1])\n\t\t\tforum, err := c.Forums.Get(fid)\n\t\t\tif err == nil {\n\t\t\t\treturn p.GetTmplPhrasef(\"panel_logs_mod_action_topic_move_dest\", topic.Link, topic.Title, forum.Link, forum.Name, actor.Link, actor.Name)\n\t\t\t}\n\t\t}\n\t\ttbit = \"move\"\n\tdefault:\n\t\treturn p.GetTmplPhrasef(\"panel_logs_mod_action_topic_unknown\", action, elementType, actor.Link, actor.Name)\n\t}\n\tif tbit != \"\" {\n\t\treturn p.GetTmplPhrasef(\"panel_logs_mod_action_topic_\"+tbit, topic.Link, topic.Title, actor.Link, actor.Name)\n\t}\n\treturn fmt.Sprintf(out, topic.Link, topic.Title, actor.Link, actor.Name)\n}\n\nfunc modlogsElementType(action, elementType string, elementID int, actor *c.User) (out string) {\n\tswitch elementType {\n\tcase \"topic\":\n\t\ttopic := handleUnknownTopic(c.Topics.Get(elementID))\n\t\tout = topicElementTypeAction(action, elementType, elementID, actor, topic)\n\tcase \"user\":\n\t\ttargetUser := handleUnknownUser(c.Users.Get(elementID))\n\t\tout = p.GetTmplPhrasef(\"panel_logs_mod_action_user_\"+action, targetUser.Link, targetUser.Name, actor.Link, actor.Name)\n\tcase \"reply\":\n\t\tif action == \"delete\" {\n\t\t\ttopic := handleUnknownTopic(c.TopicByReplyID(elementID))\n\t\t\tout = p.GetTmplPhrasef(\"panel_logs_mod_action_reply_delete\", topic.Link, topic.Title, actor.Link, actor.Name)\n\t\t}\n\tcase \"profile-reply\":\n\t\tif action == \"delete\" {\n\t\t\t// TODO: Optimise this\n\t\t\tvar profile *c.User\n\t\t\tprofileReply, err := c.Prstore.Get(elementID)\n\t\t\tif err != nil {\n\t\t\t\tprofile = &c.User{Name: p.GetTmplPhrase(\"user_unknown\"), Link: c.BuildProfileURL(\"unknown\", 0)}\n\t\t\t} else {\n\t\t\t\tprofile = handleUnknownUser(c.Users.Get(profileReply.ParentID))\n\t\t\t}\n\t\t\tout = p.GetTmplPhrasef(\"panel_logs_mod_action_profile_reply_delete\", profile.Link, profile.Name, actor.Link, actor.Name)\n\t\t}\n\t}\n\tif out == \"\" {\n\t\tout = p.GetTmplPhrasef(\"panel_logs_mod_action_unknown\", action, elementType, actor.Link, actor.Name)\n\t}\n\treturn out\n}\n\nfunc adminlogsElementType(action, elementType string, elementID int, actor *c.User, extra string) (out string) {\n\tswitch elementType {\n\t// TODO: Record more detail for this, e.g. which field/s was changed\n\tcase \"user\":\n\t\ttu := handleUnknownUser(c.Users.Get(elementID))\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_user_\"+action, tu.Link, tu.Name, actor.Link, actor.Name)\n\tcase \"group\":\n\t\tg, err := c.Groups.Get(elementID)\n\t\tif err != nil {\n\t\t\tg = &c.Group{Name: p.GetTmplPhrase(\"group_unknown\")}\n\t\t}\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_group_\"+action, \"/panel/groups/edit/\"+strconv.Itoa(g.ID), g.Name, actor.Link, actor.Name)\n\tcase \"group_promotion\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_group_promotion_\"+action, actor.Link, actor.Name)\n\tcase \"forum\":\n\t\tf, err := c.Forums.Get(elementID)\n\t\tif err != nil {\n\t\t\tf = &c.Forum{Name: p.GetTmplPhrase(\"forum_unknown\")}\n\t\t}\n\t\tif action == \"reorder\" {\n\t\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_forum_reorder\", actor.Link, actor.Name)\n\t\t} else {\n\t\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_forum_\"+action, \"/panel/forums/edit/\"+strconv.Itoa(f.ID), f.Name, actor.Link, actor.Name)\n\t\t}\n\tcase \"page\":\n\t\tpp, err := c.Pages.Get(elementID)\n\t\tif err != nil {\n\t\t\tpp = &c.CustomPage{Name: p.GetTmplPhrase(\"page_unknown\")}\n\t\t}\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_page_\"+action, \"/panel/pages/edit/\"+strconv.Itoa(pp.ID), pp.Name, actor.Link, actor.Name)\n\tcase \"setting\":\n\t\ts, err := c.SettingBox.Load().(c.SettingMap).BypassGet(action)\n\t\tif err != nil {\n\t\t\ts = &c.Setting{Name: p.GetTmplPhrase(\"setting_unknown\")}\n\t\t}\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_setting_edit\", \"/panel/settings/edit/\"+s.Name, s.Name, actor.Link, actor.Name)\n\tcase \"word_filter\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_word_filter_\"+action, actor.Link, actor.Name)\n\tcase \"menu\":\n\t\tif action == \"suborder\" {\n\t\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_menu_suborder\", elementID, actor.Link, actor.Name)\n\t\t}\n\tcase \"menu_item\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_menu_item_\"+action, \"/panel/themes/menus/item/edit/\"+strconv.Itoa(elementID), elementID, actor.Link, actor.Name)\n\tcase \"widget\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_widget_\"+action, \"/panel/themes/widgets/\", elementID, actor.Link, actor.Name)\n\tcase \"plugin\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_plugin_\"+action, extra, actor.Link, actor.Name)\n\tcase \"backup\":\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_backup_\"+action, actor.Link, actor.Name)\n\t}\n\tif out == \"\" {\n\t\tout = p.GetTmplPhrasef(\"panel_logs_admin_action_unknown\", action, elementType, actor.Link, actor.Name)\n\t}\n\treturn out\n}\n\nfunc LogsMod(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"mod_logs\", \"logs\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 12\n\toffset, page, lastPage := c.PageOffset(c.ModLogs.Count(), page, perPage)\n\n\tlogs, err := c.ModLogs.GetOffset(offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tllist := make([]c.PageLogItem, len(logs))\n\tfor index, log := range logs {\n\t\tactor := handleUnknownUser(c.Users.Get(log.ActorID))\n\t\taction := modlogsElementType(log.Action, log.ElementType, log.ElementID, actor)\n\t\tllist[index] = c.PageLogItem{Action: template.HTML(action), IP: log.IP, DoneAt: log.DoneAt}\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelLogsPage{bp, llist, c.Paginator{pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_modlogs\", pi})\n}\n\nfunc LogsAdmin(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"admin_logs\", \"logs\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 12\n\toffset, page, lastPage := c.PageOffset(c.AdminLogs.Count(), page, perPage)\n\n\tlogs, err := c.AdminLogs.GetOffset(offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tllist := make([]c.PageLogItem, len(logs))\n\tfor index, log := range logs {\n\t\tactor := handleUnknownUser(c.Users.Get(log.ActorID))\n\t\taction := adminlogsElementType(log.Action, log.ElementType, log.ElementID, actor, log.Extra)\n\t\tllist[index] = c.PageLogItem{Action: template.HTML(action), IP: log.IP, DoneAt: log.DoneAt}\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelLogsPage{bp, llist, c.Paginator{pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_adminlogs\", pi})\n}\n"
  },
  {
    "path": "routes/panel/pages.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc Pages(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"pages\", \"pages\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif r.FormValue(\"created\") == \"1\" {\n\t\tbp.AddNotice(\"panel_page_created\")\n\t} else if r.FormValue(\"deleted\") == \"1\" {\n\t\tbp.AddNotice(\"panel_page_deleted\")\n\t}\n\n\t// TODO: Test the pagination here\n\tpageCount := c.Pages.Count()\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 15\n\toffset, page, lastPage := c.PageOffset(pageCount, page, perPage)\n\n\tcPages, err := c.Pages.GetOffset(offset, perPage)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelCustomPagesPage{bp, cPages, c.Paginator{pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_page_list\", \"\", \"panel_pages\", &pi})\n}\n\nfunc PagesCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tname := c.SanitiseSingleLine(r.PostFormValue(\"name\"))\n\tif name == \"\" {\n\t\treturn c.LocalError(\"No name was provided for this page\", w, r, u)\n\t}\n\ttitle := c.SanitiseSingleLine(r.PostFormValue(\"title\"))\n\tif title == \"\" {\n\t\treturn c.LocalError(\"No title was provided for this page\", w, r, u)\n\t}\n\tbody := r.PostFormValue(\"body\")\n\tif body == \"\" {\n\t\treturn c.LocalError(\"No body was provided for this page\", w, r, u)\n\t}\n\n\tpage := c.BlankCustomPage()\n\tpage.Name = name\n\tpage.Title = title\n\tpage.Body = body\n\tpid, err := page.Create()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"create\", pid, \"page\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/pages/?created=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc PagesEdit(w http.ResponseWriter, r *http.Request, u *c.User, spid string) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"pages_edit\", \"pages\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif r.FormValue(\"updated\") == \"1\" {\n\t\tbp.AddNotice(\"panel_page_updated\")\n\t}\n\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Page ID needs to be an integer\", w, r, u)\n\t}\n\tpage, err := c.Pages.Get(pid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, bp.Header)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tpi := c.PanelCustomPageEditPage{bp, page}\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"panel_page_edit\", \"\", \"panel_pages_edit\", &pi})\n}\n\nfunc PagesEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, spid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Page ID needs to be an integer\", w, r, u)\n\t}\n\tname := c.SanitiseSingleLine(r.PostFormValue(\"name\"))\n\tif name == \"\" {\n\t\treturn c.LocalError(\"No name was provided for this page\", w, r, u)\n\t}\n\ttitle := c.SanitiseSingleLine(r.PostFormValue(\"title\"))\n\tif title == \"\" {\n\t\treturn c.LocalError(\"No title was provided for this page\", w, r, u)\n\t}\n\tbody := r.PostFormValue(\"body\")\n\tif body == \"\" {\n\t\treturn c.LocalError(\"No body was provided for this page\", w, r, u)\n\t}\n\n\tp, err := c.Pages.Get(pid)\n\tif err != nil {\n\t\treturn c.NotFound(w, r, nil)\n\t}\n\tp.Name = name\n\tp.Title = title\n\tp.Body = body\n\terr = p.Commit()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", pid, \"page\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/pages/?updated=1\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc PagesDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, spid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tpid, err := strconv.Atoi(spid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Page ID needs to be an integer\", w, r, u)\n\t}\n\terr = c.Pages.Delete(pid)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", pid, \"page\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/pages/?deleted=1\", http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/plugins.go",
    "content": "package panel\n\nimport (\n\t\"errors\"\n\t\"log\"\n\t\"net/http\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc Plugins(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbp, ferr := buildBasePage(w, r, u, \"plugins\", \"plugins\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManagePlugins {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tplList, i := make([]interface{}, len(c.Plugins)), 0\n\tfor _, pl := range c.Plugins {\n\t\tplList[i] = pl\n\t\ti++\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, bp.Header, c.Panel{bp, \"\", \"\", \"panel_plugins\", c.PanelPage{bp, plList, nil}})\n}\n\n// TODO: Abstract more of the plugin activation / installation / deactivation logic, so we can test all that more reliably and easily\nfunc PluginsActivate(w http.ResponseWriter, r *http.Request, u *c.User, uname string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManagePlugins {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tpl, ok := c.Plugins[uname]\n\tif !ok {\n\t\treturn c.LocalError(\"The plugin isn't registered in the system\", w, r, u)\n\t}\n\tif pl.Installable && !pl.Installed {\n\t\treturn c.LocalError(\"You can't activate this plugin without installing it first\", w, r, u)\n\t}\n\n\tactive, err := pl.BypassActive()\n\thasPlugin, err2 := pl.InDatabase()\n\tif err != nil || err2 != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tif pl.Activate != nil {\n\t\terr = pl.Activate(pl)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(err.Error(), w, r, u)\n\t\t}\n\t}\n\n\tif hasPlugin {\n\t\tif active {\n\t\t\treturn c.LocalError(\"The plugin is already active\", w, r, u)\n\t\t}\n\t\terr = pl.SetActive(true)\n\t} else {\n\t\terr = pl.AddToDatabase(true, false)\n\t}\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tlog.Printf(\"Activating plugin '%s'\", pl.Name)\n\terr = pl.Init(pl)\n\tif err != nil {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\terr = c.AdminLogs.CreateExtra(\"activate\", 0, \"plugin\", u.GetIP(), u.ID, c.SanitiseSingleLine(pl.Name))\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/plugins/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc PluginsDeactivate(w http.ResponseWriter, r *http.Request, u *c.User, uname string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManagePlugins {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tpl, ok := c.Plugins[uname]\n\tif !ok {\n\t\treturn c.LocalError(\"The plugin isn't registered in the system\", w, r, u)\n\t}\n\tlog.Printf(\"plugin: %+v\\n\", pl)\n\n\tactive, err := pl.BypassActive()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t} else if !active {\n\t\treturn c.LocalError(\"The plugin you're trying to deactivate isn't active\", w, r, u)\n\t}\n\n\terr = pl.SetActive(false)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif pl.Deactivate != nil {\n\t\tpl.Deactivate(pl)\n\t}\n\terr = c.AdminLogs.CreateExtra(\"deactivate\", 0, \"plugin\", u.GetIP(), u.ID, c.SanitiseSingleLine(pl.Name))\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/plugins/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc PluginsInstall(w http.ResponseWriter, r *http.Request, u *c.User, uname string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManagePlugins {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tpl, ok := c.Plugins[uname]\n\tif !ok {\n\t\treturn c.LocalError(\"The plugin isn't registered in the system\", w, r, u)\n\t}\n\tif !pl.Installable {\n\t\treturn c.LocalError(\"This plugin is not installable\", w, r, u)\n\t}\n\tif pl.Installed {\n\t\treturn c.LocalError(\"This plugin has already been installed\", w, r, u)\n\t}\n\n\tactive, err := pl.BypassActive()\n\thasPlugin, err2 := pl.InDatabase()\n\tif err != nil || err2 != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif active {\n\t\treturn c.InternalError(errors.New(\"An uninstalled plugin is still active\"), w, r)\n\t}\n\n\tif pl.Install != nil {\n\t\terr = pl.Install(pl)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(err.Error(), w, r, u)\n\t\t}\n\t}\n\n\tif pl.Activate != nil {\n\t\terr = pl.Activate(pl)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(err.Error(), w, r, u)\n\t\t}\n\t}\n\n\tif hasPlugin {\n\t\terr = pl.SetInstalled(true)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\terr = pl.SetActive(true)\n\t} else {\n\t\terr = pl.AddToDatabase(true, true)\n\t}\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tlog.Printf(\"Installing plugin '%s'\", pl.Name)\n\terr = pl.Init(pl)\n\tif err != nil {\n\t\treturn c.LocalError(err.Error(), w, r, u)\n\t}\n\terr = c.AdminLogs.CreateExtra(\"install\", 0, \"plugin\", u.GetIP(), u.ID, c.SanitiseSingleLine(pl.Name))\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/plugins/\", http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/settings.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc Settings(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"settings\", \"settings\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\t// TODO: What if the list gets too long? How should we structure this?\n\tsettings, err := basePage.Settings.BypassGetAll()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tsettingPhrases := p.GetAllSettingPhrases()\n\n\tvar settingList []*c.PanelSetting\n\tfor _, settingPtr := range settings {\n\t\ts := settingPtr.Copy()\n\t\tswitch s.Type {\n\t\tcase \"list\":\n\t\t\tllist := settingPhrases[s.Name+\"_label\"]\n\t\t\tlabels := strings.Split(llist, \",\")\n\t\t\tconv, err := strconv.Atoi(s.Content)\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalError(\"The setting '\"+s.Name+\"' can't be converted to an integer\", w, r, u)\n\t\t\t}\n\t\t\ts.Content = labels[conv-1]\n\t\t\t// TODO: Localise this\n\t\tcase \"bool\":\n\t\t\tif s.Content == \"1\" {\n\t\t\t\t//s.Content = \"Yes\"\n\t\t\t\ts.Content = p.GetTmplPhrase(\"option_yes\")\n\t\t\t} else {\n\t\t\t\t//s.Content = \"No\"\n\t\t\t\ts.Content = p.GetTmplPhrase(\"option_no\")\n\t\t\t}\n\t\tcase \"html-attribute\":\n\t\t\ts.Type = \"textarea\"\n\t\t}\n\t\tsettingList = append(settingList, &c.PanelSetting{s, p.GetSettingPhrase(s.Name)})\n\t}\n\n\tpi := c.PanelPage{basePage, tList, settingList}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_settings\", &pi})\n}\n\nfunc SettingEdit(w http.ResponseWriter, r *http.Request, u *c.User, sname string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"edit_setting\", \"settings\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\ts, err := basePage.Settings.BypassGet(sname)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The setting you want to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar itemList []c.OptionLabel\n\tif s.Type == \"list\" {\n\t\tllist := p.GetSettingPhrase(s.Name + \"_label\")\n\t\tconv, err := strconv.Atoi(s.Content)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(\"The value of this setting couldn't be converted to an integer\", w, r, u)\n\t\t}\n\t\tfor index, label := range strings.Split(llist, \",\") {\n\t\t\titemList = append(itemList, c.OptionLabel{\n\t\t\t\tLabel:    label,\n\t\t\t\tValue:    index + 1,\n\t\t\t\tSelected: conv == (index + 1),\n\t\t\t})\n\t\t}\n\t} else if s.Type == \"html-attribute\" {\n\t\ts.Type = \"textarea\"\n\t}\n\n\tpSetting := &c.PanelSetting{s, p.GetSettingPhrase(s.Name)}\n\tpi := c.PanelSettingPage{basePage, itemList, pSetting}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_setting\", &pi})\n}\n\nfunc SettingEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, name string) c.RouteError {\n\theaderLite, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tname = c.SanitiseSingleLine(name)\n\tcontent := c.SanitiseBody(r.PostFormValue(\"value\"))\n\trerr := headerLite.Settings.Update(name, content)\n\tif rerr != nil {\n\t\treturn rerr\n\t}\n\t// TODO: Avoid this hack\n\terr := c.AdminLogs.Create(name, 0, \"setting\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/settings/\", http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/themes.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc Themes(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"themes\", \"themes\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tvar pThemeList, vThemeList []*c.Theme\n\tfor _, theme := range c.Themes {\n\t\tif theme.HideFromThemes {\n\t\t\tcontinue\n\t\t}\n\t\tif theme.ForkOf == \"\" {\n\t\t\tpThemeList = append(pThemeList, theme)\n\t\t} else {\n\t\t\tvThemeList = append(vThemeList, theme)\n\t\t}\n\t}\n\n\tpi := c.PanelThemesPage{basePage, pThemeList, vThemeList}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"panel_themes\", \"\", \"panel_themes\", &pi})\n}\n\nfunc ThemesSetDefault(w http.ResponseWriter, r *http.Request, u *c.User, uname string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\ttheme, ok := c.Themes[uname]\n\tif !ok {\n\t\treturn c.LocalError(\"The theme isn't registered in the system\", w, r, u)\n\t}\n\tif theme.Disabled {\n\t\treturn c.LocalError(\"You must not enable this theme\", w, r, u)\n\t}\n\n\terr := c.UpdateDefaultTheme(theme)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.CreateExtra(\"set_default\", 0, \"theme\", u.GetIP(), u.ID, c.SanitiseSingleLine(theme.Name))\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/themes/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ThemesMenus(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"themes_menus\", \"themes\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tvar menuList []c.PanelMenuListItem\n\tfor mid, list := range c.Menus.GetAllMap() {\n\t\tname := \"\"\n\t\tif mid == 1 {\n\t\t\tname = p.GetTmplPhrase(\"panel_themes_menus_main\")\n\t\t}\n\t\tmenuList = append(menuList, c.PanelMenuListItem{\n\t\t\tName:      name,\n\t\t\tID:        mid,\n\t\t\tItemCount: len(list.List),\n\t\t})\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_themes_menus\", &c.PanelMenuListPage{basePage, menuList}})\n}\n\nfunc ThemesMenusEdit(w http.ResponseWriter, r *http.Request, u *c.User, smid string) c.RouteError {\n\t// TODO: Something like Menu #1 for the title?\n\tbasePage, ferr := buildBasePage(w, r, u, \"themes_menus_edit\", \"themes\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tbasePage.Header.AddScript(\"Sortable-1.4.0/Sortable.min.js\")\n\tbasePage.Header.AddScriptAsync(\"panel_menu_items.js\")\n\n\tmid, err := strconv.Atoi(smid)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\tmenuHold, err := c.Menus.Get(mid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, basePage.Header)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar menuList []c.MenuItem\n\tfor _, item := range menuHold.List {\n\t\tmenuTmpls := map[string]c.MenuTmpl{\n\t\t\titem.TmplName: menuHold.Parse(item.Name, []byte(\"{{.Name}}\")),\n\t\t}\n\t\tvar renderBuffer [][]byte\n\t\tvar variableIndices []int\n\t\trenderBuffer, _ = menuHold.ScanItem(menuTmpls, item, renderBuffer, variableIndices)\n\n\t\tvar out string\n\t\tfor _, renderItem := range renderBuffer {\n\t\t\tout += string(renderItem)\n\t\t}\n\t\titem.Name = out\n\t\tif item.Name == \"\" {\n\t\t\titem.Name = \"???\"\n\t\t}\n\t\tmenuList = append(menuList, item)\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_themes_menus_items\", &c.PanelMenuPage{basePage, mid, menuList}})\n}\n\nfunc ThemesMenuItemEdit(w http.ResponseWriter, r *http.Request, u *c.User, sitemID string) c.RouteError {\n\t// TODO: Something like Menu #1 for the title?\n\tbasePage, ferr := buildBasePage(w, r, u, \"themes_menus_edit\", \"themes\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\titemID, err := strconv.Atoi(sitemID)\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t}\n\tmenuItem, err := c.Menus.ItemStore().Get(itemID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, basePage.Header)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_themes_menus_item_edit\", &c.PanelMenuItemPage{basePage, menuItem}})\n}\n\nfunc themesMenuItemSetters(r *http.Request, i c.MenuItem) c.MenuItem {\n\tgetItem := func(name string) string {\n\t\treturn c.SanitiseSingleLine(r.PostFormValue(\"item-\" + name))\n\t}\n\ti.Name = getItem(\"name\")\n\ti.HTMLID = getItem(\"htmlid\")\n\ti.CSSClass = getItem(\"cssclass\")\n\ti.Position = getItem(\"position\")\n\tif i.Position != \"left\" && i.Position != \"right\" {\n\t\ti.Position = \"left\"\n\t}\n\ti.Path = getItem(\"path\")\n\ti.Aria = getItem(\"aria\")\n\ti.Tooltip = getItem(\"tooltip\")\n\ti.TmplName = getItem(\"tmplname\")\n\ti.GuestOnly = false\n\n\tswitch getItem(\"permissions\") {\n\tcase \"everyone\":\n\t\ti.MemberOnly = false\n\t\ti.SuperModOnly = false\n\t\ti.AdminOnly = false\n\tcase \"guest-only\":\n\t\ti.GuestOnly = true\n\t\ti.MemberOnly = false\n\t\ti.SuperModOnly = false\n\t\ti.AdminOnly = false\n\tcase \"member-only\":\n\t\ti.MemberOnly = true\n\t\ti.SuperModOnly = false\n\t\ti.AdminOnly = false\n\tcase \"supermod-only\":\n\t\ti.MemberOnly = true\n\t\ti.SuperModOnly = true\n\t\ti.AdminOnly = false\n\tcase \"admin-only\":\n\t\ti.MemberOnly = true\n\t\ti.SuperModOnly = true\n\t\ti.AdminOnly = true\n\t}\n\treturn i\n}\n\nfunc ThemesMenuItemEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sitemID string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\titemID, err := strconv.Atoi(sitemID)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\tmenuItem, err := c.Menus.ItemStore().Get(itemID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"This item doesn't exist.\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\t//menuItem = menuItem.Copy() // If we switch this for a pointer, we might need this as a scratchpad\n\tmenuItem = themesMenuItemSetters(r, menuItem)\n\n\terr = menuItem.Commit()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", menuItem.ID, \"menu_item\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/menus/item/edit/\"+strconv.Itoa(itemID), w, r, js)\n}\n\nfunc ThemesMenuItemCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\tsmenuID := r.PostFormValue(\"mid\")\n\tif smenuID == \"\" {\n\t\treturn c.LocalErrorJSQ(\"No menuID provided\", w, r, u, js)\n\t}\n\tmenuID, err := strconv.Atoi(smenuID)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\n\tmenuItem := c.MenuItem{MenuID: menuID}\n\tmenuItem = themesMenuItemSetters(r, menuItem)\n\titemID, err := menuItem.Create()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"create\", itemID, \"menu_item\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/menus/item/edit/\"+strconv.Itoa(itemID), w, r, js)\n}\n\nfunc ThemesMenuItemDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sitemID string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\titemID, err := strconv.Atoi(sitemID)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\tmenuItem, err := c.Menus.ItemStore().Get(itemID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"This item doesn't exist.\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\t//menuItem = menuItem.Copy() // If we switch this for a pointer, we might need this as a scratchpad\n\n\terr = menuItem.Delete()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", menuItem.ID, \"menu_item\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/menus/\", w, r, js)\n}\n\nfunc ThemesMenuItemOrderSubmit(w http.ResponseWriter, r *http.Request, u *c.User, smid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\tmid, err := strconv.Atoi(smid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\tmenuHold, err := c.Menus.Get(mid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"Can't find menu\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tsitems := strings.TrimSuffix(strings.TrimPrefix(r.PostFormValue(\"items\"), \"{\"), \"}\")\n\t//fmt.Printf(\"sitems: %+v\\n\", sitems)\n\n\tupdateMap := make(map[int]int)\n\tfor index, smiid := range strings.Split(sitems, \",\") {\n\t\tmiid, err := strconv.Atoi(smiid)\n\t\tif err != nil {\n\t\t\treturn c.LocalErrorJSQ(\"Invalid integer in menu item list\", w, r, u, js)\n\t\t}\n\t\tupdateMap[miid] = index\n\t}\n\tmenuHold.UpdateOrder(updateMap)\n\n\terr = c.AdminLogs.Create(\"suborder\", menuHold.MenuID, \"menu\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/menus/edit/\"+strconv.Itoa(mid), w, r, js)\n}\n\nfunc ThemesWidgets(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"themes_widgets\", \"themes\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tbasePage.Header.AddScript(\"widgets.js\")\n\n\tdocks := make(map[string][]c.WidgetEdit)\n\tfor _, name := range c.GetDockList() {\n\t\tif name == \"leftOfNav\" || name == \"rightOfNav\" {\n\t\t\tcontinue\n\t\t}\n\t\tvar widgets []c.WidgetEdit\n\t\tfor _, widget := range c.GetDock(name) {\n\t\t\tdata := make(map[string]string)\n\t\t\terr := json.Unmarshal([]byte(widget.RawBody), &data)\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t\twidgets = append(widgets, c.WidgetEdit{widget, data})\n\t\t}\n\t\tdocks[name] = widgets\n\t}\n\n\tpi := c.PanelWidgetListPage{basePage, docks, c.WidgetEdit{&c.Widget{ID: 0, Type: \"simple\"}, make(map[string]string)}}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_themes_widgets\", pi})\n}\n\nfunc widgetsParseInputs(r *http.Request, widget *c.Widget) (*c.WidgetEdit, error) {\n\tdata := make(map[string]string)\n\twidget.Enabled = r.FormValue(\"wenabled\") == \"1\"\n\twidget.Location = r.FormValue(\"wlocation\")\n\tif widget.Location == \"\" {\n\t\treturn nil, errors.New(\"You need to specify a location for this widget.\")\n\t}\n\twidget.Side = r.FormValue(\"wside\")\n\tif !c.HasDock(widget.Side) {\n\t\treturn nil, errors.New(\"The widget dock you specified doesn't exist.\")\n\t}\n\n\twtype := r.FormValue(\"wtype\")\n\tswitch wtype {\n\tcase \"simple\", \"about\":\n\t\tdata[\"Name\"] = r.FormValue(\"wname\")\n\t\tif data[\"Name\"] == \"\" {\n\t\t\treturn nil, errors.New(\"You need to specify a title for this widget.\")\n\t\t}\n\t\tdata[\"Text\"] = r.FormValue(\"wtext\")\n\t\tif data[\"Text\"] == \"\" {\n\t\t\treturn nil, errors.New(\"You need to fill in the body for this widget.\")\n\t\t}\n\t\twidget.Type = wtype // ? - Are we sure we should be directly assigning user provided data even if it's validated?\n\tcase \"wol\", \"wol_context\", \"search_and_filter\":\n\t\twidget.Type = wtype // ? - Are we sure we should be directly assigning user provided data even if it's validated?\n\tdefault:\n\t\treturn nil, errors.New(\"Unknown widget type\")\n\t}\n\n\treturn &c.WidgetEdit{widget, data}, nil\n}\n\n// ThemesWidgetsEditSubmit is an action which is triggered when someone sends an update request for a widget\nfunc ThemesWidgetsEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, swid string) c.RouteError {\n\t//fmt.Println(\"in ThemesWidgetsEditSubmit\")\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\twid, err := strconv.Atoi(swid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\twidget, err := c.Widgets.Get(wid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFoundJSQ(w, r, nil, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tewidget, err := widgetsParseInputs(r, widget.Copy())\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(err.Error(), w, r, u, js)\n\t}\n\n\terr = ewidget.Commit()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"edit\", widget.ID, \"widget\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/widgets/\", w, r, js)\n}\n\n// ThemesWidgetsCreateSubmit is an action which is triggered when someone sends a create request for a widget\nfunc ThemesWidgetsCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\tewidget, err := widgetsParseInputs(r, &c.Widget{})\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(err.Error(), w, r, u, js)\n\t}\n\twid, err := ewidget.Create()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.Create(\"create\", wid, \"widget\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/widgets/\", w, r, js)\n}\n\nfunc ThemesWidgetsDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, swid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.ManageThemes {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\twid, err := strconv.Atoi(swid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u, js)\n\t}\n\twidget, err := c.Widgets.Get(wid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = widget.Delete()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", widget.ID, \"widget\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/themes/widgets/\", w, r, js)\n}\n"
  },
  {
    "path": "routes/panel/users.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc Users(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"users\", \"users\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\tname := r.FormValue(\"s-name\")\n\temail := r.FormValue(\"s-email\")\n\tif !u.Perms.EditUserEmail && email != \"\" {\n\t\treturn c.LocalError(\"Only users with the EditUserEmail permission can search by email.\", w, r, u)\n\t}\n\tgroup := r.FormValue(\"s-group\")\n\tf := func(l ...string) bool {\n\t\tfor _, ll := range l {\n\t\t\tif ll != \"\" {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\thasParam := f(name, email, group)\n\tgid, _ := strconv.Atoi(group)\n\t/*if group == \"\" {\n\t\tgid = -1\n\t}*/\n\thasParam = hasParam || gid > 0\n\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tperPage := 15\n\tuserCount := basePage.Stats.Users\n\tif hasParam {\n\t\tuserCount = c.Users.CountSearch(name, email, gid)\n\t}\n\toffset, page, lastPage := c.PageOffset(userCount, page, perPage)\n\n\tallGroups, e := c.Groups.GetAll()\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tvar users []*c.User\n\tif hasParam {\n\t\tusers, e = c.Users.SearchOffset(name, email, gid, offset, perPage)\n\t} else {\n\t\tusers, e = c.Users.GetOffset(offset, perPage)\n\t}\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tname = url.QueryEscape(name)\n\temail = url.QueryEscape(email)\n\tsearch := c.PanelUserPageSearch{name, email, gid, hasParam}\n\n\tvar params string\n\tif hasParam {\n\t\tif name != \"\" {\n\t\t\tparams += \"s-name=\" + name + \"&\"\n\t\t}\n\t\tif email != \"\" {\n\t\t\tparams += \"s-email=\" + email + \"&\"\n\t\t}\n\t\tif gid > 0 {\n\t\t\tparams += \"s-group=\" + strconv.Itoa(gid) + \"&\"\n\t\t}\n\t}\n\tpageList := c.Paginate(page, lastPage, 5)\n\tpi := c.PanelUserPage{basePage, users, allGroups, search, c.PaginatorMod{template.URL(params), pageList, page, lastPage}}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_users\", &pi})\n}\n\nfunc UsersEdit(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"edit_user\", \"users\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditUser {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif targetUser.IsAdmin && !u.IsAdmin {\n\t\treturn c.LocalError(\"Only administrators can edit the account of an administrator.\", w, r, u)\n\t}\n\n\t// ? - Should we stop admins from deleting all the groups? Maybe, protect the group they're currently using?\n\tgroups, err := c.Groups.GetRange(1, 0) // ? - 0 = Go to the end\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar groupList []*c.Group\n\tfor _, group := range groups {\n\t\tif !u.Perms.EditUserGroupAdmin && group.IsAdmin {\n\t\t\tcontinue\n\t\t}\n\t\tif !u.Perms.EditUserGroupSuperMod && group.IsMod {\n\t\t\tcontinue\n\t\t}\n\t\tgroupList = append(groupList, group)\n\t}\n\n\tif r.FormValue(\"updated\") == \"1\" {\n\t\tbasePage.AddNotice(\"panel_user_updated\")\n\t}\n\tshowEmail := r.FormValue(\"show-email\") == \"1\"\n\n\tpi := c.PanelUserEditPage{basePage, groupList, targetUser, showEmail}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_user_edit\", &pi})\n}\n\nfunc UsersEditSubmit(w http.ResponseWriter, r *http.Request, user *c.User, suid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.EditUser {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, user)\n\t}\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to edit doesn't exist.\", w, r, user)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif targetUser.IsAdmin && !user.IsAdmin {\n\t\treturn c.LocalError(\"Only administrators can edit the account of other administrators.\", w, r, user)\n\t}\n\n\tnewName := c.SanitiseSingleLine(r.PostFormValue(\"name\"))\n\tif newName == \"\" {\n\t\treturn c.LocalError(\"You didn't put in a name.\", w, r, user)\n\t}\n\n\t// TODO: How should activation factor into admin set emails?\n\t// TODO: How should we handle secondary emails? Do we even have secondary emails implemented?\n\tnewEmail := c.SanitiseSingleLine(r.PostFormValue(\"email\"))\n\tif newEmail == \"\" && targetUser.Email != \"\" {\n\t\treturn c.LocalError(\"You didn't put in an email address.\", w, r, user)\n\t}\n\tif newEmail == \"-1\" {\n\t\tnewEmail = targetUser.Email\n\t}\n\tif (newEmail != targetUser.Email) && !user.Perms.EditUserEmail {\n\t\treturn c.LocalError(\"You need the EditUserEmail permission to edit the email address of a user.\", w, r, user)\n\t}\n\n\tnewPassword := r.PostFormValue(\"password\")\n\tif newPassword != \"\" && !user.Perms.EditUserPassword {\n\t\treturn c.LocalError(\"You need the EditUserPassword permission to edit the password of a user.\", w, r, user)\n\t}\n\n\tnewGroup, err := strconv.Atoi(r.PostFormValue(\"group\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"You need to provide a whole number for the group ID\", w, r, user)\n\t}\n\tgroup, err := c.Groups.Get(newGroup)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The group you're trying to place this user in doesn't exist.\", w, r, user)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif !user.Perms.EditUserGroupAdmin && group.IsAdmin {\n\t\treturn c.LocalError(\"You need the EditUserGroupAdmin permission to assign someone to an administrator group.\", w, r, user)\n\t}\n\tif !user.Perms.EditUserGroupSuperMod && group.IsMod {\n\t\treturn c.LocalError(\"You need the EditUserGroupSuperMod permission to assign someone to a super mod group.\", w, r, user)\n\t}\n\n\terr = targetUser.Update(newName, c.CanonEmail(newEmail), newGroup)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tred := false\n\tif newPassword != \"\" {\n\t\tc.SetPassword(targetUser.ID, newPassword)\n\t\t// Log the user out as a safety precaution\n\t\tc.Auth.ForceLogout(targetUser.ID)\n\t\tred = true\n\t}\n\ttargetUser.CacheRemove()\n\n\ttargetUser, err = c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to edit doesn't exist.\", w, r, user)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.GroupPromotions.PromoteIfEligible(targetUser, targetUser.Level, targetUser.Posts, targetUser.CreatedAt)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\ttargetUser.CacheRemove()\n\n\terr = c.AdminLogs.Create(\"edit\", targetUser.ID, \"user\", user.GetIP(), user.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// If we're changing our own password, redirect to the index rather than to a noperms error due to the force logout\n\tif targetUser.ID == user.ID && red {\n\t\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\t} else {\n\t\tvar se string\n\t\tif r.PostFormValue(\"show-email\") == \"1\" {\n\t\t\tse = \"&show-email=1\"\n\t\t}\n\t\thttp.Redirect(w, r, \"/panel/users/edit/\"+strconv.Itoa(targetUser.ID)+\"?updated=1\"+se, http.StatusSeeOther)\n\t}\n\treturn nil\n}\n\nfunc UsersAvatarSubmit(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\t// TODO: Check the UploadAvatars permission too?\n\tif !u.Perms.EditUser {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif targetUser.IsAdmin && !u.IsAdmin {\n\t\treturn c.LocalError(\"Only administrators can edit the account of other administrators.\", w, r, u)\n\t}\n\n\text, ferr := c.UploadAvatar(w, r, u, targetUser.ID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tferr = c.ChangeAvatar(\".\"+ext, w, r, targetUser)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\t// TODO: Only schedule a resize if the avatar isn't tiny\n\terr = targetUser.ScheduleAvatarResize()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\terr = c.AdminLogs.Create(\"edit\", targetUser.ID, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar se string\n\tif r.PostFormValue(\"show-email\") == \"1\" {\n\t\tse = \"&show-email=1\"\n\t}\n\thttp.Redirect(w, r, \"/panel/users/edit/\"+strconv.Itoa(targetUser.ID)+\"?updated=1\"+se, http.StatusSeeOther)\n\treturn nil\n}\n\nfunc UsersAvatarRemoveSubmit(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditUser {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to edit doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif targetUser.IsAdmin && !u.IsAdmin {\n\t\treturn c.LocalError(\"Only administrators can edit the account of other administrators.\", w, r, u)\n\t}\n\tferr = c.ChangeAvatar(\"\", w, r, targetUser)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\n\terr = c.AdminLogs.Create(\"edit\", targetUser.ID, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar se string\n\tif r.PostFormValue(\"show-email\") == \"1\" {\n\t\tse = \"&show-email=1\"\n\t}\n\thttp.Redirect(w, r, \"/panel/users/edit/\"+strconv.Itoa(targetUser.ID)+\"?updated=1\"+se, http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/panel/word_filters.go",
    "content": "package panel\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc WordFilters(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"word_filters\", \"word-filters\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\t// TODO: What if this list gets too long?\n\tfilters, e := c.WordFilters.GetAll()\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\tpi := c.PanelPage{basePage, tList, filters}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_word_filters\", &pi})\n}\n\nfunc WordFiltersCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\t// ? - We're not doing a full sanitise here, as it would be useful if admins were able to put down rules for replacing things with HTML, etc.\n\tfind := strings.TrimSpace(r.PostFormValue(\"find\"))\n\tif find == \"\" {\n\t\treturn c.LocalErrorJSQ(\"You need to specify what word you want to match\", w, r, u, js)\n\t}\n\n\t// Unlike with find, it's okay if we leave this blank, as this means that the admin wants to remove the word entirely with no replacement\n\treplace := strings.TrimSpace(r.PostFormValue(\"replace\"))\n\n\twfid, e := c.WordFilters.Create(find, replace)\n\tif e != nil {\n\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t}\n\te = c.AdminLogs.Create(\"create\", wfid, \"word_filter\", u.GetIP(), u.ID)\n\tif e != nil {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\n\treturn successRedirect(\"/panel/settings/word-filters/\", w, r, js)\n}\n\n// TODO: Implement this as a non-JS fallback\nfunc WordFiltersEdit(w http.ResponseWriter, r *http.Request, u *c.User, wfid string) c.RouteError {\n\tbasePage, ferr := buildBasePage(w, r, u, \"edit_word_filter\", \"word-filters\")\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\t_ = wfid\n\n\tpi := c.PanelPage{basePage, tList, nil}\n\treturn renderTemplate(\"panel\", w, r, basePage.Header, c.Panel{basePage, \"\", \"\", \"panel_word_filters_edit\", &pi})\n}\n\nfunc WordFiltersEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, swfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\twfid, err := strconv.Atoi(swfid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The word filter ID must be an integer.\", w, r, u, js)\n\t}\n\tfind := strings.TrimSpace(r.PostFormValue(\"find\"))\n\tif find == \"\" {\n\t\treturn c.LocalErrorJSQ(\"You need to specify what word you want to match\", w, r, u, js)\n\t}\n\t// Unlike with find, it's okay if we leave this blank, as this means that the admin wants to remove the word entirely with no replacement\n\treplace := strings.TrimSpace(r.PostFormValue(\"replace\"))\n\n\twf, err := c.WordFilters.Get(wfid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"This word filter doesn't exist.\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.WordFilters.Update(wfid, find, replace)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tlBytes, err := json.Marshal(c.WordFilterDiff{wf.Find, wf.Replace, find, replace})\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = c.AdminLogs.CreateExtra(\"edit\", wfid, \"word_filter\", u.GetIP(), u.ID, string(lBytes))\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/settings/word-filters/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc WordFiltersDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, swfid string) c.RouteError {\n\t_, ferr := c.SimplePanelUserCheck(w, r, u)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\tif !u.Perms.EditSettings {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\twfid, err := strconv.Atoi(swfid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The word filter ID must be an integer.\", w, r, u, js)\n\t}\n\terr = c.WordFilters.Delete(wfid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"This word filter doesn't exist\", w, r, u, js)\n\t}\n\terr = c.AdminLogs.Create(\"delete\", wfid, \"word_filter\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/panel/settings/word-filters/\", http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes/poll.go",
    "content": "package routes\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc PollVote(w http.ResponseWriter, r *http.Request, u *c.User, sPollID string) c.RouteError {\n\tpollID, err := strconv.Atoi(sPollID)\n\tif err != nil {\n\t\treturn c.PreError(\"The provided PollID is not a valid number.\", w, r)\n\t}\n\tpoll, err := c.Polls.Get(pollID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreError(\"The poll you tried to vote for doesn't exist.\", w, r)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar topic *c.Topic\n\tif poll.ParentTable == \"replies\" {\n\t\treply, err := c.Rstore.Get(poll.ParentID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.PreError(\"The parent post doesn't exist.\", w, r)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\ttopic, err = c.Topics.Get(reply.ParentID)\n\t} else if poll.ParentTable == \"topics\" {\n\t\ttopic, err = c.Topics.Get(poll.ParentID)\n\t} else {\n\t\treturn c.InternalError(errors.New(\"Unknown parentTable for poll\"), w, r)\n\t}\n\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreError(\"The parent topic doesn't exist.\", w, r)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\t_, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\toptIndex, err := strconv.Atoi(r.PostFormValue(\"poll_option_input\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"Malformed input\", w, r, u)\n\t}\n\terr = poll.CastVote(optIndex, u.ID, u.GetIP())\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(topic.ID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc PollResults(w http.ResponseWriter, r *http.Request, u *c.User, sPollID string) c.RouteError {\n\t//log.Print(\"in PollResults\")\n\tpollID, err := strconv.Atoi(sPollID)\n\tif err != nil {\n\t\treturn c.PreError(\"The provided PollID is not a valid number.\", w, r)\n\t}\n\tpoll, err := c.Polls.Get(pollID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreError(\"The poll you tried to vote for doesn't exist.\", w, r)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Implement a version of this which doesn't rely so much on sequential order\n\tvar ob bytes.Buffer\n\tob.WriteRune('[')\n\tvar i int\n\te := poll.Resultsf(func(votes int) error {\n\t\tif i != 0 {\n\t\t\tob.WriteRune(',')\n\t\t}\n\t\tob.WriteString(strconv.Itoa(votes))\n\t\ti++\n\t\treturn nil\n\t})\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn c.InternalError(e, w, r)\n\t}\n\tob.WriteRune(']')\n\tw.Write(ob.Bytes())\n\n\treturn nil\n}\n"
  },
  {
    "path": "routes/profile.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"math\"\n\t\"net/http\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype ProfileStmts struct {\n\tgetReplies *sql.Stmt\n}\n\nvar profileStmts ProfileStmts\n\n// TODO: Move these DbInits into some sort of abstraction\nfunc init() {\n\tc.DbInits.Add(func(acc *qgen.Accumulator) error {\n\t\tprofileStmts = ProfileStmts{\n\t\t\tgetReplies: acc.SimpleLeftJoin(\"users_replies\", \"users\", \"users_replies.rid, users_replies.content, users_replies.createdBy, users_replies.createdAt, users_replies.lastEdit, users_replies.lastEditBy, users.avatar, users.name, users.group\", \"users_replies.createdBy=users.uid\", \"users_replies.uid=?\", \"\", \"\"),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\n// TODO: Remove the View part of the name?\nfunc ViewProfile(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header) c.RouteError {\n\tvar reCreatedAt time.Time\n\tvar reContent, reCreatedByName, reAvatar string\n\tvar rid, reCreatedBy, reLastEdit, reLastEditBy, reGroup int\n\tvar reList []*c.ReplyUser\n\n\t// TODO: Do a 301 if it's the wrong username? Do a canonical too?\n\t_, pid, err := ParseSEOURL(r.URL.Path[len(\"/user/\"):])\n\tif err != nil {\n\t\treturn c.SimpleError(phrases.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, h)\n\t}\n\tif pid == 0 {\n\t\treturn c.NotFound(w, r, h)\n\t}\n\n\tvar puser *c.User\n\tif pid == user.ID {\n\t\tuser.IsMod = true\n\t\tpuser = user\n\t} else {\n\t\t// Fetch the user data\n\t\t// TODO: Add a shared function for checking for ErrNoRows and internal erroring if it's not that case?\n\t\tpuser, err = c.Users.Get(pid)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.NotFound(w, r, h)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\th.Title = phrases.GetTitlePhrasef(\"profile\", puser.Name)\n\th.Path = c.BuildProfileURL(c.NameToSlug(puser.Name), puser.ID)\n\t// TODO: Preload this?\n\th.AddSheet(h.Theme.Name + \"/profile.css\")\n\tif user.Loggedin {\n\t\th.AddScriptAsync(\"profile_member.js\")\n\t}\n\n\t// Get the replies..\n\trows, err := profileStmts.getReplies.Query(puser.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tdefer rows.Close()\n\n\tfor rows.Next() {\n\t\terr := rows.Scan(&rid, &reContent, &reCreatedBy, &reCreatedAt, &reLastEdit, &reLastEditBy, &reAvatar, &reCreatedByName, &reGroup)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\treLiked := false\n\t\treLikeCount := 0\n\t\tru := &c.ReplyUser{Reply: c.Reply{rid, puser.ID, reContent, reCreatedBy /*, reGroup*/, reCreatedAt, reLastEdit, reLastEditBy, 0, \"\", reLiked, reLikeCount, 0, \"\"}, ContentHtml: c.ParseMessage(reContent, 0, \"\", user.ParseSettings, user), CreatedByName: reCreatedByName, Avatar: reAvatar, Group: reGroup, Level: 0}\n\t\t_, err = ru.Init(user)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tif puser.ID == ru.CreatedBy {\n\t\t\tru.Tag = phrases.GetTmplPhrase(\"profile.owner_tag\")\n\t\t}\n\n\t\t// TODO: Add a hook here\n\t\treList = append(reList, ru)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// Normalise the score so that the user sees their relative progress to the next level rather than showing them their total score\n\tprevScore := c.GetLevelScore(puser.Level)\n\tscore := puser.Score\n\t//score = 23\n\tcurrentScore := score - prevScore\n\tnextScore := c.GetLevelScore(puser.Level+1) - prevScore\n\tperc := int(math.Floor((float64(currentScore) / float64(nextScore)) * 100))\n\tvar blocked, blockedInv bool\n\tif user.Loggedin {\n\t\tblocked, err = c.UserBlocks.IsBlockedBy(user.ID, puser.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tblockedInv, err = c.UserBlocks.IsBlockedBy(puser.ID, user.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\n\tcanMessage := (!blockedInv && user.Perms.UseConvos) || (!blockedInv && puser.IsSuperMod && user.Perms.UseConvosOnlyWithMod) || user.IsSuperMod\n\tcanComment := !blockedInv && user.Perms.CreateProfileReply\n\tshowComments := c.PrivacyCommentsShow(puser, user)\n\tif !showComments {\n\t\tcanComment = false\n\t}\n\tif !c.PrivacyAllowMessage(puser, user) {\n\t\tcanMessage = false\n\t}\n\n\tppage := c.ProfilePage{h, reList, *puser, currentScore, nextScore, perc, blocked, canMessage, canComment, showComments}\n\treturn renderTemplate(\"profile\", w, r, h, ppage)\n}\n"
  },
  {
    "path": "routes/profile_reply.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n)\n\nfunc ProfileReplyCreateSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tif !u.Perms.CreateProfileReply {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tuid, err := strconv.Atoi(r.PostFormValue(\"uid\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"Invalid UID\", w, r, u)\n\t}\n\tprofileOwner, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The profile you're trying to post on doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tblocked, err := c.UserBlocks.IsBlockedBy(profileOwner.ID, u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\t// Supermods can bypass blocks so they can tell people off when they do something stupid or have to convey important information\n\tif (blocked || !c.PrivacyCommentsShow(profileOwner, u)) && !u.IsSuperMod {\n\t\treturn c.LocalError(\"You don't have permission to send messages to one of these users.\", w, r, u)\n\t}\n\n\tcontent := c.PreparseMessage(r.PostFormValue(\"content\"))\n\tif len(content) == 0 {\n\t\treturn c.LocalError(\"You can't make a blank post\", w, r, u)\n\t}\n\t// TODO: Fully parse the post and store it in the parsed column\n\tprid, err := c.Prstore.Create(profileOwner.ID, content, u.ID, u.GetIP())\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// ! Be careful about leaking per-route permission state with user ptr\n\talert := c.Alert{ActorID: u.ID, TargetUserID: profileOwner.ID, Event: \"reply\", ElementType: \"user\", ElementID: profileOwner.ID, Actor: u, Extra: strconv.Itoa(prid)}\n\terr = c.AddActivityAndNotifyTarget(alert)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tco.PostCounter.Bump()\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(uid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ProfileReplyEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\trid, err := strconv.Atoi(srid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The provided Reply ID is not a valid number.\", w, r, u, js)\n\t}\n\treply, err := c.Prstore.Get(rid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The target reply doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tcreator, err := c.Users.Get(reply.CreatedBy)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tif !u.Perms.CreateProfileReply {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\t// ? Does the admin understand that this group perm affects this?\n\tif u.ID != creator.ID && !u.Perms.EditReply {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\tprofileOwner, err := c.Users.Get(reply.ParentID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The profile you're trying to edit a post on doesn't exist.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tblocked, err := c.UserBlocks.IsBlockedBy(profileOwner.ID, u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\t// Supermods can bypass blocks so they can tell people off when they do something stupid or have to convey important information\n\tif (blocked || !c.PrivacyCommentsShow(profileOwner, u)) && !u.IsSuperMod {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\terr = reply.SetBody(r.PostFormValue(\"edit_item\"))\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\treturn actionSuccess(w, r, \"/user/\"+strconv.Itoa(creator.ID)+\"#reply-\"+strconv.Itoa(rid), js)\n}\n\nfunc ProfileReplyDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\trid, err := strconv.Atoi(srid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"The provided Reply ID is not a valid number.\", w, r, u, js)\n\t}\n\treply, err := c.Prstore.Get(rid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The target reply doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tcreator, err := c.Users.Get(reply.CreatedBy)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tif u.ID != creator.ID && !u.Perms.DeleteReply {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\terr = reply.Delete()\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\t//log.Printf(\"The profile post '%d' was deleted by c.User #%d\", reply.ID, u.ID)\n\n\tif !js {\n\t\t//http.Redirect(w,r, \"/user/\" + strconv.Itoa(creator.ID), http.StatusSeeOther)\n\t} else {\n\t\tw.Write(successJSONBytes)\n\t}\n\n\terr = c.ModLogs.Create(\"delete\", reply.ParentID, \"profile-reply\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "routes/reply.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/counters\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype ReplyStmts struct {\n\tcreateReplyPaging *sql.Stmt\n}\n\nvar replyStmts ReplyStmts\n\n// TODO: Move this statement somewhere else\nfunc init() {\n\tc.DbInits.Add(func(acc *qgen.Accumulator) error {\n\t\treplyStmts = ReplyStmts{\n\t\t\tcreateReplyPaging: acc.Select(\"replies\").Cols(\"rid\").Where(\"rid >= ? - 1 AND tid=?\").Orderby(\"rid ASC\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\ntype JsonReply struct {\n\tContent string\n}\n\nfunc CreateReplySubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t// TODO: Use this\n\tjs := r.FormValue(\"js\") == \"1\"\n\ttid, err := strconv.Atoi(r.PostFormValue(\"tid\"))\n\tif err != nil {\n\t\treturn c.PreErrorJSQ(\"Failed to convert the Topic ID\", w, r, js)\n\t}\n\ttopic, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"Couldn't find the parent topic\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, user, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.ViewTopic || !user.Perms.CreateReply {\n\t\treturn c.NoPermissionsJSQ(w, r, user, js)\n\t}\n\tif topic.IsClosed && !user.Perms.CloseTopic {\n\t\treturn c.NoPermissionsJSQ(w, r, user, js)\n\t}\n\n\tcontent := c.PreparseMessage(r.PostFormValue(\"content\"))\n\t// TODO: Fully parse the post and put that in the parsed column\n\trid, err := c.Rstore.Create(topic, content, user.GetIP(), user.ID)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\treply, err := c.Rstore.Get(rid)\n\tif err != nil {\n\t\treturn c.LocalErrorJSQ(\"Unable to load the reply\", w, r, user, js)\n\t}\n\n\t// Handle the file attachments\n\t// TODO: Stop duplicating this code\n\tif user.Perms.UploadFiles {\n\t\t_, rerr := uploadAttachment(w, r, user, topic.ParentID, \"forums\", rid, \"replies\", strconv.Itoa(topic.ID))\n\t\tif rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tif r.PostFormValue(\"has_poll\") == \"1\" {\n\t\tmaxPollOptions := 10\n\t\tpollInputItems := make(map[int]string)\n\t\tfor key, values := range r.Form {\n\t\t\t//c.DebugDetail(\"key: \", key)\n\t\t\t//c.DebugDetailf(\"values: %+v\\n\", values)\n\t\t\tif !strings.HasPrefix(key, \"pollinputitem[\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thalves := strings.Split(key, \"[\")\n\t\t\tif len(halves) != 2 {\n\t\t\t\treturn c.LocalErrorJSQ(\"Malformed pollinputitem\", w, r, user, js)\n\t\t\t}\n\t\t\thalves[1] = strings.TrimSuffix(halves[1], \"]\")\n\n\t\t\tindex, err := strconv.Atoi(halves[1])\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalErrorJSQ(\"Malformed pollinputitem\", w, r, user, js)\n\t\t\t}\n\t\t\tfor _, value := range values {\n\t\t\t\t// If there are duplicates, then something has gone horribly wrong, so let's ignore them, this'll likely happen during an attack\n\t\t\t\t_, exists := pollInputItems[index]\n\t\t\t\t// TODO: Should we use SanitiseBody instead to keep the newlines?\n\t\t\t\tif !exists && len(c.SanitiseSingleLine(value)) != 0 {\n\t\t\t\t\tpollInputItems[index] = c.SanitiseSingleLine(value)\n\t\t\t\t\tif len(pollInputItems) >= maxPollOptions {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Make sure the indices are sequential to avoid out of bounds issues\n\t\tseqPollInputItems := make(map[int]string)\n\t\tfor i := 0; i < len(pollInputItems); i++ {\n\t\t\tseqPollInputItems[i] = pollInputItems[i]\n\t\t}\n\n\t\tpollType := 0 // Basic single choice\n\t\t_, err := c.Polls.Create(reply, pollType, seqPollInputItems)\n\t\tif err != nil {\n\t\t\treturn c.LocalErrorJSQ(\"Failed to add poll to reply\", w, r, user, js) // TODO: Might need to be an internal error as it could leave phantom polls?\n\t\t}\n\t}\n\t_ = c.Rstore.GetCache().Remove(reply.ID)\n\n\terr = c.Forums.UpdateLastTopic(tid, user.ID, topic.ParentID)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tc.AddActivityAndNotifyAll(c.Alert{ActorID: user.ID, TargetUserID: topic.CreatedBy, Event: \"reply\", ElementType: \"topic\", ElementID: tid, Extra: strconv.Itoa(rid)})\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\terr = user.IncreasePostStats(c.WordCount(content), false)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tnTopic, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"Couldn't find the parent topic\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tpage := c.LastPage(nTopic.PostCount, c.Config.ItemsPerPage)\n\n\trows, err := replyStmts.createReplyPaging.Query(reply.ID, topic.ID)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tdefer rows.Close()\n\n\tvar rids []int\n\tfor rows.Next() {\n\t\tvar rid int\n\t\tif err := rows.Scan(&rid); err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\t\trids = append(rids, rid)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tif len(rids) == 0 {\n\t\treturn c.NotFoundJSQ(w, r, nil, js)\n\t}\n\n\tif page > 1 {\n\t\tvar offset int\n\t\tif rids[0] == reply.ID {\n\t\t\toffset = 1\n\t\t} else if len(rids) == 2 && rids[1] == reply.ID {\n\t\t\toffset = 2\n\t\t}\n\t\tpage = c.LastPage(nTopic.PostCount-(len(rids)+offset), c.Config.ItemsPerPage)\n\t}\n\n\tcounters.PostCounter.Bump()\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_create_reply\", reply.ID, user)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tprid, _ := strconv.Atoi(r.FormValue(\"prid\"))\n\tif js && (prid == 0 || rids[0] == prid) {\n\t\toutBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, \"forums\", user.ParseSettings, user)})\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\t\tw.Write(outBytes)\n\t} else {\n\t\tvar spage string\n\t\tif page > 1 {\n\t\t\tspage = \"?page=\" + strconv.Itoa(page)\n\t\t}\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(tid)+spage+\"#post-\"+strconv.Itoa(reply.ID), http.StatusSeeOther)\n\t}\n\treturn nil\n}\n\n// TODO: Disable stat updates in posts handled by plugin_guilds\n// TODO: Update the stats after edits so that we don't under or over decrement stats during deletes\nfunc ReplyEditSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\treply, topic, lite, ferr := ReplyActPre(w, r, u, srid, js)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditReply {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\tif topic.IsClosed && !u.Perms.CloseTopic {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\terr := reply.SetPost(r.PostFormValue(\"edit_item\"))\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The parent topic doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tif !c.Rstore.Exists(reply.ID) {\n\t\treturn c.PreErrorJSQ(\"The updated reply doesn't exist.\", w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_edit_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tif !js {\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(topic.ID)+\"#reply-\"+strconv.Itoa(reply.ID), http.StatusSeeOther)\n\t} else {\n\t\toutBytes, err := json.Marshal(JsonReply{c.ParseMessage(reply.Content, topic.ParentID, \"forums\", u.ParseSettings, u)})\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\t\tw.Write(outBytes)\n\t}\n\n\treturn nil\n}\n\n// TODO: Refactor this\n// TODO: Disable stat updates in posts handled by plugin_guilds\nfunc ReplyDeleteSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\treply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif reply.CreatedBy != u.ID {\n\t\tif !u.Perms.ViewTopic || !u.Perms.DeleteReply {\n\t\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t\t}\n\t}\n\tif e := reply.Delete(); e != nil {\n\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_delete_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\t//log.Printf(\"Reply #%d was deleted by c.User #%d\", rid, u.ID)\n\tif !js {\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(reply.ParentID), http.StatusSeeOther)\n\t} else {\n\t\tw.Write(successJSONBytes)\n\t}\n\n\t// ? - What happens if an error fires after a redirect...?\n\t/*creator, e := c.Users.Get(reply.CreatedBy)\n\tif e == nil {\n\t\te = creator.DecreasePostStats(c.WordCount(reply.Content), false)\n\t\tif e != nil {\n\t\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t\t}\n\t} else if e != sql.ErrNoRows {\n\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t}*/\n\n\te := c.ModLogs.Create(\"delete\", reply.ParentID, \"reply\", u.GetIP(), u.ID)\n\tif e != nil {\n\t\treturn c.InternalErrorJSQ(e, w, r, js)\n\t}\n\treturn nil\n}\n\n// TODO: Avoid uploading this again if the attachment already exists? They'll resolve to the same hash either way, but we could save on some IO / bandwidth here\n// TODO: Enforce the max request limit on all of this topic's attachments\n// TODO: Test this route\nfunc AddAttachToReplySubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\treply, topic, lite, ferr := ReplyActPre(w, r, u, srid, true)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditReply || !u.Perms.UploadFiles {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\tif topic.IsClosed && !u.Perms.CloseTopic {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\n\t// Handle the file attachments\n\tpathMap, rerr := uploadAttachment(w, r, u, topic.ParentID, \"forums\", reply.ID, \"replies\", strconv.Itoa(topic.ID))\n\tif rerr != nil {\n\t\t// TODO: This needs to be a JS error...\n\t\treturn rerr\n\t}\n\tif len(pathMap) == 0 {\n\t\treturn c.InternalErrorJS(errors.New(\"no paths for attachment add\"), w, r)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_add_attach_to_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tvar elemStr string\n\tfor path, aids := range pathMap {\n\t\telemStr += \"\\\"\" + path + \"\\\":\\\"\" + aids + \"\\\",\"\n\t}\n\tif len(elemStr) > 1 {\n\t\telemStr = elemStr[:len(elemStr)-1]\n\t}\n\n\tw.Write([]byte(`{\"success\":1,\"elems\":{` + elemStr + `}}`))\n\treturn nil\n}\n\n// TODO: Reduce the amount of duplication between this and RemoveAttachFromTopicSubmit\nfunc RemoveAttachFromReplySubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\treply, topic, lite, ferr := ReplyActPre(w, r, u, srid, true)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditReply {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\tif topic.IsClosed && !u.Perms.CloseTopic {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\n\tsaids := strings.Split(r.PostFormValue(\"aids\"), \",\")\n\tif len(saids) == 0 {\n\t\treturn c.LocalErrorJS(\"No aids provided\", w, r)\n\t}\n\tfor _, said := range saids {\n\t\taid, err := strconv.Atoi(said)\n\t\tif err != nil {\n\t\t\treturn c.LocalErrorJS(p.GetErrorPhrase(\"id_must_be_integer\"), w, r)\n\t\t}\n\t\trerr := deleteAttachment(w, r, u, aid, true)\n\t\tif rerr != nil {\n\t\t\t// TODO: This needs to be a JS error...\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_remove_attach_from_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tw.Write(successJSONBytes)\n\treturn nil\n}\n\nfunc ReplyActPre(w http.ResponseWriter, r *http.Request, u *c.User, srid string, js bool) (rep *c.Reply, t *c.Topic, l *c.HeaderLite, ferr c.RouteError) {\n\trid, err := strconv.Atoi(srid)\n\tif err != nil {\n\t\treturn rep, t, l, c.PreErrorJSQ(\"The provided Reply ID is not a valid number.\", w, r, js)\n\t}\n\trep, err = c.Rstore.Get(rid)\n\tif err == sql.ErrNoRows {\n\t\treturn rep, t, l, c.PreErrorJSQ(\"The linked reply doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn rep, t, l, c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tt, err = rep.Topic()\n\tif err == sql.ErrNoRows {\n\t\treturn rep, t, l, c.PreErrorJSQ(\"The parent topic doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn rep, t, l, c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tl, ferr = c.SimpleForumUserCheck(w, r, u, t.ParentID)\n\treturn rep, t, l, ferr\n}\n\nfunc ReplyLikeSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\treply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.LikeItem {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\tif reply.CreatedBy == u.ID {\n\t\treturn c.LocalErrorJSQ(\"You can't like your own replies\", w, r, u, js)\n\t}\n\n\t_, err := c.Users.Get(reply.CreatedBy)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"The target user doesn't exist\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\terr = reply.Like(u.ID)\n\tif err == c.ErrAlreadyLiked {\n\t\treturn c.LocalErrorJSQ(\"You've already liked this!\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// ! Be careful about leaking per-route permission state with user ptr\n\talert := c.Alert{ActorID: u.ID, TargetUserID: reply.CreatedBy, Event: \"like\", ElementType: \"post\", ElementID: reply.ID, Actor: u}\n\terr = c.AddActivityAndNotifyTarget(alert)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_like_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn actionSuccess(w, r, \"/topic/\"+strconv.Itoa(reply.ParentID), js)\n}\n\nfunc ReplyUnlikeSubmit(w http.ResponseWriter, r *http.Request, u *c.User, srid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\treply, _, lite, ferr := ReplyActPre(w, r, u, srid, js)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.LikeItem {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\t_, err := c.Users.Get(reply.CreatedBy)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"The target user doesn't exist\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = reply.Unlike(u.ID)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Better coupling between the two params queries\n\taids, err := c.Activity.AidsByParams(\"like\", reply.ID, \"post\")\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tfor _, aid := range aids {\n\t\tc.DismissAlert(reply.CreatedBy, aid)\n\t}\n\terr = c.Activity.DeleteByParams(\"like\", reply.ID, \"post\")\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_unlike_reply\", reply.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn actionSuccess(w, r, \"/topic/\"+strconv.Itoa(reply.ParentID), js)\n}\n"
  },
  {
    "path": "routes/reports.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/counters\"\n)\n\nfunc ReportSubmit(w http.ResponseWriter, r *http.Request, user *c.User, sItemID string) c.RouteError {\n\theaderLite, ferr := c.SimpleUserCheck(w, r, user)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\n\titemID, err := strconv.Atoi(sItemID)\n\tif err != nil {\n\t\treturn c.LocalError(\"Bad ID\", w, r, user)\n\t}\n\titemType := r.FormValue(\"type\")\n\n\t// TODO: Localise these titles and bodies\n\tvar title, content string\n\tswitch itemType {\n\tcase \"reply\":\n\t\treply, err := c.Rstore.Get(itemID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"We were unable to find the reported post\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\ttopic, err := c.Topics.Get(reply.ParentID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"We weren't able to find the topic the reported post is supposed to be in\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\ttitle = \"Reply: \" + topic.Title\n\t\tcontent = reply.Content + \"\\n\\nOriginal Post: #rid-\" + strconv.Itoa(itemID)\n\tcase \"user-reply\":\n\t\tuserReply, err := c.Prstore.Get(itemID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"We weren't able to find the reported post\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\tprofileOwner, err := c.Users.Get(userReply.ParentID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"We weren't able to find the profile the reported post is supposed to be on\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\ttitle = \"Profile: \" + profileOwner.Name\n\t\tcontent = userReply.Content + \"\\n\\nOriginal Post: @\" + strconv.Itoa(userReply.ParentID)\n\tcase \"topic\":\n\t\ttopic, err := c.Topics.Get(itemID)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.NotFound(w, r, nil)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\ttitle = \"Topic: \" + topic.Title\n\t\tcontent = topic.Content + \"\\n\\nOriginal Post: #tid-\" + strconv.Itoa(itemID)\n\tcase \"convo-reply\":\n\t\tpost := &c.ConversationPost{ID: itemID}\n\t\terr := post.Fetch()\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.NotFound(w, r, nil)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\tpost, err = c.ConvoPostProcess.OnLoad(post)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tuser, err := c.Users.Get(post.CreatedBy)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\n\t\ttitle = \"Convo: \" + user.Name\n\t\tcontent = post.Body + \"\\n\\nOriginal Post: #cpid-\" + strconv.Itoa(itemID)\n\tdefault:\n\t\t_, hasHook := headerLite.Hooks.VhookNeedHook(\"report_preassign\", &itemID, &itemType)\n\t\tif hasHook {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Don't try to guess the type\n\t\treturn c.LocalError(\"Unknown type\", w, r, user)\n\t}\n\n\t// TODO: Repost attachments in the reports forum, so that the mods can see them\n\t_, err = c.Reports.Create(title, content, user, itemType, itemID)\n\tif err == c.ErrAlreadyReported {\n\t\treturn c.LocalError(\"Someone has already reported this!\", w, r, user)\n\t}\n\tcounters.PostCounter.Bump()\n\t// TODO: Redirect back to where we came from\n\treturn actionSuccess(w, r, \"/\", js)\n}\n"
  },
  {
    "path": "routes/stubs.go",
    "content": "package routes\n\n// Temporary stubs for view tracking\nfunc DynamicRoute() {\n}\nfunc UploadedFile() {\n}\nfunc BadRoute() {\n}\nfunc Favicon() {\n}\n\n// TODO: Temporary stub for handling route group errors\nfunc Error() {\n}\n\n// Real implementation is in router_gen/main.go, this is just a stub to map the analytics onto\nfunc HTTPSRedirect() {\n}\n"
  },
  {
    "path": "routes/topic.go",
    "content": "package routes\n\nimport (\n\t\"crypto/sha256\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\n\t\"image\"\n\t\"image/gif\"\n\t\"image/jpeg\"\n\t\"image/png\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"golang.org/x/image/tiff\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\tco \"github.com/Azareal/Gosora/common/counters\"\n\tp \"github.com/Azareal/Gosora/common/phrases\"\n\tqgen \"github.com/Azareal/Gosora/query_gen\"\n)\n\ntype TopicStmts struct {\n\tgetLikedTopic *sql.Stmt\n}\n\nvar topicStmts TopicStmts\n\n// TODO: Move these DbInits into a TopicList abstraction\nfunc init() {\n\tc.DbInits.Add(func(acc *qgen.Accumulator) error {\n\t\ttopicStmts = TopicStmts{\n\t\t\tgetLikedTopic: acc.Select(\"likes\").Columns(\"targetItem\").Where(\"sentBy=? && targetItem=? && targetType='topics'\").Prepare(),\n\t\t}\n\t\treturn acc.FirstError()\n\t})\n}\n\nfunc ViewTopic(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header, urlBit string) c.RouteError {\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\t_, tid, err := ParseSEOURL(urlBit)\n\tif err != nil {\n\t\treturn c.SimpleError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, h)\n\t}\n\n\t// Get the topic...\n\ttopic, err := c.GetTopicUser(user, tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.NotFound(w, r, nil) // TODO: Can we add a simplified invocation of header here? This is likely to be an extremely common NotFound\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tferr := c.ForumUserCheck(h, w, r, user, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !user.Perms.ViewTopic {\n\t\treturn c.NoPermissions(w, r, user)\n\t}\n\th.Title = topic.Title\n\th.Path = topic.Link\n\t//h.Path = c.BuildTopicURL(c.NameToSlug(topic.Title), topic.ID)\n\n\tpostGroup, err := c.Groups.Get(topic.Group)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\ttopic.ContentLines = strings.Count(topic.Content, \"\\n\")\n\tif !user.Loggedin && user.LastAgent != c.SimpleBots[0] && user.LastAgent != c.SimpleBots[1] {\n\t\tif len(topic.Content) > 200 {\n\t\t\th.OGDesc = topic.Content[:197] + \"...\"\n\t\t} else {\n\t\t\th.OGDesc = topic.Content\n\t\t}\n\t\th.OGDesc = c.H_topic_ogdesc_assign_hook(h.Hooks, h.OGDesc)\n\t}\n\n\tvar parseSettings *c.ParseSettings\n\tif (c.Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {\n\t\tparseSettings = c.DefaultParseSettings.CopyPtr()\n\t\tparseSettings.NoEmbed = true\n\t} else {\n\t\tparseSettings = user.ParseSettings\n\t}\n\n\t// TODO: Cache ContentHTML when possible?\n\ttopic.ContentHTML, h.ExternalMedia = c.ParseMessage2(topic.Content, topic.ParentID, \"forums\", parseSettings, user)\n\t// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.\n\tif topic.ContentHTML == topic.Content {\n\t\ttopic.ContentHTML = topic.Content\n\t}\n\n\ttopic.Tag = postGroup.Tag\n\tif postGroup.IsMod {\n\t\ttopic.ClassName = c.Config.StaffCSS\n\t}\n\ttopic.Deletable = user.Perms.DeleteTopic || topic.CreatedBy == user.ID\n\n\tforum, err := c.Forums.Get(topic.ParentID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tvar poll *c.Poll\n\tif topic.Poll != 0 {\n\t\tpPoll, err := c.Polls.Get(topic.Poll)\n\t\tif err != nil {\n\t\t\tlog.Print(\"Couldn't find the attached poll for topic \" + strconv.Itoa(topic.ID))\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tpoll = new(c.Poll)\n\t\t*poll = pPoll.Copy()\n\t}\n\n\tif topic.LikeCount > 0 && user.Liked > 0 {\n\t\tvar disp int // Discard this value\n\t\terr = topicStmts.getLikedTopic.QueryRow(user.ID, topic.ID).Scan(&disp)\n\t\tif err == nil {\n\t\t\ttopic.Liked = true\n\t\t} else if err != nil && err != sql.ErrNoRows {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\n\tif topic.AttachCount > 0 {\n\t\tattachs, err := c.Attachments.MiniGetList(\"topics\", topic.ID)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\t// TODO: We might want to be a little permissive here in-case of a desync?\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\ttopic.Attachments = attachs\n\t}\n\n\t// Calculate the offset\n\toffset, page, lastPage := c.PageOffset(topic.PostCount, page, c.Config.ItemsPerPage)\n\tpageList := c.Paginate(page, lastPage, 5)\n\ttpage := c.TopicPage{h, nil, topic, forum, poll, c.Paginator{pageList, page, lastPage}}\n\n\t// Get the replies if we have any...\n\tif topic.PostCount > 0 {\n\t\t/*var pFrag int\n\t\tif strings.HasPrefix(r.URL.Fragment, \"post-\") {\n\t\t\tpFrag, _ = strconv.Atoi(strings.TrimPrefix(r.URL.Fragment, \"post-\"))\n\t\t}*/\n\t\trlist, externalHead, err := topic.Replies(offset /* pFrag,*/, user)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"Bad Page. Some of the posts may have been deleted or you got here by directly typing in the page number.\", w, r, user)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t//fmt.Printf(\"rlist: %+v\\n\",rlist)\n\t\ttpage.ItemList = rlist\n\t\tif externalHead {\n\t\t\th.ExternalMedia = true\n\t\t}\n\t}\n\n\th.Zone = \"view_topic\"\n\th.ZoneID = topic.ID\n\th.ZoneData = topic\n\n\tvar rerr c.RouteError\n\ttmpl := forum.Tmpl\n\tif r.FormValue(\"i\") == \"1\" {\n\t\tif tpage.Poll != nil {\n\t\t\th.AddXRes(\"chartist/chartist.min.css\", \"chartist/chartist.min.js\")\n\t\t}\n\t\tif tmpl == \"\" {\n\t\t\trerr = renderTemplate(\"topic_mini\", w, r, h, tpage)\n\t\t} else {\n\t\t\ttmpl = \"topic_mini\" + tmpl\n\t\t\terr = renderTemplate3(tmpl, tmpl, w, r, h, tpage)\n\t\t\tif err != nil {\n\t\t\t\trerr = renderTemplate(\"topic_mini\", w, r, h, tpage)\n\t\t\t}\n\t\t}\n\t} else {\n\t\tif tpage.Poll != nil {\n\t\t\th.AddSheet(\"chartist/chartist.min.css\")\n\t\t\th.AddScript(\"chartist/chartist.min.js\")\n\t\t}\n\t\tif tmpl == \"\" {\n\t\t\trerr = renderTemplate(\"topic\", w, r, h, tpage)\n\t\t} else {\n\t\t\ttmpl = \"topic_\" + tmpl\n\t\t\terr = renderTemplate3(tmpl, tmpl, w, r, h, tpage)\n\t\t\tif err != nil {\n\t\t\t\trerr = renderTemplate(\"topic\", w, r, h, tpage)\n\t\t\t}\n\t\t}\n\t}\n\tco.TopicViewCounter.Bump(topic.ID) // TODO: Move this into the router?\n\tco.ForumViewCounter.Bump(topic.ParentID)\n\treturn rerr\n}\n\nfunc AttachTopicActCommon(w http.ResponseWriter, r *http.Request, u *c.User, stid string) (t *c.Topic, ferr c.RouteError) {\n\ttid, e := strconv.Atoi(stid)\n\tif e != nil {\n\t\treturn t, c.LocalErrorJS(p.GetErrorPhrase(\"id_must_be_integer\"), w, r)\n\t}\n\tt, e = c.Topics.Get(tid)\n\tif e != nil {\n\t\treturn t, c.NotFoundJS(w, r)\n\t}\n\t_, ferr = c.SimpleForumUserCheck(w, r, u, t.ParentID)\n\tif ferr != nil {\n\t\treturn t, ferr\n\t}\n\tif t.IsClosed && !u.Perms.CloseTopic {\n\t\treturn t, c.NoPermissionsJS(w, r, u)\n\t}\n\treturn t, nil\n}\n\n// TODO: Avoid uploading this again if the attachment already exists? They'll resolve to the same hash either way, but we could save on some IO / bandwidth here\n// TODO: Enforce the max request limit on all of this topic's attachments\n// TODO: Test this route\nfunc AddAttachToTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\ttopic, ferr := AttachTopicActCommon(w, r, u, stid)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditTopic || !u.Perms.UploadFiles {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\n\t// Handle the file attachments\n\tpathMap, rerr := uploadAttachment(w, r, u, topic.ParentID, \"forums\", topic.ID, \"topics\", \"\")\n\tif rerr != nil {\n\t\t// TODO: This needs to be a JS error...\n\t\treturn rerr\n\t}\n\tif len(pathMap) == 0 {\n\t\treturn c.InternalErrorJS(errors.New(\"no paths for attachment add\"), w, r)\n\t}\n\n\tvar elemStr string\n\tfor path, aids := range pathMap {\n\t\telemStr += \"\\\"\" + path + \"\\\":\\\"\" + aids + \"\\\",\"\n\t}\n\tif len(elemStr) > 1 {\n\t\telemStr = elemStr[:len(elemStr)-1]\n\t}\n\n\tw.Write([]byte(`{\"success\":1,\"elems\":[{` + elemStr + `}]}`))\n\treturn nil\n}\n\nfunc RemoveAttachFromTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\t_, ferr := AttachTopicActCommon(w, r, u, stid)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditTopic {\n\t\treturn c.NoPermissionsJS(w, r, u)\n\t}\n\n\tfor _, said := range strings.Split(r.PostFormValue(\"aids\"), \",\") {\n\t\taid, err := strconv.Atoi(said)\n\t\tif err != nil {\n\t\t\treturn c.LocalErrorJS(p.GetErrorPhrase(\"id_must_be_integer\"), w, r)\n\t\t}\n\t\trerr := deleteAttachment(w, r, u, aid, true)\n\t\tif rerr != nil {\n\t\t\t// TODO: This needs to be a JS error...\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tw.Write(successJSONBytes)\n\treturn nil\n}\n\n// ? - Should we add a new permission or permission zone (like per-forum permissions) specifically for profile comment creation\n// ? - Should we allow banned users to make reports? How should we handle report abuse?\n// TODO: Add a permission to stop certain users from using custom avatars\n// ? - Log username changes and put restrictions on this?\n// TODO: Test this\n// TODO: Revamp this route\nfunc CreateTopic(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, sfid string) c.RouteError {\n\tvar fid int\n\tvar err error\n\tif sfid != \"\" {\n\t\tfid, err = strconv.Atoi(sfid)\n\t\tif err != nil {\n\t\t\treturn c.LocalError(p.GetErrorPhrase(\"url_id_must_be_integer\"), w, r, u)\n\t\t}\n\t}\n\tif fid == 0 {\n\t\tfid = c.Config.DefaultForum\n\t}\n\n\tferr := c.ForumUserCheck(h, w, r, u, fid)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.CreateTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\t// TODO: Add a phrase for this\n\th.Title = p.GetTitlePhrase(\"create_topic\")\n\th.Zone = \"create_topic\"\n\n\t// Lock this to the forum being linked?\n\t// Should we always put it in strictmode when it's linked from another forum? Well, the user might end up changing their mind on what forum they want to post in and it would be a hassle, if they had to switch pages, even if it is a single click for many (exc. mobile)\n\tvar strict bool\n\th.Hooks.VhookNoRet(\"topic_create_pre_loop\", w, r, fid, h, u, &strict)\n\n\t// TODO: Re-add support for plugin_guilds\n\tvar forumList []c.Forum\n\tvar canSee []int\n\tif u.IsSuperAdmin {\n\t\tcanSee, err = c.Forums.GetAllVisibleIDs()\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t} else {\n\t\tgroup, err := c.Groups.Get(u.Group)\n\t\tif err != nil {\n\t\t\t// TODO: Refactor this\n\t\t\tc.LocalError(\"Something weird happened behind the scenes\", w, r, u)\n\t\t\tlog.Printf(\"Group #%d doesn't exist, but it's set on c.User #%d\", u.Group, u.ID)\n\t\t\treturn nil\n\t\t}\n\t\tcanSee = group.CanSee\n\t}\n\n\t// TODO: plugin_superadmin needs to be able to override this loop. Skip flag on topic_create_pre_loop?\n\tfor _, ffid := range canSee {\n\t\t// TODO: Surely, there's a better way of doing this. I've added it in for now to support plugin_guilds, but we really need to clean this up\n\t\tif strict && ffid != fid {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Do a bulk forum fetch, just in case it's the SqlForumStore?\n\t\tf := c.Forums.DirtyGet(ffid)\n\t\tif f.Name != \"\" && f.Active {\n\t\t\tfcopy := f.Copy()\n\t\t\t// TODO: Abstract this\n\t\t\t//if h.Hooks.HookSkip(\"topic_create_frow_assign\", &fcopy) {\n\t\t\tif c.H_topic_create_frow_assign_hook(h.Hooks, &fcopy) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tforumList = append(forumList, fcopy)\n\t\t}\n\t}\n\n\treturn renderTemplate(\"create_topic\", w, r, h, c.CreateTopicPage{h, forumList, fid})\n}\n\nfunc CreateTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\tfid, err := strconv.Atoi(r.PostFormValue(\"board\"))\n\tif err != nil {\n\t\treturn c.LocalError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, u)\n\t}\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, u, fid)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.CreateTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\n\tname := c.SanitiseSingleLine(r.PostFormValue(\"name\"))\n\tcontent := c.PreparseMessage(r.PostFormValue(\"content\"))\n\t// TODO: Fully parse the post and store it in the parsed column\n\ttid, err := c.Topics.Create(fid, name, content, u.ID, u.GetIP())\n\tif err != nil {\n\t\tswitch err {\n\t\tcase c.ErrNoRows:\n\t\t\treturn c.LocalError(\"Something went wrong, perhaps the forum got deleted?\", w, r, u)\n\t\tcase c.ErrNoTitle:\n\t\t\treturn c.LocalError(\"This topic doesn't have a title\", w, r, u)\n\t\tcase c.ErrLongTitle:\n\t\t\treturn c.LocalError(\"The length of the title is too long, max: \"+strconv.Itoa(c.Config.MaxTopicTitleLength), w, r, u)\n\t\tcase c.ErrNoBody:\n\t\t\treturn c.LocalError(\"This topic doesn't have a body\", w, r, u)\n\t\t}\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\ttopic, err := c.Topics.Get(tid)\n\tif err != nil {\n\t\treturn c.LocalError(\"Unable to load the topic\", w, r, u)\n\t}\n\tif r.PostFormValue(\"has_poll\") == \"1\" {\n\t\tmaxPollOptions := 10\n\t\tpollInputItems := make(map[int]string)\n\t\tfor key, values := range r.Form {\n\t\t\tif !strings.HasPrefix(key, \"pollinputitem[\") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\thalves := strings.Split(key, \"[\")\n\t\t\tif len(halves) != 2 {\n\t\t\t\treturn c.LocalError(\"Malformed pollinputitem\", w, r, u)\n\t\t\t}\n\t\t\thalves[1] = strings.TrimSuffix(halves[1], \"]\")\n\n\t\t\tindex, err := strconv.Atoi(halves[1])\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalError(\"Malformed pollinputitem\", w, r, u)\n\t\t\t}\n\t\t\tfor _, value := range values {\n\t\t\t\t// If there are duplicates, then something has gone horribly wrong, so let's ignore them, this'll likely happen during an attack\n\t\t\t\t_, exists := pollInputItems[index]\n\t\t\t\t// TODO: Should we use SanitiseBody instead to keep the newlines?\n\t\t\t\tif !exists && len(c.SanitiseSingleLine(value)) != 0 {\n\t\t\t\t\tpollInputItems[index] = c.SanitiseSingleLine(value)\n\t\t\t\t\tif len(pollInputItems) >= maxPollOptions {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif len(pollInputItems) > 0 {\n\t\t\t// Make sure the indices are sequential to avoid out of bounds issues\n\t\t\tseqPollInputItems := make(map[int]string)\n\t\t\tfor i := 0; i < len(pollInputItems); i++ {\n\t\t\t\tseqPollInputItems[i] = pollInputItems[i]\n\t\t\t}\n\n\t\t\tpollType := 0 // Basic single choice\n\t\t\t_, err := c.Polls.Create(topic, pollType, seqPollInputItems)\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalError(\"Failed to add poll to topic\", w, r, u) // TODO: Might need to be an internal error as it could leave phantom polls?\n\t\t\t}\n\t\t}\n\t}\n\n\terr = c.Subscriptions.Add(u.ID, tid, \"topic\")\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = u.IncreasePostStats(c.WordCount(content), true)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// Handle the file attachments\n\tif u.Perms.UploadFiles {\n\t\t_, rerr := uploadAttachment(w, r, u, fid, \"forums\", tid, \"topics\", \"\")\n\t\tif rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tco.PostCounter.Bump()\n\tco.TopicCounter.Bump()\n\t// TODO: Pass more data to this hook?\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_create_topic\", tid, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(tid), http.StatusSeeOther)\n\treturn nil\n}\n\n// TODO: Move this function\nfunc uploadFilesWithHash(w http.ResponseWriter, r *http.Request, u *c.User, dir string) (filenames []string, rerr c.RouteError) {\n\tfiles, ok := r.MultipartForm.File[\"upload_files\"]\n\tif !ok {\n\t\treturn nil, nil\n\t}\n\tif len(files) > 5 {\n\t\treturn nil, c.LocalError(\"You can't attach more than five files\", w, r, u)\n\t}\n\tdisableEncode := r.PostFormValue(\"ko\") == \"1\"\n\n\tfor _, file := range files {\n\t\tif file.Filename == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t//c.DebugLog(\"file.Filename \", file.Filename)\n\n\t\textarr := strings.Split(file.Filename, \".\")\n\t\tif len(extarr) < 2 {\n\t\t\treturn nil, c.LocalError(\"Bad file\", w, r, u)\n\t\t}\n\t\text := extarr[len(extarr)-1]\n\n\t\t// TODO: Can we do this without a regex?\n\t\treg, err := regexp.Compile(\"[^A-Za-z0-9]+\")\n\t\tif err != nil {\n\t\t\treturn nil, c.LocalError(\"Bad file extension\", w, r, u)\n\t\t}\n\t\text = strings.ToLower(reg.ReplaceAllString(ext, \"\"))\n\t\tif !c.AllowedFileExts.Contains(ext) {\n\t\t\treturn nil, c.LocalError(\"You're not allowed to upload files with this extension\", w, r, u)\n\t\t}\n\n\t\tinFile, err := file.Open()\n\t\tif err != nil {\n\t\t\treturn nil, c.LocalError(\"Upload failed\", w, r, u)\n\t\t}\n\t\tdefer inFile.Close()\n\n\t\thasher := sha256.New()\n\t\t_, err = io.Copy(hasher, inFile)\n\t\tif err != nil {\n\t\t\treturn nil, c.LocalError(\"Upload failed [Hashing Failed]\", w, r, u)\n\t\t}\n\t\tinFile.Close()\n\n\t\tchecksum := hex.EncodeToString(hasher.Sum(nil))\n\t\tfilename := checksum + \".\" + ext\n\n\t\tinFile, err = file.Open()\n\t\tif err != nil {\n\t\t\treturn nil, c.LocalError(\"Upload failed\", w, r, u)\n\t\t}\n\t\tdefer inFile.Close()\n\n\t\toutFile, err := os.Create(dir + filename)\n\t\tif err != nil {\n\t\t\treturn nil, c.LocalError(\"Upload failed [File Creation Failed]\", w, r, u)\n\t\t}\n\t\tdefer outFile.Close()\n\n\t\tif disableEncode || (ext != \"jpg\" && ext != \"jpeg\" && ext != \"png\" && ext != \"gif\" && ext != \"tiff\" && ext != \"tif\") {\n\t\t\t_, err = io.Copy(outFile, inFile)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, c.LocalError(\"Upload failed [Copy Failed]\", w, r, u)\n\t\t\t}\n\t\t} else {\n\t\t\timg, _, err := image.Decode(inFile)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, c.LocalError(\"Upload failed [Image Decoding Failed]\", w, r, u)\n\t\t\t}\n\n\t\t\tswitch ext {\n\t\t\tcase \"gif\":\n\t\t\t\terr = gif.Encode(outFile, img, nil)\n\t\t\tcase \"png\":\n\t\t\t\terr = png.Encode(outFile, img)\n\t\t\tcase \"tiff\", \"tif\":\n\t\t\t\terr = tiff.Encode(outFile, img, nil)\n\t\t\tdefault:\n\t\t\t\terr = jpeg.Encode(outFile, img, nil)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn nil, c.LocalError(\"Upload failed [Image Encoding Failed]\", w, r, u)\n\t\t\t}\n\t\t}\n\n\t\tfilenames = append(filenames, filename)\n\t}\n\n\treturn filenames, nil\n}\n\n// TODO: Update the stats after edits so that we don't under or over decrement stats during deletes\n// TODO: Disable stat updates in posts handled by plugin_guilds\nfunc EditTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\tjs := (r.PostFormValue(\"js\") == \"1\")\n\ttid, err := strconv.Atoi(stid)\n\tif err != nil {\n\t\treturn c.PreErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, js)\n\t}\n\ttopic, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The topic you tried to edit doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.EditTopic {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\tif topic.IsClosed && !u.Perms.CloseTopic {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\terr = topic.Update(r.PostFormValue(\"name\"), r.PostFormValue(\"content\"))\n\t// TODO: Avoid duplicating this across this route and the topic creation route\n\tif err != nil {\n\t\tswitch err {\n\t\tcase c.ErrNoTitle:\n\t\t\treturn c.LocalErrorJSQ(\"This topic doesn't have a title\", w, r, u, js)\n\t\tcase c.ErrLongTitle:\n\t\t\treturn c.LocalErrorJSQ(\"The length of the title is too long, max: \"+strconv.Itoa(c.Config.MaxTopicTitleLength), w, r, u, js)\n\t\tcase c.ErrNoBody:\n\t\t\treturn c.LocalErrorJSQ(\"This topic doesn't have a body\", w, r, u, js)\n\t\t}\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\terr = c.Forums.UpdateLastTopic(topic.ID, u.ID, topic.ParentID)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Avoid the load to get this faster?\n\ttopic, err = c.Topics.Get(topic.ID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The updated topic doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_edit_topic\", topic.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\tif !js {\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(tid), http.StatusSeeOther)\n\t} else {\n\t\toutBytes, err := json.Marshal(JsonReply{c.ParseMessage(topic.Content, topic.ParentID, \"forums\", u.ParseSettings, u)})\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\t\tw.Write(outBytes)\n\t}\n\treturn nil\n}\n\n// TODO: Add support for soft-deletion and add a permission for hard delete in addition to the usual\n// TODO: Disable stat updates in posts handled by plugin_guilds\nfunc DeleteTopicSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t// TODO: Move this to some sort of middleware\n\tvar tids []int\n\tjs := c.ReqIsJson(r)\n\tif js {\n\t\tif r.Body == nil {\n\t\t\treturn c.PreErrorJS(\"No request body\", w, r)\n\t\t}\n\t\terr := json.NewDecoder(r.Body).Decode(&tids)\n\t\tif err != nil {\n\t\t\treturn c.PreErrorJS(\"We weren't able to parse your data\", w, r)\n\t\t}\n\t} else {\n\t\ttid, err := strconv.Atoi(r.URL.Path[len(\"/topic/delete/submit/\"):])\n\t\tif err != nil {\n\t\t\treturn c.PreError(\"The provided TopicID is not a valid number.\", w, r)\n\t\t}\n\t\ttids = []int{tid}\n\t}\n\tif len(tids) == 0 {\n\t\treturn c.LocalErrorJSQ(\"You haven't provided any IDs\", w, r, user, js)\n\t}\n\n\tfor _, tid := range tids {\n\t\ttopic, err := c.Topics.Get(tid)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.PreErrorJSQ(\"The topic you tried to delete doesn't exist.\", w, r, js)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\n\t\t// TODO: Add hooks to make use of headerLite\n\t\tlite, ferr := c.SimpleForumUserCheck(w, r, user, topic.ParentID)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\tif topic.CreatedBy != user.ID {\n\t\t\tif !user.Perms.ViewTopic || !user.Perms.DeleteTopic {\n\t\t\t\treturn c.NoPermissionsJSQ(w, r, user, js)\n\t\t\t}\n\t\t}\n\n\t\t// We might be able to handle this err better\n\t\terr = topic.Delete()\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\t\terr = c.ModLogs.Create(\"delete\", tid, \"topic\", user.GetIP(), user.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\n\t\t// ? - We might need to add soft-delete before we can do an action reply for this\n\t\t/*_, err = stmts.createActionReply.Exec(tid,\"delete\",ip,user.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err,w,r,js)\n\t\t}*/\n\n\t\t// TODO: Do a bulk delete action hook?\n\t\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_delete_topic\", topic.ID, user)\n\t\tif skip || rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\n\t\tlog.Printf(\"Topic #%d was deleted by UserID #%d\", tid, user.ID)\n\t}\n\thttp.Redirect(w, r, \"/\", http.StatusSeeOther)\n\treturn nil\n}\n\nfunc StickTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\ttopic, lite, rerr := topicActionPre(stid, \"pin\", w, r, u)\n\tif rerr != nil {\n\t\treturn rerr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.PinTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\treturn topicActionPost(topic.Stick(), \"stick\", w, r, lite, topic, u)\n}\n\n//\n//\n// mark\n//\n//\nfunc topicActionPre(stid, action string, w http.ResponseWriter, r *http.Request, u *c.User) (*c.Topic, *c.HeaderLite, c.RouteError) {\n\ttid, err := strconv.Atoi(stid)\n\tif err != nil {\n\t\treturn nil, nil, c.PreError(p.GetErrorPhrase(\"id_must_be_integer\"), w, r)\n\t}\n\tt, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn nil, nil, c.PreError(\"The topic you tried to \"+action+\" doesn't exist.\", w, r)\n\t} else if err != nil {\n\t\treturn nil, nil, c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, u, t.ParentID)\n\tif ferr != nil {\n\t\treturn nil, nil, ferr\n\t}\n\treturn t, lite, nil\n}\n\nfunc topicActionPost(err error, action string, w http.ResponseWriter, r *http.Request, lite *c.HeaderLite, topic *c.Topic, u *c.User) c.RouteError {\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = addTopicAction(action, topic, u)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_\"+action+\"_topic\", topic.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(topic.ID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc UnstickTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\tt, lite, rerr := topicActionPre(stid, \"unpin\", w, r, u)\n\tif rerr != nil {\n\t\treturn rerr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.PinTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\treturn topicActionPost(t.Unstick(), \"unstick\", w, r, lite, t, u)\n}\n\nfunc LockTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {\n\t// TODO: Move this to some sort of middleware\n\tvar tids []int\n\tjs := c.ReqIsJson(r)\n\tif js {\n\t\tif r.Body == nil {\n\t\t\treturn c.PreErrorJS(\"No request body\", w, r)\n\t\t}\n\t\terr := json.NewDecoder(r.Body).Decode(&tids)\n\t\tif err != nil {\n\t\t\treturn c.PreErrorJS(\"We weren't able to parse your data\", w, r)\n\t\t}\n\t} else {\n\t\ttid, err := strconv.Atoi(r.URL.Path[len(\"/topic/lock/submit/\"):])\n\t\tif err != nil {\n\t\t\treturn c.PreError(\"The provided TopicID is not a valid number.\", w, r)\n\t\t}\n\t\ttids = append(tids, tid)\n\t}\n\tif len(tids) == 0 {\n\t\treturn c.LocalErrorJSQ(\"You haven't provided any IDs\", w, r, u, js)\n\t}\n\n\tfor _, tid := range tids {\n\t\ttopic, err := c.Topics.Get(tid)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.PreErrorJSQ(\"The topic you tried to lock doesn't exist.\", w, r, js)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\n\t\t// TODO: Add hooks to make use of headerLite\n\t\tlite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\tif !u.Perms.ViewTopic || !u.Perms.CloseTopic {\n\t\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t\t}\n\n\t\terr = topic.Lock()\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\n\t\terr = addTopicAction(\"lock\", topic, u)\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t\t}\n\n\t\t// TODO: Do a bulk lock action hook?\n\t\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_lock_topic\", topic.ID, u)\n\t\tif skip || rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tif len(tids) == 1 {\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(tids[0]), http.StatusSeeOther)\n\t}\n\treturn nil\n}\n\nfunc UnlockTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\tt, lite, rerr := topicActionPre(stid, \"unlock\", w, r, u)\n\tif rerr != nil {\n\t\treturn rerr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.CloseTopic {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\treturn topicActionPost(t.Unlock(), \"unlock\", w, r, lite, t, u)\n}\n\n// ! JS only route\n// TODO: Figure a way to get this route to work without JS\nfunc MoveTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {\n\tfid, err := strconv.Atoi(sfid)\n\tif err != nil {\n\t\treturn c.PreErrorJS(p.GetErrorPhrase(\"id_must_be_integer\"), w, r)\n\t}\n\t// TODO: Move this to some sort of middleware\n\tvar tids []int\n\tif r.Body == nil {\n\t\treturn c.PreErrorJS(\"No request body\", w, r)\n\t}\n\terr = json.NewDecoder(r.Body).Decode(&tids)\n\tif err != nil {\n\t\treturn c.PreErrorJS(\"We weren't able to parse your data\", w, r)\n\t}\n\tif len(tids) == 0 {\n\t\treturn c.LocalErrorJS(\"You haven't provided any IDs\", w, r)\n\t}\n\n\tfor _, tid := range tids {\n\t\ttopic, err := c.Topics.Get(tid)\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.PreErrorJS(\"The topic you tried to move doesn't exist.\", w, r)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t}\n\n\t\t// TODO: Add hooks to make use of headerLite\n\t\t_, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\tif !u.Perms.ViewTopic || !u.Perms.MoveTopic {\n\t\t\treturn c.NoPermissionsJS(w, r, u)\n\t\t}\n\t\tlite, ferr := c.SimpleForumUserCheck(w, r, u, fid)\n\t\tif ferr != nil {\n\t\t\treturn ferr\n\t\t}\n\t\tif !u.Perms.ViewTopic || !u.Perms.MoveTopic {\n\t\t\treturn c.NoPermissionsJS(w, r, u)\n\t\t}\n\n\t\terr = topic.MoveTo(fid)\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t}\n\t\t// ? - Is there a better way of doing this?\n\t\terr = addTopicAction(\"move-\"+strconv.Itoa(fid), topic, u)\n\t\tif err != nil {\n\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t}\n\n\t\t// TODO: Do a bulk move action hook?\n\t\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_move_topic\", topic.ID, u)\n\t\tif skip || rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\t}\n\n\tif len(tids) == 1 {\n\t\thttp.Redirect(w, r, \"/topic/\"+strconv.Itoa(tids[0]), http.StatusSeeOther)\n\t}\n\treturn nil\n}\n\nfunc addTopicAction(action string, t *c.Topic, u *c.User) error {\n\terr := c.ModLogs.Create(action, t.ID, \"topic\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn t.CreateActionReply(action, u.GetIP(), u.ID)\n}\n\n// TODO: Refactor this\nfunc LikeTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\ttid, err := strconv.Atoi(stid)\n\tif err != nil {\n\t\treturn c.PreErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, js)\n\t}\n\ttopic, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The requested topic doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.LikeItem {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\tif topic.CreatedBy == u.ID {\n\t\treturn c.LocalErrorJSQ(\"You can't like your own topics\", w, r, u, js)\n\t}\n\n\t_, err = c.Users.Get(topic.CreatedBy)\n\tif err != nil && err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"The target user doesn't exist\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tscore := 1\n\terr = topic.Like(score, u.ID)\n\tif err == c.ErrAlreadyLiked {\n\t\treturn c.LocalErrorJSQ(\"You already liked this\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// ! Be careful about leaking per-route permission state with user ptr\n\talert := c.Alert{ActorID: u.ID, TargetUserID: topic.CreatedBy, Event: \"like\", ElementType: \"topic\", ElementID: tid, Actor: u}\n\terr = c.AddActivityAndNotifyTarget(alert)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_like_topic\", topic.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn actionSuccess(w, r, \"/topic/\"+strconv.Itoa(tid), js)\n}\nfunc UnlikeTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {\n\tjs := r.PostFormValue(\"js\") == \"1\"\n\ttid, err := strconv.Atoi(stid)\n\tif err != nil {\n\t\treturn c.PreErrorJSQ(p.GetErrorPhrase(\"id_must_be_integer\"), w, r, js)\n\t}\n\ttopic, err := c.Topics.Get(tid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.PreErrorJSQ(\"The requested topic doesn't exist.\", w, r, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Add hooks to make use of headerLite\n\tlite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)\n\tif ferr != nil {\n\t\treturn ferr\n\t}\n\tif !u.Perms.ViewTopic || !u.Perms.LikeItem {\n\t\treturn c.NoPermissionsJSQ(w, r, u, js)\n\t}\n\n\t_, err = c.Users.Get(topic.CreatedBy)\n\tif err != nil && err == sql.ErrNoRows {\n\t\treturn c.LocalErrorJSQ(\"The target user doesn't exist\", w, r, u, js)\n\t} else if err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\terr = topic.Unlike(u.ID)\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\t// TODO: Better coupling between the two params queries\n\taids, err := c.Activity.AidsByParams(\"like\", topic.ID, \"topic\")\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\tfor _, aid := range aids {\n\t\tc.DismissAlert(topic.CreatedBy, aid)\n\t}\n\terr = c.Activity.DeleteByParams(\"like\", topic.ID, \"topic\")\n\tif err != nil {\n\t\treturn c.InternalErrorJSQ(err, w, r, js)\n\t}\n\n\tskip, rerr := lite.Hooks.VhookSkippable(\"action_end_unlike_topic\", topic.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn actionSuccess(w, r, \"/topic/\"+strconv.Itoa(tid), js)\n}\n"
  },
  {
    "path": "routes/topic_list.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n)\n\nfunc wsTopicList(topicList []*c.TopicsRow, lastPage int) *c.WsTopicList {\n\twsTopicList := make([]*c.WsTopicsRow, len(topicList))\n\tfor i, tr := range topicList {\n\t\twsTopicList[i] = tr.WebSockets()\n\t}\n\treturn &c.WsTopicList{wsTopicList, lastPage, 0}\n}\n\nfunc wsTopicList2(topicList []*c.TopicsRow, u *c.User, fps map[int]c.QuickTools, lastPage int) *c.WsTopicList {\n\twsTopicList := make([]*c.WsTopicsRow, len(topicList))\n\tfor i, t := range topicList {\n\t\tvar canMod bool\n\t\tif fps == nil {\n\t\t\tcanMod = true\n\t\t} else {\n\t\t\tquickTools := fps[t.ParentID]\n\t\t\tcanMod = t.CreatedBy == u.ID || quickTools.CanDelete || quickTools.CanLock || quickTools.CanMove\n\t\t}\n\t\twsTopicList[i] = t.WebSockets2(canMod)\n\t}\n\treturn &c.WsTopicList{wsTopicList, lastPage, 0}\n}\n\nfunc TopicList(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\t/*skip, rerr := h.Hooks.VhookSkippable(\"route_topic_list_start\", w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}*/\n\tskip, rerr := c.H_route_topic_list_start_hook(h.Hooks, w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn TopicListCommon(w, r, u, h, \"lastupdated\", 0)\n}\n\nfunc TopicListMostViewed(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tskip, rerr := h.Hooks.VhookSkippable(\"route_topic_list_mostviewed_start\", w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn TopicListCommon(w, r, u, h, \"mostviewed\", c.TopicListMostViewed)\n}\n\nfunc TopicListWeekViews(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header) c.RouteError {\n\tskip, rerr := h.Hooks.VhookSkippable(\"route_topic_list_weekviews_start\", w, r, u, h)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\treturn TopicListCommon(w, r, u, h, \"weekviews\", c.TopicListWeekViews)\n}\n\n// TODO: Implement search\nfunc TopicListCommon(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header, torder string, tsorder int) c.RouteError {\n\th.Title = phrases.GetTitlePhrase(\"topics\")\n\th.Zone = \"topics\"\n\th.Path = \"/topics/\"\n\th.MetaDesc = h.Settings[\"meta_desc\"].(string)\n\n\tgroup, err := c.Groups.Get(user.Group)\n\tif err != nil {\n\t\tlog.Printf(\"Group #%d doesn't exist despite being used by c.User #%d\", user.Group, user.ID)\n\t\treturn c.LocalError(\"Something weird happened\", w, r, user)\n\t}\n\n\tvar forumList []c.Forum\n\t// Get the current page\n\tpage, _ := strconv.Atoi(r.FormValue(\"page\"))\n\tsfids := r.FormValue(\"fids\")\n\tvar fids []int\n\tif sfids != \"\" {\n\t\tfor _, sfid := range strings.Split(sfids, \",\") {\n\t\t\tfid, err := strconv.Atoi(sfid)\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalError(\"Invalid fid\", w, r, user)\n\t\t\t}\n\t\t\tfids = append(fids, fid)\n\t\t}\n\t\tif len(fids) == 1 {\n\t\t\tf, err := c.Forums.Get(fids[0])\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalError(\"Invalid fid forum\", w, r, user)\n\t\t\t}\n\t\t\th.Title = f.Name\n\t\t\th.ZoneID = f.ID\n\t\t\tforumList = append(forumList, *f)\n\t\t}\n\t}\n\n\t// TODO: Allow multiple forums in searches\n\t// TODO: Simplify this block after initially landing search\n\tvar topicList []*c.TopicsRow\n\tvar pagi c.Paginator\n\tvar canDelete, ccanDelete, canLock, ccanLock, canMove, ccanMove bool\n\tq := r.FormValue(\"q\")\n\tif q != \"\" && c.RepliesSearch != nil {\n\t\tvar canSee []int\n\t\tif user.IsSuperAdmin {\n\t\t\tcanSee, err = c.Forums.GetAllVisibleIDs()\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t} else {\n\t\t\tcanSee = group.CanSee\n\t\t}\n\n\t\tvar cfids []int\n\t\tif len(fids) > 0 {\n\t\t\tinSlice := func(haystack []int, needle int) bool {\n\t\t\t\tfor _, it := range haystack {\n\t\t\t\t\tif needle == it {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tfor _, fid := range fids {\n\t\t\t\tif inSlice(canSee, fid) {\n\t\t\t\t\tf := c.Forums.DirtyGet(fid)\n\t\t\t\t\tif f.Name != \"\" && f.Active && (f.ParentType == \"\" || f.ParentType == \"forum\") && f.TopicCount != 0 {\n\t\t\t\t\t\t// TODO: Add a hook here for plugin_guilds?\n\t\t\t\t\t\tcfids = append(cfids, fid)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tcfids = canSee\n\t\t}\n\n\t\ttids, err := c.RepliesSearch.Query(q, cfids)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t//log.Printf(\"tids %+v\\n\", tids)\n\t\t// TODO: Handle the case where there aren't any items...\n\t\t// TODO: Add a BulkGet method which returns a slice?\n\t\ttMap, err := c.Topics.BulkGetMap(tids)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t// TODO: Cache emptied map across requests with sync pool\n\t\treqUserList := make(map[int]bool)\n\t\tfor _, t := range tMap {\n\t\t\treqUserList[t.CreatedBy] = true\n\t\t\treqUserList[t.LastReplyBy] = true\n\t\t\ttopicList = append(topicList, t.TopicsRow())\n\t\t}\n\t\t//fmt.Printf(\"reqUserList %+v\\n\", reqUserList)\n\n\t\t// Convert the user ID map to a slice, then bulk load the users\n\t\tidSlice := make([]int, len(reqUserList))\n\t\tvar i int\n\t\tfor userID := range reqUserList {\n\t\t\tidSlice[i] = userID\n\t\t\ti++\n\t\t}\n\n\t\t// TODO: What if a user is deleted via the Control Panel?\n\t\t//fmt.Printf(\"idSlice %+v\\n\", idSlice)\n\t\tuserList, err := c.Users.BulkGetMap(idSlice)\n\t\tif err != nil {\n\t\t\treturn nil // TODO: Implement this!\n\t\t}\n\n\t\t// TODO: De-dupe this logic in common/topic_list.go?\n\t\t//var sb strings.Builder\n\t\tfps := make(map[int]c.QuickTools)\n\t\tfor _, t := range topicList {\n\t\t\t//c.BuildTopicURLSb(&sb, c.NameToSlug(t.Title), t.ID)\n\t\t\t//t.Link = sb.String()\n\t\t\t//sb.Reset()\n\t\t\tt.Link = c.BuildTopicURL(c.NameToSlug(t.Title), t.ID)\n\t\t\t// TODO: Pass forum to something like t.Forum and use that instead of these two properties? Could be more flexible.\n\t\t\tf := c.Forums.DirtyGet(t.ParentID)\n\t\t\tt.ForumName = f.Name\n\t\t\tt.ForumLink = f.Link\n\n\t\t\t_, ok := fps[f.ID]\n\t\t\tif !ok {\n\t\t\t\t// TODO: Abstract this?\n\t\t\t\tfp, err := c.FPStore.Get(f.ID, user.Group)\n\t\t\t\tif err == c.ErrNoRows {\n\t\t\t\t\tfp = c.BlankForumPerms()\n\t\t\t\t} else if err != nil {\n\t\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t\t}\n\t\t\t\tif fp.Overrides && !user.IsSuperAdmin {\n\t\t\t\t\tccanDelete = fp.DeleteTopic\n\t\t\t\t\tccanLock = fp.CloseTopic\n\t\t\t\t\tccanMove = fp.MoveTopic\n\t\t\t\t} else {\n\t\t\t\t\tccanDelete = user.Perms.DeleteTopic\n\t\t\t\t\tccanLock = user.Perms.CloseTopic\n\t\t\t\t\tccanMove = user.Perms.MoveTopic\n\t\t\t\t}\n\t\t\t\tif ccanDelete {\n\t\t\t\t\tcanDelete = true\n\t\t\t\t}\n\t\t\t\tif ccanLock {\n\t\t\t\t\tcanLock = true\n\t\t\t\t}\n\t\t\t\tif ccanMove {\n\t\t\t\t\tcanMove = true\n\t\t\t\t}\n\t\t\t\tfps[f.ID] = c.QuickTools{ccanDelete, ccanLock, ccanMove}\n\t\t\t}\n\n\t\t\t// TODO: Create a specialised function with a bit less overhead for getting the last page for a post count\n\t\t\t_, _, lastPage := c.PageOffset(t.PostCount, 1, c.Config.ItemsPerPage)\n\t\t\tt.LastPage = lastPage\n\t\t\t// TODO: Avoid map if either is equal to the current user\n\t\t\tt.Creator = userList[t.CreatedBy]\n\t\t\tt.LastUser = userList[t.LastReplyBy]\n\t\t}\n\n\t\t// TODO: Reduce the amount of boilerplate here\n\t\tif r.FormValue(\"js\") == \"1\" {\n\t\t\toutBytes, err := wsTopicList2(topicList, user, fps, pagi.LastPage).MarshalJSON()\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t\tw.Write(outBytes)\n\t\t\treturn nil\n\t\t}\n\n\t\ttopicList2 := make([]c.TopicsRowMut, len(topicList))\n\t\tfor i, t := range topicList {\n\t\t\tvar canMod bool\n\t\t\tif fps == nil {\n\t\t\t\tcanMod = true\n\t\t\t} else {\n\t\t\t\tquickTools := fps[t.ParentID]\n\t\t\t\tcanMod = t.CreatedBy == user.ID || quickTools.CanDelete || quickTools.CanLock || quickTools.CanMove\n\t\t\t}\n\t\t\ttopicList2[i] = c.TopicsRowMut{t, canMod}\n\t\t}\n\n\t\th.Title = phrases.GetTitlePhrase(\"topics_search\")\n\t\t//log.Printf(\"cfids: %+v\\n\", cfids)\n\t\tpi := c.TopicListPage{h, topicList2, forumList, c.Config.DefaultForum, c.TopicListSort{torder, false}, cfids, c.QuickTools{canDelete, canLock, canMove}, pagi}\n\t\treturn renderTemplate(\"topics\", w, r, h, pi)\n\t}\n\n\t// TODO: Pass a struct back rather than passing back so many variables\n\t//log.Printf(\"before forumList: %+v\\n\", forumList)\n\tvar fps map[int]c.QuickTools\n\tif user.IsSuperAdmin {\n\t\t//log.Print(\"user.IsSuperAdmin\")\n\t\ttopicList, forumList, pagi, err = c.TopicList.GetList(page, tsorder, fids)\n\t\tcanLock, canMove = true, true\n\t} else {\n\t\t//log.Print(\"!user.IsSuperAdmin\")\n\t\ttopicList, forumList, pagi, err = c.TopicList.GetListByGroup(group, page, tsorder, fids)\n\t\tfps = make(map[int]c.QuickTools)\n\t\tfor _, f := range forumList {\n\t\t\tfp, err := c.FPStore.Get(f.ID, user.Group)\n\t\t\tif err == c.ErrNoRows {\n\t\t\t\tfp = c.BlankForumPerms()\n\t\t\t} else if err != nil {\n\t\t\t\treturn c.InternalError(err, w, r)\n\t\t\t}\n\t\t\tif fp.Overrides {\n\t\t\t\tccanDelete = fp.DeleteTopic\n\t\t\t\tccanLock = fp.CloseTopic\n\t\t\t\tccanMove = fp.MoveTopic\n\t\t\t} else {\n\t\t\t\tccanDelete = user.Perms.DeleteTopic\n\t\t\t\tccanLock = user.Perms.CloseTopic\n\t\t\t\tccanMove = user.Perms.MoveTopic\n\t\t\t}\n\t\t\tif ccanDelete {\n\t\t\t\tcanDelete = true\n\t\t\t}\n\t\t\tif ccanLock {\n\t\t\t\tcanLock = true\n\t\t\t}\n\t\t\tif ccanMove {\n\t\t\t\tcanMove = true\n\t\t\t}\n\t\t\tfps[f.ID] = c.QuickTools{ccanDelete, ccanLock, ccanMove}\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\t//log.Printf(\"after forumList: %+v\\n\", forumList)\n\t//log.Printf(\"after topicList: %+v\\n\", topicList)\n\n\t// TODO: Reduce the amount of boilerplate here\n\tif r.FormValue(\"js\") == \"1\" {\n\t\toutBytes, err := wsTopicList2(topicList, user, fps, pagi.LastPage).MarshalJSON()\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tw.Write(outBytes)\n\t\treturn nil\n\t}\n\n\ttopicList2 := make([]c.TopicsRowMut, len(topicList))\n\tfor i, t := range topicList {\n\t\tvar canMod bool\n\t\tif fps == nil {\n\t\t\tcanMod = true\n\t\t} else {\n\t\t\tquickTools := fps[t.ParentID]\n\t\t\tcanMod = t.CreatedBy == user.ID || quickTools.CanDelete || quickTools.CanLock || quickTools.CanMove\n\t\t}\n\t\ttopicList2[i] = c.TopicsRowMut{t, canMod}\n\t}\n\n\tpi := c.TopicListPage{h, topicList2, forumList, c.Config.DefaultForum, c.TopicListSort{torder, false}, fids, c.QuickTools{canDelete, canLock, canMove}, pagi}\n\tif r.FormValue(\"i\") == \"1\" {\n\t\treturn renderTemplate(\"topics_mini\", w, r, h, pi)\n\t}\n\treturn renderTemplate(\"topics\", w, r, h, pi)\n}\n"
  },
  {
    "path": "routes/user.go",
    "content": "package routes\n\nimport (\n\t\"database/sql\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n)\n\nfunc BanUserSubmit(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\tif !u.Perms.BanUsers {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\tif uid == -2 {\n\t\treturn c.LocalError(\"Why don't you like Merlin?\", w, r, u)\n\t}\n\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to ban no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\t// TODO: Is there a difference between IsMod and IsSuperMod? Should we delete the redundant one?\n\tif targetUser.IsMod {\n\t\treturn c.LocalError(\"You may not ban another staff member.\", w, r, u)\n\t}\n\tif uid == u.ID {\n\t\treturn c.LocalError(\"Why are you trying to ban yourself? Stop that.\", w, r, u)\n\t}\n\tif targetUser.IsBanned {\n\t\treturn c.LocalError(\"The user you're trying to unban is already banned.\", w, r, u)\n\t}\n\n\tdurDays, err := strconv.Atoi(r.FormValue(\"dur-days\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"You can only use whole numbers for the number of days\", w, r, u)\n\t}\n\tdurWeeks, err := strconv.Atoi(r.FormValue(\"dur-weeks\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"You can only use whole numbers for the number of weeks\", w, r, u)\n\t}\n\tdurMonths, err := strconv.Atoi(r.FormValue(\"dur-months\"))\n\tif err != nil {\n\t\treturn c.LocalError(\"You can only use whole numbers for the number of months\", w, r, u)\n\t}\n\tdeletePosts := false\n\tswitch r.FormValue(\"delete-posts\") {\n\tcase \"1\":\n\t\tdeletePosts = true\n\t}\n\n\tvar dur time.Duration\n\tif durDays > 1 && durWeeks > 1 && durMonths > 1 {\n\t\tdur, _ = time.ParseDuration(\"0\")\n\t} else {\n\t\tvar secs int\n\t\tsecs += durDays * int(c.Day)\n\t\tsecs += durWeeks * int(c.Week)\n\t\tsecs += durMonths * int(c.Month)\n\t\tdur, _ = time.ParseDuration(strconv.Itoa(secs) + \"s\")\n\t}\n\n\terr = targetUser.Ban(dur, u.ID)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to ban no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.ModLogs.Create(\"ban\", uid, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\tif deletePosts {\n\t\terr = targetUser.DeletePosts()\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn c.LocalError(\"The user you're trying to ban no longer exists.\", w, r, u)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\terr = c.ModLogs.Create(\"delete-posts\", uid, \"user\", u.GetIP(), u.ID)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t}\n\n\t// TODO: Trickle the hookTable down from the router\n\thTbl := c.GetHookTable()\n\tskip, rerr := hTbl.VhookSkippable(\"action_end_ban_user\", targetUser.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(uid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc UnbanUser(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\tif !u.Perms.BanUsers {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to unban no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif !targetUser.IsBanned {\n\t\treturn c.LocalError(\"The user you're trying to unban isn't banned.\", w, r, u)\n\t}\n\n\terr = targetUser.Unban()\n\tif err == c.ErrNoTempGroup {\n\t\treturn c.LocalError(\"The user you're trying to unban is not banned\", w, r, u)\n\t} else if err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to unban no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\terr = c.ModLogs.Create(\"unban\", uid, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Trickle the hookTable down from the router\n\thTbl := c.GetHookTable()\n\tskip, rerr := hTbl.VhookSkippable(\"action_end_unban_user\", targetUser.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(uid), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc ActivateUser(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\tif !u.Perms.ActivateUsers {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The account you're trying to activate no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tif targetUser.Active {\n\t\treturn c.LocalError(\"The account you're trying to activate has already been activated.\", w, r, u)\n\t}\n\terr = targetUser.Activate()\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\ttargetUser, err = c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The account you're trying to activate no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.GroupPromotions.PromoteIfEligible(targetUser, targetUser.Level, targetUser.Posts, targetUser.CreatedAt)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\ttargetUser.CacheRemove()\n\n\terr = c.ModLogs.Create(\"activate\", targetUser.ID, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Trickle the hookTable down from the router\n\thTbl := c.GetHookTable()\n\tskip, rerr := hTbl.VhookSkippable(\"action_end_activate_user\", targetUser.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(targetUser.ID), http.StatusSeeOther)\n\treturn nil\n}\n\nfunc DeletePostsSubmit(w http.ResponseWriter, r *http.Request, u *c.User, suid string) c.RouteError {\n\tif !u.Perms.BanUsers {\n\t\treturn c.NoPermissions(w, r, u)\n\t}\n\tuid, err := strconv.Atoi(suid)\n\tif err != nil {\n\t\treturn c.LocalError(\"The provided UserID is not a valid number.\", w, r, u)\n\t}\n\n\ttargetUser, err := c.Users.Get(uid)\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to purge posts of no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\t// TODO: Is there a difference between IsMod and IsSuperMod? Should we delete the redundant one?\n\tif targetUser.IsMod {\n\t\treturn c.LocalError(\"You may not purge the posts of another staff member.\", w, r, u)\n\t}\n\n\terr = targetUser.DeletePosts()\n\tif err == sql.ErrNoRows {\n\t\treturn c.LocalError(\"The user you're trying to purge posts of no longer exists.\", w, r, u)\n\t} else if err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\terr = c.ModLogs.Create(\"delete-posts\", uid, \"user\", u.GetIP(), u.ID)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\n\t// TODO: Trickle the hookTable down from the router\n\thTbl := c.GetHookTable()\n\tskip, rerr := hTbl.VhookSkippable(\"action_end_delete_posts\", targetUser.ID, u)\n\tif skip || rerr != nil {\n\t\treturn rerr\n\t}\n\n\thttp.Redirect(w, r, \"/user/\"+strconv.Itoa(uid), http.StatusSeeOther)\n\treturn nil\n}\n"
  },
  {
    "path": "routes.go",
    "content": "/*\n*\n*\tGosora Route Handlers\n*\tCopyright Azareal 2016 - 2020\n*\n */\npackage main\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/common/phrases\"\n)\n\n// A blank list to fill out that parameter in Page for routes which don't use it\nvar tList []interface{}\nvar successJSONBytes = []byte(`{\"success\":1}`)\n\n// TODO: Refactor this\n// TODO: Use the phrase system\nvar phraseLoginAlerts = []byte(`{\"msgs\":[{\"msg\":\"Login to see your alerts\",\"path\":\"/accounts/login\"}],\"count\":0}`)\nvar alertStrPool = sync.Pool{}\n\n// TODO: Refactor this endpoint\n// TODO: Move this into the routes package\nfunc routeAPI(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t// TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr := r.ParseForm()\n\tif err != nil {\n\t\treturn c.PreErrorJS(\"Bad Form\", w, r)\n\t}\n\n\taction := r.FormValue(\"a\")\n\tif action == \"\" {\n\t\taction = \"get\"\n\t}\n\tif action != \"get\" && action != \"set\" {\n\t\treturn c.PreErrorJS(\"Invalid Action\", w, r)\n\t}\n\n\tswitch r.FormValue(\"m\") {\n\t// TODO: Split this into it's own function\n\tcase \"dismiss-alert\":\n\t\tid, err := strconv.Atoi(r.FormValue(\"id\"))\n\t\tif err != nil {\n\t\t\treturn c.PreErrorJS(\"Invalid id\", w, r)\n\t\t}\n\t\tres, err := stmts.deleteActivityStreamMatch.Exec(user.ID, id)\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\tcount, err := res.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn c.InternalError(err, w, r)\n\t\t}\n\t\t// Don't want to throw an internal error due to a socket closing\n\t\tif c.EnableWebsockets && count > 0 {\n\t\t\tc.DismissAlert(user.ID, id)\n\t\t}\n\t\tw.Write(successJSONBytes)\n\t// TODO: Split this into it's own function\n\tcase \"alerts\": // A feed of events tailored for a specific user\n\t\tif !user.Loggedin {\n\t\t\th := w.Header()\n\t\t\tif gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\t\t\tw = gzw.ResponseWriter\n\t\t\t\th.Del(\"Content-Encoding\")\n\t\t\t}\n\t\t\tetag := \"\\\"1583653869-n\\\"\"\n\t\t\t//etag = c.StartEtag\n\t\t\th.Set(\"ETag\", etag)\n\t\t\tif match := r.Header.Get(\"If-None-Match\"); match != \"\" {\n\t\t\t\tif strings.Contains(match, etag) {\n\t\t\t\t\tw.WriteHeader(http.StatusNotModified)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t\tw.Write(phraseLoginAlerts)\n\t\t\treturn nil\n\t\t}\n\n\t\tvar count int\n\t\terr = stmts.getActivityCountByWatcher.QueryRow(user.ID).Scan(&count)\n\t\tif err == ErrNoRows {\n\t\t\treturn c.PreErrorJS(\"Unable to get the activity count\", w, r)\n\t\t} else if err != nil {\n\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t}\n\n\t\tif count == 0 {\n\t\t\tif gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\t\t\tw = gzw.ResponseWriter\n\t\t\t\tw.Header().Del(\"Content-Encoding\")\n\t\t\t}\n\t\t\t_, _ = io.WriteString(w, `{}`)\n\t\t\treturn nil\n\t\t}\n\n\t\trCreatedAt, _ := strconv.ParseInt(r.FormValue(\"t\"), 10, 64)\n\t\trCount, _ := strconv.Atoi(r.FormValue(\"c\"))\n\t\t//log.Print(\"rCreatedAt:\", rCreatedAt)\n\t\t//log.Print(\"rCount:\", rCount)\n\t\tvar actors []int\n\t\tvar alerts []*c.Alert\n\t\tvar createdAt time.Time\n\t\tvar topCreatedAt int64\n\n\t\tif count != 0 {\n\t\t\trows, err := stmts.getActivityFeedByWatcher.Query(user.ID, 12)\n\t\t\tif err != nil {\n\t\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t\t}\n\t\t\tdefer rows.Close()\n\n\t\t\tfor rows.Next() {\n\t\t\t\tal := &c.Alert{}\n\t\t\t\terr = rows.Scan(&al.ASID, &al.ActorID, &al.TargetUserID, &al.Event, &al.ElementType, &al.ElementID, &createdAt)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t\t\t}\n\n\t\t\t\tuCreatedAt := createdAt.Unix()\n\t\t\t\t//log.Print(\"uCreatedAt\", uCreatedAt)\n\t\t\t\t//if rCreatedAt == 0 || rCreatedAt < uCreatedAt {\n\t\t\t\talerts = append(alerts, al)\n\t\t\t\tactors = append(actors, al.ActorID)\n\t\t\t\t//}\n\t\t\t\tif uCreatedAt > topCreatedAt {\n\t\t\t\t\ttopCreatedAt = uCreatedAt\n\t\t\t\t}\n\t\t\t}\n\t\t\tif err = rows.Err(); err != nil {\n\t\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t\t}\n\t\t}\n\n\t\tif len(alerts) == 0 || (rCreatedAt != 0 && rCreatedAt >= topCreatedAt && count == rCount) {\n\t\t\tif gzw, ok := w.(c.GzipResponseWriter); ok {\n\t\t\t\tw = gzw.ResponseWriter\n\t\t\t\tw.Header().Del(\"Content-Encoding\")\n\t\t\t}\n\t\t\t_, _ = io.WriteString(w, `{}`)\n\t\t\treturn nil\n\t\t}\n\n\t\t// Might not want to error here, if the account was deleted properly, we might want to figure out how we should handle deletions in general\n\t\tlist, err := c.Users.BulkGetMap(actors)\n\t\tif err != nil {\n\t\t\tlog.Print(\"actors:\", actors)\n\t\t\treturn c.InternalErrorJS(err, w, r)\n\t\t}\n\n\t\tvar sb *strings.Builder\n\t\tii := alertStrPool.Get()\n\t\tif ii == nil {\n\t\t\tsb = &strings.Builder{}\n\t\t} else {\n\t\t\tsb = ii.(*strings.Builder)\n\t\t\tsb.Reset()\n\t\t}\n\t\tsb.Grow(c.AlertsGrowHint + (len(alerts) * (c.AlertsGrowHint2 + 1)) - 1)\n\t\tsb.WriteString(`{\"msgs\":[`)\n\n\t\tvar ok bool\n\t\tfor i, alert := range alerts {\n\t\t\tif i != 0 {\n\t\t\t\tsb.WriteRune(',')\n\t\t\t}\n\t\t\talert.Actor, ok = list[alert.ActorID]\n\t\t\tif !ok {\n\t\t\t\treturn c.InternalErrorJS(errors.New(\"No such actor\"), w, r)\n\t\t\t}\n\t\t\terr := c.BuildAlertSb(sb, alert, user)\n\t\t\tif err != nil {\n\t\t\t\treturn c.LocalErrorJS(err.Error(), w, r)\n\t\t\t}\n\t\t}\n\t\tsb.WriteString(`],\"count\":`)\n\t\tsb.WriteString(strconv.Itoa(count))\n\t\tsb.WriteString(`,\"tc\":`)\n\t\t//rCreatedAt\n\t\tsb.WriteString(strconv.Itoa(int(topCreatedAt)))\n\t\tsb.WriteRune('}')\n\n\t\t_, _ = io.WriteString(w, sb.String())\n\t\talertStrPool.Put(sb)\n\tdefault:\n\t\treturn c.PreErrorJS(\"Invalid Module\", w, r)\n\t}\n\treturn nil\n}\n\n// TODO: Remove this line after we move routeAPIPhrases to the routes package\nvar cacheControlMaxAge = \"max-age=\" + strconv.Itoa(int(c.Day))\n\n// TODO: Be careful with exposing the panel phrases here, maybe move them into a different namespace? We also need to educate the admin that phrases aren't necessarily secret\n// TODO: Move to the routes package\nvar phraseWhitelist = []string{\n\t\"topic\",\n\t\"status\",\n\t\"alerts\",\n\t\"paginator\",\n\t\"analytics\",\n\n\t\"panel\", // We're going to handle this specially below as this is a security boundary\n}\n\nfunc routeAPIPhrases(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\t// TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats\n\th := w.Header()\n\th.Set(\"Content-Type\", \"application/json\")\n\n\terr := r.ParseForm()\n\tif err != nil {\n\t\treturn c.PreErrorJS(\"Bad Form\", w, r)\n\t}\n\tquery := r.FormValue(\"q\")\n\tif query == \"\" {\n\t\treturn c.PreErrorJS(\"No query provided\", w, r)\n\t}\n\n\tvar negations, positives []string\n\tfor _, queryBit := range strings.Split(query, \",\") {\n\t\tqueryBit = strings.TrimSpace(queryBit)\n\t\tif queryBit[0] == '!' && len(queryBit) > 1 {\n\t\t\tqueryBit = strings.TrimPrefix(queryBit, \"!\")\n\t\t\tfor _, ch := range queryBit {\n\t\t\t\tif !unicode.IsLetter(ch) && ch != '-' && ch != '_' {\n\t\t\t\t\treturn c.PreErrorJS(\"No symbols allowed, only - and _\", w, r)\n\t\t\t\t}\n\t\t\t}\n\t\t\tnegations = append(negations, queryBit)\n\t\t} else {\n\t\t\tfor _, ch := range queryBit {\n\t\t\t\tif !unicode.IsLetter(ch) && ch != '-' && ch != '_' {\n\t\t\t\t\treturn c.PreErrorJS(\"No symbols allowed, only - and _\", w, r)\n\t\t\t\t}\n\t\t\t}\n\t\t\tpositives = append(positives, queryBit)\n\t\t}\n\t}\n\tif len(positives) == 0 {\n\t\treturn c.PreErrorJS(\"You haven't requested any phrases\", w, r)\n\t}\n\th.Set(\"Cache-Control\", cacheControlMaxAge) //Cache-Control: max-age=31536000\n\n\tvar etag string\n\t_, ok := w.(c.GzipResponseWriter)\n\tif ok {\n\t\tetag = \"\\\"\" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + \"-ng\\\"\"\n\t} else {\n\t\tetag = \"\\\"\" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + \"-n\\\"\"\n\t}\n\n\tvar plist map[string]string\n\tvar notModified, private bool\n\tposLoop := func(positive string) c.RouteError {\n\t\t// ! Constrain it to a subset of phrases for now\n\t\tfor _, item := range phraseWhitelist {\n\t\t\tif strings.HasPrefix(positive, item) {\n\t\t\t\t// TODO: Break this down into smaller security boundaries based on control panel sections?\n\t\t\t\t// TODO: Do we have to be so strict with panel phrases?\n\t\t\t\tif strings.HasPrefix(positive, \"panel\") {\n\t\t\t\t\tprivate = true\n\t\t\t\t\tok = user.IsSuperMod\n\t\t\t\t} else {\n\t\t\t\t\tok = true\n\t\t\t\t\tif notModified {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\tw.Header().Set(\"ETag\", etag)\n\t\t\t\t\tmatch := r.Header.Get(\"If-None-Match\")\n\t\t\t\t\tif match != \"\" && strings.Contains(match, etag) {\n\t\t\t\t\t\tnotModified = true\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !ok {\n\t\t\treturn c.PreErrorJS(\"Outside of phrase prefix whitelist\", w, r)\n\t\t}\n\t\treturn nil\n\t}\n\n\t// A little optimisation to avoid copying entries from one map to the other, if we don't have to mutate it\n\tif len(positives) > 1 {\n\t\tplist = make(map[string]string)\n\t\tfor _, positive := range positives {\n\t\t\trerr := posLoop(positive)\n\t\t\tif rerr != nil {\n\t\t\t\treturn rerr\n\t\t\t}\n\t\t\tpPhrases, ok := phrases.GetTmplPhrasesByPrefix(positive)\n\t\t\tif !ok {\n\t\t\t\treturn c.PreErrorJS(\"No such prefix\", w, r)\n\t\t\t}\n\t\t\tfor name, phrase := range pPhrases {\n\t\t\t\tplist[name] = phrase\n\t\t\t}\n\t\t}\n\t} else {\n\t\trerr := posLoop(positives[0])\n\t\tif rerr != nil {\n\t\t\treturn rerr\n\t\t}\n\t\tpPhrases, ok := phrases.GetTmplPhrasesByPrefix(positives[0])\n\t\tif !ok {\n\t\t\treturn c.PreErrorJS(\"No such prefix\", w, r)\n\t\t}\n\t\tplist = pPhrases\n\t}\n\n\tif private {\n\t\tw.Header().Set(\"Cache-Control\", \"private\")\n\t} else if notModified {\n\t\tw.WriteHeader(http.StatusNotModified)\n\t\treturn nil\n\t}\n\n\tfor _, negation := range negations {\n\t\tfor name, _ := range plist {\n\t\t\tif strings.HasPrefix(name, negation) {\n\t\t\t\tdelete(plist, name)\n\t\t\t}\n\t\t}\n\t}\n\n\t// TODO: Cache the output of this, especially for things like topic, so we don't have to waste more time than we need on this\n\tjsonBytes, err := json.Marshal(plist)\n\tif err != nil {\n\t\treturn c.InternalError(err, w, r)\n\t}\n\tw.Write(jsonBytes)\n\treturn nil\n}\n\n// A dedicated function so we can shake things up every now and then to make the token harder to parse\n// TODO: Are we sure we want to do this by ID, just in case we reuse this and have multiple antispams on the page?\nfunc routeJSAntispam(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {\n\th := sha256.New()\n\th.Write([]byte(c.JSTokenBox.Load().(string)))\n\th.Write([]byte(user.GetIP()))\n\tjsToken := hex.EncodeToString(h.Sum(nil))\n\n\tinnerCode := \"`document.getElementByld('golden-watch').value='\" + jsToken + \"';`\"\n\tio.WriteString(w, `let hihi=`+innerCode+`;hihi=hihi.replace('ld','Id');eval(hihi);`)\n\n\treturn nil\n}\n"
  },
  {
    "path": "run-linux",
    "content": "echo \"Deleting artifacts from previous builds\"\nrm -f template_*.go\nrm -f tmpl_*.go\nrm -f gen_*.go\nrm -f tmpl_client/template_*\nrm -f tmpl_client/tmpl_*\nrm -f ./Gosora\nrm -f ./common/gen_extend.go\n\necho \"Generating the dynamic code\"\ngo generate\n\necho \"Building the router generator\"\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\necho \"Running the router generator\"\n./RouterGen\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\necho \"Building the hook generator\"\ngo build -tags hookgen -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\necho \"Running the hook generator\"\n./HookGen\n\necho \"Generating the JSON handlers\"\neasyjson -pkg common\n\necho \"Building the query generator\"\ngo build -ldflags=\"-s -w\" -o QGen \"./cmd/query_gen\"\necho \"Running the query generator\"\n./QGen\n\necho \"Building Gosora\"\ngo build -ldflags=\"-s -w\" -o Gosora\n\necho \"Building the templates\"\n./Gosora -build-templates\n\necho \"Building Gosora... Again\"\ngo build -ldflags=\"-s -w\" -o Gosora\n\necho \"Running Gosora\"\n./Gosora"
  },
  {
    "path": "run-linux-nowebsockets",
    "content": "echo \"Deleting artifacts from previous builds\"\nrm -f template_*.go\nrm -f tmpl_*.go\nrm -f gen_*.go\nrm -f tmpl_client/template_*\nrm -f tmpl_client/tmpl_*\nrm -f ./Gosora\nrm -f ./common/gen_extend.go\n\necho \"Generating the dynamic code\"\ngo generate\n\necho \"Building the router generator\"\ngo build -ldflags=\"-s -w\" -o RouterGen \"./router_gen\"\necho \"Running the router generator\"\n./RouterGen\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\necho \"Building the hook generator\"\ngo build -tags hookgen -ldflags=\"-s -w\" -o HookGen \"./cmd/hook_gen\"\necho \"Running the hook generator\"\n./HookGen\n\necho \"Generating the JSON handlers\"\neasyjson -pkg common\n\necho \"Building the query generator\"\ngo build -ldflags=\"-s -w\" -o QueryGen \"./cmd/query_gen\"\necho \"Running the query generator\"\n./QueryGen\n\necho \"Building Gosora\"\ngo build -ldflags=\"-s -w\" -o Gosora -tags no_ws\n\necho \"Building the templates\"\n./Gosora -build-templates\n\necho \"Building Gosora... Again\"\ngo build -ldflags=\"-s -w\" -o Gosora -tags no_ws\n\necho \"Running Gosora\"\n./Gosora\n"
  },
  {
    "path": "run-linux-tests",
    "content": "echo \"Generating the dynamic code\"\ngo generate\necho Generating the JSON handlers\neasyjson -pkg common\necho \"Running tests\"\ngo build -ldflags=\"-s -w\" -o mssqlBuild -tags mssql\ngo test -coverprofile c.out\n"
  },
  {
    "path": "run-nowebsockets.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the router generator\nrouter_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook stub generator\nhook_stub_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook generator\nhook_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the query generator\nquery_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe -tags no_ws\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the templates\ngosora.exe -build-templates\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable... again\ngo build -ldflags=\"-s -w\" -o gosora.exe -tags no_ws\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Running Gosora\ngosora.exe\npause"
  },
  {
    "path": "run.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the router generator\nrouter_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook stub generator\nhook_stub_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook generator\nhook_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the query generator\nquery_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the templates\ngosora.exe -build-templates\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable... again\ngo build -ldflags=\"-s -w\" -gcflags=\"-d=ssa/check_bce/debug=1\" -o gosora.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Running Gosora\ngosora.exe\nrem Or you could redirect the output to a file\nrem gosora.exe > ./logs/ops.log 2>&1\npause"
  },
  {
    "path": "run_mssql.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the router generator\nrouter_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook stub generator\nhook_stub_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook generator\nhook_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the query generator\nquery_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable\ngo build -ldflags=\"-s -w\" -o gosora.exe -tags mssql\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the templates\ngosora.exe -build-templates\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable... again\ngo build -ldflags=\"-s -w\" -o gosora.exe -tags mssql\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Running Gosora\ngosora.exe\npause"
  },
  {
    "path": "run_tests.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the router generator\nrouter_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook stub generator\nhook_stub_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook generator\nhook_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the query generator\nquery_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable\ngo test\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\npause"
  },
  {
    "path": "run_tests_mssql.bat",
    "content": "@echo off\nrem TODO: Make these deletes a little less noisy\ndel \"template_*.go\"\ndel \"tmpl_*.go\"\ndel \"gen_*.go\"\ndel \".\\tmpl_client\\template_*\"\ndel \".\\tmpl_client\\tmpl_*\"\ndel \".\\common\\gen_extend.go\"\ndel \"gosora.exe\"\n\necho Generating the dynamic code\ngo generate\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the router generator\ngo build -ldflags=\"-s -w\" ./router_gen\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the router generator\nrouter_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the hook stub generator\ngo build -ldflags=\"-s -w\" \"./cmd/hook_stub_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook stub generator\nhook_stub_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Generating the JSON handlers\neasyjson -pkg common\n\necho Building the hook generator\ngo build -tags hookgen -ldflags=\"-s -w\" \"./cmd/hook_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the hook generator\nhook_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the query generator\ngo build -ldflags=\"-s -w\" \"./cmd/query_gen\"\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\necho Running the query generator\nquery_gen.exe\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho Building the executable\ngo test -tags mssql\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\npause"
  },
  {
    "path": "schema/mssql/inserts.sql",
    "content": "INSERT INTO [sync] ([last_update]) VALUES (GETUTCDATE());\nINSERT INTO [settings] ([name],[content],[type],[constraints]) VALUES ('activation_type','1','list','1-3');\nINSERT INTO [settings] ([name],[content],[type]) VALUES ('bigpost_min_words','250','int');\nINSERT INTO [settings] ([name],[content],[type]) VALUES ('megapost_min_words','1000','int');\nINSERT INTO [settings] ([name],[content],[type]) VALUES ('meta_desc','','html-attribute');\nINSERT INTO [settings] ([name],[content],[type]) VALUES ('rapid_loading','1','bool');\nINSERT INTO [settings] ([name],[content],[type]) VALUES ('google_site_verify','','html-attribute');\nINSERT INTO [settings] ([name],[content],[type],[constraints]) VALUES ('avatar_visibility','0','list','0-1');\nINSERT INTO [themes] ([uname],[default]) VALUES ('cosora',1);\nINSERT INTO [emails] ([email],[uid],[validated]) VALUES ('admin@localhost',1,1);\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Administrator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserEmail\":true,\"EditUserPassword\":true,\"EditUserGroup\":true,\"EditUserGroupSuperMod\":true,\"EditGroup\":true,\"EditGroupLocalPerms\":true,\"EditGroupGlobalPerms\":true,\"EditGroupSuperMod\":true,\"ManageForums\":true,\"EditSettings\":true,\"ManageThemes\":true,\"ManagePlugins\":true,\"ViewAdminLogs\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,1,0,'Admin');\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Moderator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserGroup\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,0,0,'Mod');\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Member','{\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"CreateReply\":true}','{}',0,0,0,\"\");\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Banned','{\"ViewTopic\":true}','{}',0,0,1,\"\");\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Awaiting Activation','{\"UseConvosOnlyWithMod\":true,\"ViewTopic\":true}','{}',0,0,0,\"\");\nINSERT INTO [users_groups] ([name],[permissions],[plugin_perms],[is_mod],[is_admin],[is_banned],[tag]) VALUES ('Not Loggedin','{\"ViewTopic\":true}','{}',0,0,0,'Guest');\nINSERT INTO [forums] ([name],[active],[desc],[tmpl]) VALUES ('Reports',0,'All the reports go here','');\nINSERT INTO [forums] ([name],[lastTopicID],[lastReplyerID],[desc],[tmpl]) VALUES ('General',1,1,'A place for general discussions which don''t fit elsewhere','');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (1,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"PinTopic\":true,\"CloseTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (2,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CloseTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (3,1,'{}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (4,1,'{}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (5,1,'{}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (6,1,'{}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (1,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (2,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (3,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (4,2,'{\"ViewTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (5,2,'{\"ViewTopic\":true}');\nINSERT INTO [forums_permissions] ([gid],[fid],[permissions]) VALUES (6,2,'{\"ViewTopic\":true}');\nINSERT INTO [topics] ([title],[content],[parsed_content],[createdAt],[lastReplyAt],[lastReplyBy],[createdBy],[parentID],[ip]) VALUES ('Test Topic','A topic automatically generated by the software.','A topic automatically generated by the software.',GETUTCDATE(),GETUTCDATE(),1,1,2,'');\nINSERT INTO [replies] ([tid],[content],[parsed_content],[createdAt],[createdBy],[lastUpdated],[lastEdit],[lastEditBy],[ip]) VALUES (1,'A reply!','A reply!',GETUTCDATE(),1,GETUTCDATE(),0,0,'');\nINSERT INTO [menus] () VALUES ();\nINSERT INTO [menu_items] ([mid],[name],[htmlID],[position],[path],[aria],[tooltip],[order]) VALUES (1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0);\nINSERT INTO [menu_items] ([mid],[name],[htmlID],[cssClass],[position],[path],[aria],[tooltip],[order]) VALUES (1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1);\nINSERT INTO [menu_items] ([mid],[htmlID],[cssClass],[position],[tmplName],[order]) VALUES (1,'general_alerts','menu_alerts','right','menu_alerts',2);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[staffOnly],[order]) VALUES (1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[memberOnly],[order]) VALUES (1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?s={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[guestOnly],[order]) VALUES (1,'{lang.menu_register}','menu_register','left','/accounts/create/','{lang.menu_register_aria}','{lang.menu_register_tooltip}',1,7);\nINSERT INTO [menu_items] ([mid],[name],[cssClass],[position],[path],[aria],[tooltip],[guestOnly],[order]) VALUES (1,'{lang.menu_login}','menu_login','left','/accounts/login/','{lang.menu_login_aria}','{lang.menu_login_tooltip}',1,8);\n"
  },
  {
    "path": "schema/mssql/query_activity_stream.sql",
    "content": "CREATE TABLE [activity_stream] (\n\t[asid] int not null IDENTITY,\n\t[actor] int not null,\n\t[targetUser] int not null,\n\t[event] nvarchar (50) not null,\n\t[elementType] nvarchar (50) not null,\n\t[elementID] int not null,\n\t[createdAt] datetime not null,\n\t[extra] nvarchar (200) DEFAULT '' not null,\n\tprimary key([asid])\n);"
  },
  {
    "path": "schema/mssql/query_activity_stream_matches.sql",
    "content": "CREATE TABLE [activity_stream_matches] (\n\t[watcher] int not null,\n\t[asid] int not null,\n\tforeign key([asid],[asid])\n);"
  },
  {
    "path": "schema/mssql/query_activity_subscriptions.sql",
    "content": "CREATE TABLE [activity_subscriptions] (\n\t[user] int not null,\n\t[targetID] int not null,\n\t[targetType] nvarchar (50) not null,\n\t[level] int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mssql/query_administration_logs.sql",
    "content": "CREATE TABLE [administration_logs] (\n\t[action] nvarchar (100) not null,\n\t[elementID] int not null,\n\t[elementType] nvarchar (100) not null,\n\t[ipaddress] nvarchar (200) not null,\n\t[actorID] int not null,\n\t[doneAt] datetime not null,\n\t[extra] nvarchar (MAX) not null\n);"
  },
  {
    "path": "schema/mssql/query_attachments.sql",
    "content": "CREATE TABLE [attachments] (\n\t[attachID] int not null IDENTITY,\n\t[sectionID] int DEFAULT 0 not null,\n\t[sectionTable] nvarchar (200) DEFAULT 'forums' not null,\n\t[originID] int not null,\n\t[originTable] nvarchar (200) DEFAULT 'replies' not null,\n\t[uploadedBy] int not null,\n\t[path] nvarchar (200) not null,\n\t[extra] nvarchar (200) not null,\n\tprimary key([attachID])\n);"
  },
  {
    "path": "schema/mssql/query_conversations.sql",
    "content": "CREATE TABLE [conversations] (\n\t[cid] int not null IDENTITY,\n\t[createdBy] int not null,\n\t[createdAt] datetime not null,\n\t[lastReplyAt] datetime not null,\n\t[lastReplyBy] int not null,\n\tprimary key([cid])\n);"
  },
  {
    "path": "schema/mssql/query_conversations_participants.sql",
    "content": "CREATE TABLE [conversations_participants] (\n\t[uid] int not null,\n\t[cid] int not null\n);"
  },
  {
    "path": "schema/mssql/query_conversations_posts.sql",
    "content": "CREATE TABLE [conversations_posts] (\n\t[pid] int not null IDENTITY,\n\t[cid] int not null,\n\t[createdBy] int not null,\n\t[body] nvarchar (50) not null,\n\t[post] nvarchar (50) DEFAULT '' not null,\n\tprimary key([pid])\n);"
  },
  {
    "path": "schema/mssql/query_emails.sql",
    "content": "CREATE TABLE [emails] (\n\t[email] nvarchar (200) not null,\n\t[uid] int not null,\n\t[validated] bit DEFAULT 0 not null,\n\t[token] nvarchar (200) DEFAULT '' not null\n);"
  },
  {
    "path": "schema/mssql/query_forums.sql",
    "content": "CREATE TABLE [forums] (\n\t[fid] int not null IDENTITY,\n\t[name] nvarchar (100) not null,\n\t[desc] nvarchar (200) not null,\n\t[tmpl] nvarchar (200) DEFAULT '' not null,\n\t[active] bit DEFAULT 1 not null,\n\t[order] int DEFAULT 0 not null,\n\t[topicCount] int DEFAULT 0 not null,\n\t[preset] nvarchar (100) DEFAULT '' not null,\n\t[parentID] int DEFAULT 0 not null,\n\t[parentType] nvarchar (50) DEFAULT '' not null,\n\t[lastTopicID] int DEFAULT 0 not null,\n\t[lastReplyerID] int DEFAULT 0 not null,\n\tprimary key([fid])\n);"
  },
  {
    "path": "schema/mssql/query_forums_actions.sql",
    "content": "CREATE TABLE [forums_actions] (\n\t[faid] int not null IDENTITY,\n\t[fid] int not null,\n\t[runOnTopicCreation] bit DEFAULT 0 not null,\n\t[runDaysAfterTopicCreation] int DEFAULT 0 not null,\n\t[runDaysAfterTopicLastReply] int DEFAULT 0 not null,\n\t[action] nvarchar (50) not null,\n\t[extra] nvarchar (200) DEFAULT '' not null,\n\tprimary key([faid])\n);"
  },
  {
    "path": "schema/mssql/query_forums_permissions.sql",
    "content": "CREATE TABLE [forums_permissions] (\n\t[fid] int not null,\n\t[gid] int not null,\n\t[preset] nvarchar (100) DEFAULT '' not null,\n\t[permissions] nvarchar (MAX) not null,\n\tprimary key([fid],[gid])\n);"
  },
  {
    "path": "schema/mssql/query_likes.sql",
    "content": "CREATE TABLE [likes] (\n\t[weight] tinyint DEFAULT 1 not null,\n\t[targetItem] int not null,\n\t[targetType] nvarchar (50) DEFAULT 'replies' not null,\n\t[sentBy] int not null,\n\t[createdAt] datetime not null,\n\t[recalc] tinyint DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mssql/query_login_logs.sql",
    "content": "CREATE TABLE [login_logs] (\n\t[lid] int not null IDENTITY,\n\t[uid] int not null,\n\t[success] bool DEFAULT 0 not null,\n\t[ipaddress] nvarchar (200) not null,\n\t[doneAt] datetime not null,\n\tprimary key([lid])\n);"
  },
  {
    "path": "schema/mssql/query_memchunks.sql",
    "content": "CREATE TABLE [memchunks] (\n\t[count] int DEFAULT 0 not null,\n\t[stack] int DEFAULT 0 not null,\n\t[heap] int DEFAULT 0 not null,\n\t[createdAt] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_menu_items.sql",
    "content": "CREATE TABLE [menu_items] (\n\t[miid] int not null IDENTITY,\n\t[mid] int not null,\n\t[name] nvarchar (200) DEFAULT '' not null,\n\t[htmlID] nvarchar (200) DEFAULT '' not null,\n\t[cssClass] nvarchar (200) DEFAULT '' not null,\n\t[position] nvarchar (100) not null,\n\t[path] nvarchar (200) DEFAULT '' not null,\n\t[aria] nvarchar (200) DEFAULT '' not null,\n\t[tooltip] nvarchar (200) DEFAULT '' not null,\n\t[tmplName] nvarchar (200) DEFAULT '' not null,\n\t[order] int DEFAULT 0 not null,\n\t[guestOnly] bit DEFAULT 0 not null,\n\t[memberOnly] bit DEFAULT 0 not null,\n\t[staffOnly] bit DEFAULT 0 not null,\n\t[adminOnly] bit DEFAULT 0 not null,\n\tprimary key([miid])\n);"
  },
  {
    "path": "schema/mssql/query_menus.sql",
    "content": "CREATE TABLE [menus] (\n\t[mid] int not null IDENTITY,\n\tprimary key([mid])\n);"
  },
  {
    "path": "schema/mssql/query_meta.sql",
    "content": "CREATE TABLE [meta] (\n\t[name] nvarchar (200) not null,\n\t[value] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_moderation_logs.sql",
    "content": "CREATE TABLE [moderation_logs] (\n\t[action] nvarchar (100) not null,\n\t[elementID] int not null,\n\t[elementType] nvarchar (100) not null,\n\t[ipaddress] nvarchar (200) not null,\n\t[actorID] int not null,\n\t[doneAt] datetime not null,\n\t[extra] nvarchar (MAX) not null\n);"
  },
  {
    "path": "schema/mssql/query_pages.sql",
    "content": "CREATE TABLE [pages] (\n\t[pid] int not null IDENTITY,\n\t[name] nvarchar (200) not null,\n\t[title] nvarchar (200) not null,\n\t[body] nvarchar (MAX) not null,\n\t[allowedGroups] nvarchar (MAX) not null,\n\t[menuID] int DEFAULT -1 not null,\n\tprimary key([pid])\n);"
  },
  {
    "path": "schema/mssql/query_password_resets.sql",
    "content": "CREATE TABLE [password_resets] (\n\t[email] nvarchar (200) not null,\n\t[uid] int not null,\n\t[validated] nvarchar (200) not null,\n\t[token] nvarchar (200) not null,\n\t[createdAt] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_perfchunks.sql",
    "content": "CREATE TABLE [perfchunks] (\n\t[low] int DEFAULT 0 not null,\n\t[high] int DEFAULT 0 not null,\n\t[avg] int DEFAULT 0 not null,\n\t[createdAt] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_plugins.sql",
    "content": "CREATE TABLE [plugins] (\n\t[uname] nvarchar (180) not null,\n\t[active] bit DEFAULT 0 not null,\n\t[installed] bit DEFAULT 0 not null,\n\tunique([uname])\n);"
  },
  {
    "path": "schema/mssql/query_polls.sql",
    "content": "CREATE TABLE [polls] (\n\t[pollID] int not null IDENTITY,\n\t[parentID] int DEFAULT 0 not null,\n\t[parentTable] nvarchar (100) DEFAULT 'topics' not null,\n\t[type] int DEFAULT 0 not null,\n\t[options] nvarchar (MAX) not null,\n\t[votes] int DEFAULT 0 not null,\n\tprimary key([pollID])\n);"
  },
  {
    "path": "schema/mssql/query_polls_options.sql",
    "content": "CREATE TABLE [polls_options] (\n\t[pollID] int not null,\n\t[option] int DEFAULT 0 not null,\n\t[votes] int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mssql/query_polls_voters.sql",
    "content": "CREATE TABLE [polls_voters] (\n\t[pollID] int not null,\n\t[uid] int not null,\n\t[option] int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mssql/query_polls_votes.sql",
    "content": "CREATE TABLE [polls_votes] (\n\t[pollID] int not null,\n\t[uid] int not null,\n\t[option] int DEFAULT 0 not null,\n\t[castAt] datetime not null,\n\t[ip] nvarchar (200) DEFAULT '' not null\n);"
  },
  {
    "path": "schema/mssql/query_postchunks.sql",
    "content": "CREATE TABLE [postchunks] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_registration_logs.sql",
    "content": "CREATE TABLE [registration_logs] (\n\t[rlid] int not null IDENTITY,\n\t[username] nvarchar (100) not null,\n\t[email] nvarchar (100) not null,\n\t[failureReason] nvarchar (100) not null,\n\t[success] bool DEFAULT 0 not null,\n\t[ipaddress] nvarchar (200) not null,\n\t[doneAt] datetime not null,\n\tprimary key([rlid])\n);"
  },
  {
    "path": "schema/mssql/query_replies.sql",
    "content": "CREATE TABLE [replies] (\n\t[rid] int not null IDENTITY,\n\t[tid] int not null,\n\t[content] nvarchar (MAX) not null,\n\t[parsed_content] nvarchar (MAX) not null,\n\t[createdAt] datetime not null,\n\t[createdBy] int not null,\n\t[lastEdit] int DEFAULT 0 not null,\n\t[lastEditBy] int DEFAULT 0 not null,\n\t[lastUpdated] datetime not null,\n\t[ip] nvarchar (200) DEFAULT '' not null,\n\t[likeCount] int DEFAULT 0 not null,\n\t[attachCount] int DEFAULT 0 not null,\n\t[words] int DEFAULT 1 not null,\n\t[actionType] nvarchar (20) DEFAULT '' not null,\n\t[poll] int DEFAULT 0 not null,\n\tprimary key([rid]),\n\tfulltext key([content])\n);"
  },
  {
    "path": "schema/mssql/query_revisions.sql",
    "content": "CREATE TABLE [revisions] (\n\t[reviseID] int not null IDENTITY,\n\t[content] nvarchar (MAX) not null,\n\t[contentID] int not null,\n\t[contentType] nvarchar (100) DEFAULT 'replies' not null,\n\t[createdAt] datetime not null,\n\tprimary key([reviseID])\n);"
  },
  {
    "path": "schema/mssql/query_settings.sql",
    "content": "CREATE TABLE [settings] (\n\t[name] nvarchar (180) not null,\n\t[content] nvarchar (250) not null,\n\t[type] nvarchar (50) not null,\n\t[constraints] nvarchar (200) DEFAULT '' not null,\n\tunique([name])\n);"
  },
  {
    "path": "schema/mssql/query_sync.sql",
    "content": "CREATE TABLE [sync] (\n\t[last_update] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_themes.sql",
    "content": "CREATE TABLE [themes] (\n\t[uname] nvarchar (180) not null,\n\t[default] bit DEFAULT 0 not null,\n\tunique([uname])\n);"
  },
  {
    "path": "schema/mssql/query_topicchunks.sql",
    "content": "CREATE TABLE [topicchunks] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null\n);"
  },
  {
    "path": "schema/mssql/query_topics.sql",
    "content": "CREATE TABLE [topics] (\n\t[tid] int not null IDENTITY,\n\t[title] nvarchar (100) not null,\n\t[content] nvarchar (MAX) not null,\n\t[parsed_content] nvarchar (MAX) not null,\n\t[createdAt] datetime not null,\n\t[lastReplyAt] datetime not null,\n\t[lastReplyBy] int not null,\n\t[lastReplyID] int DEFAULT 0 not null,\n\t[createdBy] int not null,\n\t[is_closed] bit DEFAULT 0 not null,\n\t[sticky] bit DEFAULT 0 not null,\n\t[parentID] int DEFAULT 2 not null,\n\t[ip] nvarchar (200) DEFAULT '' not null,\n\t[postCount] int DEFAULT 1 not null,\n\t[likeCount] int DEFAULT 0 not null,\n\t[attachCount] int DEFAULT 0 not null,\n\t[words] int DEFAULT 0 not null,\n\t[views] int DEFAULT 0 not null,\n\t[weekEvenViews] int DEFAULT 0 not null,\n\t[weekOddViews] int DEFAULT 0 not null,\n\t[css_class] nvarchar (100) DEFAULT '' not null,\n\t[poll] int DEFAULT 0 not null,\n\t[data] nvarchar (200) DEFAULT '' not null,\n\tprimary key([tid]),\n\tfulltext key([title]),\n\tfulltext key([content])\n);"
  },
  {
    "path": "schema/mssql/query_updates.sql",
    "content": "CREATE TABLE [updates] (\n\t[dbVersion] int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mssql/query_users.sql",
    "content": "CREATE TABLE [users] (\n\t[uid] int not null IDENTITY,\n\t[name] nvarchar (100) not null,\n\t[password] nvarchar (100) not null,\n\t[salt] nvarchar (80) DEFAULT '' not null,\n\t[group] int not null,\n\t[active] bit DEFAULT 0 not null,\n\t[is_super_admin] bit DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[lastActiveAt] datetime not null,\n\t[session] nvarchar (200) DEFAULT '' not null,\n\t[last_ip] nvarchar (200) DEFAULT '' not null,\n\t[enable_embeds] int DEFAULT -1 not null,\n\t[email] nvarchar (200) DEFAULT '' not null,\n\t[avatar] nvarchar (100) DEFAULT '' not null,\n\t[message] nvarchar (MAX) DEFAULT '' not null,\n\t[url_prefix] nvarchar (20) DEFAULT '' not null,\n\t[url_name] nvarchar (100) DEFAULT '' not null,\n\t[level] smallint DEFAULT 0 not null,\n\t[score] int DEFAULT 0 not null,\n\t[posts] int DEFAULT 0 not null,\n\t[bigposts] int DEFAULT 0 not null,\n\t[megaposts] int DEFAULT 0 not null,\n\t[topics] int DEFAULT 0 not null,\n\t[liked] int DEFAULT 0 not null,\n\t[oldestItemLikedCreatedAt] datetime not null,\n\t[lastLiked] datetime not null,\n\t[temp_group] int DEFAULT 0 not null,\n\tprimary key([uid]),\n\tunique([name])\n);"
  },
  {
    "path": "schema/mssql/query_users_2fa_keys.sql",
    "content": "CREATE TABLE [users_2fa_keys] (\n\t[uid] int not null,\n\t[secret] nvarchar (100) not null,\n\t[scratch1] nvarchar (50) not null,\n\t[scratch2] nvarchar (50) not null,\n\t[scratch3] nvarchar (50) not null,\n\t[scratch4] nvarchar (50) not null,\n\t[scratch5] nvarchar (50) not null,\n\t[scratch6] nvarchar (50) not null,\n\t[scratch7] nvarchar (50) not null,\n\t[scratch8] nvarchar (50) not null,\n\t[createdAt] datetime not null,\n\tprimary key([uid])\n);"
  },
  {
    "path": "schema/mssql/query_users_avatar_queue.sql",
    "content": "CREATE TABLE [users_avatar_queue] (\n\t[uid] int not null,\n\tprimary key([uid])\n);"
  },
  {
    "path": "schema/mssql/query_users_blocks.sql",
    "content": "CREATE TABLE [users_blocks] (\n\t[blocker] int not null,\n\t[blockedUser] int not null\n);"
  },
  {
    "path": "schema/mssql/query_users_groups.sql",
    "content": "CREATE TABLE [users_groups] (\n\t[gid] int not null IDENTITY,\n\t[name] nvarchar (100) not null,\n\t[permissions] nvarchar (MAX) not null,\n\t[plugin_perms] nvarchar (MAX) not null,\n\t[is_mod] bit DEFAULT 0 not null,\n\t[is_admin] bit DEFAULT 0 not null,\n\t[is_banned] bit DEFAULT 0 not null,\n\t[user_count] int DEFAULT 0 not null,\n\t[tag] nvarchar (50) DEFAULT '' not null,\n\tprimary key([gid])\n);"
  },
  {
    "path": "schema/mssql/query_users_groups_promotions.sql",
    "content": "CREATE TABLE [users_groups_promotions] (\n\t[pid] int not null IDENTITY,\n\t[from_gid] int not null,\n\t[to_gid] int not null,\n\t[two_way] bit DEFAULT 0 not null,\n\t[level] int not null,\n\t[posts] int DEFAULT 0 not null,\n\t[minTime] int not null,\n\t[registeredFor] int DEFAULT 0 not null,\n\tprimary key([pid])\n);"
  },
  {
    "path": "schema/mssql/query_users_groups_scheduler.sql",
    "content": "CREATE TABLE [users_groups_scheduler] (\n\t[uid] int not null,\n\t[set_group] int not null,\n\t[issued_by] int not null,\n\t[issued_at] datetime not null,\n\t[revert_at] datetime not null,\n\t[temporary] bit not null,\n\tprimary key([uid])\n);"
  },
  {
    "path": "schema/mssql/query_users_replies.sql",
    "content": "CREATE TABLE [users_replies] (\n\t[rid] int not null IDENTITY,\n\t[uid] int not null,\n\t[content] nvarchar (MAX) not null,\n\t[parsed_content] nvarchar (MAX) not null,\n\t[createdAt] datetime not null,\n\t[createdBy] int not null,\n\t[lastEdit] int DEFAULT 0 not null,\n\t[lastEditBy] int DEFAULT 0 not null,\n\t[ip] nvarchar (200) DEFAULT '' not null,\n\tprimary key([rid])\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks.sql",
    "content": "CREATE TABLE [viewchunks] (\n\t[count] int DEFAULT 0 not null,\n\t[avg] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[route] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks_agents.sql",
    "content": "CREATE TABLE [viewchunks_agents] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[browser] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks_forums.sql",
    "content": "CREATE TABLE [viewchunks_forums] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[forum] int not null\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks_langs.sql",
    "content": "CREATE TABLE [viewchunks_langs] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[lang] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks_referrers.sql",
    "content": "CREATE TABLE [viewchunks_referrers] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[domain] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_viewchunks_systems.sql",
    "content": "CREATE TABLE [viewchunks_systems] (\n\t[count] int DEFAULT 0 not null,\n\t[createdAt] datetime not null,\n\t[system] nvarchar (200) not null\n);"
  },
  {
    "path": "schema/mssql/query_widgets.sql",
    "content": "CREATE TABLE [widgets] (\n\t[wid] int not null IDENTITY,\n\t[position] int not null,\n\t[side] nvarchar (100) not null,\n\t[type] nvarchar (100) not null,\n\t[active] bit DEFAULT 0 not null,\n\t[location] nvarchar (100) not null,\n\t[data] nvarchar (MAX) DEFAULT '' not null,\n\tprimary key([wid])\n);"
  },
  {
    "path": "schema/mssql/query_word_filters.sql",
    "content": "CREATE TABLE [word_filters] (\n\t[wfid] int not null IDENTITY,\n\t[find] nvarchar (200) not null,\n\t[replacement] nvarchar (200) not null,\n\tprimary key([wfid])\n);"
  },
  {
    "path": "schema/mysql/inserts.sql",
    "content": "ALTER TABLE `topics` ADD INDEX `i_parentID` (`parentID`);;\nALTER TABLE `replies` ADD INDEX `i_tid` (`tid`);;\nALTER TABLE `polls` ADD INDEX `i_parentID` (`parentID`);;\nALTER TABLE `likes` ADD INDEX `i_targetItem` (`targetItem`);;\nALTER TABLE `emails` ADD INDEX `i_uid` (`uid`);;\nALTER TABLE `attachments` ADD INDEX `i_originID` (`originID`);;\nALTER TABLE `attachments` ADD INDEX `i_path` (`path`);;\nALTER TABLE `activity_stream_matches` ADD INDEX `i_watcher` (`watcher`);;\nINSERT INTO`sync`(`last_update`)VALUES(UTC_TIMESTAMP());\nINSERT INTO`settings`(`name`,`content`,`type`,`constraints`)VALUES('activation_type','1','list','1-3');\nINSERT INTO`settings`(`name`,`content`,`type`)VALUES('bigpost_min_words','250','int');\nINSERT INTO`settings`(`name`,`content`,`type`)VALUES('megapost_min_words','1000','int');\nINSERT INTO`settings`(`name`,`content`,`type`)VALUES('meta_desc','','html-attribute');\nINSERT INTO`settings`(`name`,`content`,`type`)VALUES('rapid_loading','1','bool');\nINSERT INTO`settings`(`name`,`content`,`type`)VALUES('google_site_verify','','html-attribute');\nINSERT INTO`settings`(`name`,`content`,`type`,`constraints`)VALUES('avatar_visibility','0','list','0-1');\nINSERT INTO`themes`(`uname`,`default`)VALUES('cosora',1);\nINSERT INTO`emails`(`email`,`uid`,`validated`)VALUES('admin@localhost',1,1);\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Administrator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserEmail\":true,\"EditUserPassword\":true,\"EditUserGroup\":true,\"EditUserGroupSuperMod\":true,\"EditGroup\":true,\"EditGroupLocalPerms\":true,\"EditGroupGlobalPerms\":true,\"EditGroupSuperMod\":true,\"ManageForums\":true,\"EditSettings\":true,\"ManageThemes\":true,\"ManagePlugins\":true,\"ViewAdminLogs\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,1,0,'Admin');\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Moderator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserGroup\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,0,0,'Mod');\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Member','{\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"CreateReply\":true}','{}',0,0,0,\"\");\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Banned','{\"ViewTopic\":true}','{}',0,0,1,\"\");\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Awaiting Activation','{\"UseConvosOnlyWithMod\":true,\"ViewTopic\":true}','{}',0,0,0,\"\");\nINSERT INTO`users_groups`(`name`,`permissions`,`plugin_perms`,`is_mod`,`is_admin`,`is_banned`,`tag`)VALUES('Not Loggedin','{\"ViewTopic\":true}','{}',0,0,0,'Guest');\nINSERT INTO`forums`(`name`,`active`,`desc`,`tmpl`)VALUES('Reports',0,'All the reports go here','');\nINSERT INTO`forums`(`name`,`lastTopicID`,`lastReplyerID`,`desc`,`tmpl`)VALUES('General',1,1,'A place for general discussions which don''t fit elsewhere','');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(1,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"PinTopic\":true,\"CloseTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(2,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CloseTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(3,1,'{}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(4,1,'{}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(5,1,'{}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(6,1,'{}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(1,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(2,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(3,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(4,2,'{\"ViewTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(5,2,'{\"ViewTopic\":true}');\nINSERT INTO`forums_permissions`(`gid`,`fid`,`permissions`)VALUES(6,2,'{\"ViewTopic\":true}');\nINSERT INTO`topics`(`title`,`content`,`parsed_content`,`createdAt`,`lastReplyAt`,`lastReplyBy`,`createdBy`,`parentID`,`ip`)VALUES('Test Topic','A topic automatically generated by the software.','A topic automatically generated by the software.',UTC_TIMESTAMP(),UTC_TIMESTAMP(),1,1,2,'');\nINSERT INTO`replies`(`tid`,`content`,`parsed_content`,`createdAt`,`createdBy`,`lastUpdated`,`lastEdit`,`lastEditBy`,`ip`)VALUES(1,'A reply!','A reply!',UTC_TIMESTAMP(),1,UTC_TIMESTAMP(),0,0,'');\nINSERT INTO`menus`()VALUES();\nINSERT INTO`menu_items`(`mid`,`name`,`htmlID`,`position`,`path`,`aria`,`tooltip`,`order`)VALUES(1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0);\nINSERT INTO`menu_items`(`mid`,`name`,`htmlID`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`order`)VALUES(1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1);\nINSERT INTO`menu_items`(`mid`,`htmlID`,`cssClass`,`position`,`tmplName`,`order`)VALUES(1,'general_alerts','menu_alerts','right','menu_alerts',2);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`)VALUES(1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`)VALUES(1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`staffOnly`,`order`)VALUES(1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`memberOnly`,`order`)VALUES(1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?s={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`guestOnly`,`order`)VALUES(1,'{lang.menu_register}','menu_register','left','/accounts/create/','{lang.menu_register_aria}','{lang.menu_register_tooltip}',1,7);\nINSERT INTO`menu_items`(`mid`,`name`,`cssClass`,`position`,`path`,`aria`,`tooltip`,`guestOnly`,`order`)VALUES(1,'{lang.menu_login}','menu_login','left','/accounts/login/','{lang.menu_login_aria}','{lang.menu_login_tooltip}',1,8);\n"
  },
  {
    "path": "schema/mysql/query_activity_stream.sql",
    "content": "CREATE TABLE `activity_stream`(\n\t`asid` int not null AUTO_INCREMENT,\n\t`actor` int not null,\n\t`targetUser` int not null,\n\t`event` varchar(50) not null,\n\t`elementType` varchar(50) not null,\n\t`elementID` int not null,\n\t`createdAt` datetime not null,\n\t`extra` varchar(200) DEFAULT '' not null,\n\tprimary key(`asid`)\n);"
  },
  {
    "path": "schema/mysql/query_activity_stream_matches.sql",
    "content": "CREATE TABLE `activity_stream_matches`(\n\t`watcher` int not null,\n\t`asid` int not null,\n\tforeign key(`asid`) REFERENCES `activity_stream`(`asid`) ON DELETE CASCADE\n);"
  },
  {
    "path": "schema/mysql/query_activity_subscriptions.sql",
    "content": "CREATE TABLE `activity_subscriptions`(\n\t`user` int not null,\n\t`targetID` int not null,\n\t`targetType` varchar(50) not null,\n\t`level` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mysql/query_administration_logs.sql",
    "content": "CREATE TABLE `administration_logs`(\n\t`action` varchar(100) not null,\n\t`elementID` int not null,\n\t`elementType` varchar(100) not null,\n\t`ipaddress` varchar(200) not null,\n\t`actorID` int not null,\n\t`doneAt` datetime not null,\n\t`extra` text not null\n);"
  },
  {
    "path": "schema/mysql/query_attachments.sql",
    "content": "CREATE TABLE `attachments`(\n\t`attachID` int not null AUTO_INCREMENT,\n\t`sectionID` int DEFAULT 0 not null,\n\t`sectionTable` varchar(200) DEFAULT 'forums' not null,\n\t`originID` int not null,\n\t`originTable` varchar(200) DEFAULT 'replies' not null,\n\t`uploadedBy` int not null,\n\t`path` varchar(200) not null,\n\t`extra` varchar(200) not null,\n\tprimary key(`attachID`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_conversations.sql",
    "content": "CREATE TABLE `conversations`(\n\t`cid` int not null AUTO_INCREMENT,\n\t`createdBy` int not null,\n\t`createdAt` datetime not null,\n\t`lastReplyAt` datetime not null,\n\t`lastReplyBy` int not null,\n\tprimary key(`cid`)\n);"
  },
  {
    "path": "schema/mysql/query_conversations_participants.sql",
    "content": "CREATE TABLE `conversations_participants`(\n\t`uid` int not null,\n\t`cid` int not null\n);"
  },
  {
    "path": "schema/mysql/query_conversations_posts.sql",
    "content": "CREATE TABLE `conversations_posts`(\n\t`pid` int not null AUTO_INCREMENT,\n\t`cid` int not null,\n\t`createdBy` int not null,\n\t`body` varchar(50) not null,\n\t`post` varchar(50) DEFAULT '' not null,\n\tprimary key(`pid`)\n);"
  },
  {
    "path": "schema/mysql/query_emails.sql",
    "content": "CREATE TABLE `emails`(\n\t`email` varchar(200) not null,\n\t`uid` int not null,\n\t`validated` boolean DEFAULT 0 not null,\n\t`token` varchar(200) DEFAULT '' not null\n);"
  },
  {
    "path": "schema/mysql/query_forums.sql",
    "content": "CREATE TABLE `forums`(\n\t`fid` int not null AUTO_INCREMENT,\n\t`name` varchar(100) not null,\n\t`desc` varchar(200) not null,\n\t`tmpl` varchar(200) DEFAULT '' not null,\n\t`active` boolean DEFAULT 1 not null,\n\t`order` int DEFAULT 0 not null,\n\t`topicCount` int DEFAULT 0 not null,\n\t`preset` varchar(100) DEFAULT '' not null,\n\t`parentID` int DEFAULT 0 not null,\n\t`parentType` varchar(50) DEFAULT '' not null,\n\t`lastTopicID` int DEFAULT 0 not null,\n\t`lastReplyerID` int DEFAULT 0 not null,\n\tprimary key(`fid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_forums_actions.sql",
    "content": "CREATE TABLE `forums_actions`(\n\t`faid` int not null AUTO_INCREMENT,\n\t`fid` int not null,\n\t`runOnTopicCreation` boolean DEFAULT 0 not null,\n\t`runDaysAfterTopicCreation` int DEFAULT 0 not null,\n\t`runDaysAfterTopicLastReply` int DEFAULT 0 not null,\n\t`action` varchar(50) not null,\n\t`extra` varchar(200) DEFAULT '' not null,\n\tprimary key(`faid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_forums_permissions.sql",
    "content": "CREATE TABLE `forums_permissions`(\n\t`fid` int not null,\n\t`gid` int not null,\n\t`preset` varchar(100) DEFAULT '' not null,\n\t`permissions` text not null,\n\tprimary key(`fid`,`gid`)\n);"
  },
  {
    "path": "schema/mysql/query_likes.sql",
    "content": "CREATE TABLE `likes`(\n\t`weight` tinyint DEFAULT 1 not null,\n\t`targetItem` int not null,\n\t`targetType` varchar(50) DEFAULT 'replies' not null,\n\t`sentBy` int not null,\n\t`createdAt` datetime not null,\n\t`recalc` tinyint DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mysql/query_login_logs.sql",
    "content": "CREATE TABLE `login_logs`(\n\t`lid` int not null AUTO_INCREMENT,\n\t`uid` int not null,\n\t`success` boolean DEFAULT 0 not null,\n\t`ipaddress` varchar(200) not null,\n\t`doneAt` datetime not null,\n\tprimary key(`lid`)\n);"
  },
  {
    "path": "schema/mysql/query_memchunks.sql",
    "content": "CREATE TABLE `memchunks`(\n\t`count` int DEFAULT 0 not null,\n\t`stack` int DEFAULT 0 not null,\n\t`heap` int DEFAULT 0 not null,\n\t`createdAt` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_menu_items.sql",
    "content": "CREATE TABLE `menu_items`(\n\t`miid` int not null AUTO_INCREMENT,\n\t`mid` int not null,\n\t`name` varchar(200) DEFAULT '' not null,\n\t`htmlID` varchar(200) DEFAULT '' not null,\n\t`cssClass` varchar(200) DEFAULT '' not null,\n\t`position` varchar(100) not null,\n\t`path` varchar(200) DEFAULT '' not null,\n\t`aria` varchar(200) DEFAULT '' not null,\n\t`tooltip` varchar(200) DEFAULT '' not null,\n\t`tmplName` varchar(200) DEFAULT '' not null,\n\t`order` int DEFAULT 0 not null,\n\t`guestOnly` boolean DEFAULT 0 not null,\n\t`memberOnly` boolean DEFAULT 0 not null,\n\t`staffOnly` boolean DEFAULT 0 not null,\n\t`adminOnly` boolean DEFAULT 0 not null,\n\tprimary key(`miid`)\n);"
  },
  {
    "path": "schema/mysql/query_menus.sql",
    "content": "CREATE TABLE `menus`(\n\t`mid` int not null AUTO_INCREMENT,\n\tprimary key(`mid`)\n);"
  },
  {
    "path": "schema/mysql/query_meta.sql",
    "content": "CREATE TABLE `meta`(\n\t`name` varchar(200) not null,\n\t`value` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_moderation_logs.sql",
    "content": "CREATE TABLE `moderation_logs`(\n\t`action` varchar(100) not null,\n\t`elementID` int not null,\n\t`elementType` varchar(100) not null,\n\t`ipaddress` varchar(200) not null,\n\t`actorID` int not null,\n\t`doneAt` datetime not null,\n\t`extra` text not null\n);"
  },
  {
    "path": "schema/mysql/query_pages.sql",
    "content": "CREATE TABLE `pages`(\n\t`pid` int not null AUTO_INCREMENT,\n\t`name` varchar(200) not null,\n\t`title` varchar(200) not null,\n\t`body` text not null,\n\t`allowedGroups` text not null,\n\t`menuID` int DEFAULT -1 not null,\n\tprimary key(`pid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_password_resets.sql",
    "content": "CREATE TABLE `password_resets`(\n\t`email` varchar(200) not null,\n\t`uid` int not null,\n\t`validated` varchar(200) not null,\n\t`token` varchar(200) not null,\n\t`createdAt` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_perfchunks.sql",
    "content": "CREATE TABLE `perfchunks`(\n\t`low` int DEFAULT 0 not null,\n\t`high` int DEFAULT 0 not null,\n\t`avg` int DEFAULT 0 not null,\n\t`createdAt` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_plugins.sql",
    "content": "CREATE TABLE `plugins`(\n\t`uname` varchar(180) not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`installed` boolean DEFAULT 0 not null,\n\tunique(`uname`)\n);"
  },
  {
    "path": "schema/mysql/query_polls.sql",
    "content": "CREATE TABLE `polls`(\n\t`pollID` int not null AUTO_INCREMENT,\n\t`parentID` int DEFAULT 0 not null,\n\t`parentTable` varchar(100) DEFAULT 'topics' not null,\n\t`type` int DEFAULT 0 not null,\n\t`options` text not null,\n\t`votes` int DEFAULT 0 not null,\n\tprimary key(`pollID`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_polls_options.sql",
    "content": "CREATE TABLE `polls_options`(\n\t`pollID` int not null,\n\t`option` int DEFAULT 0 not null,\n\t`votes` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mysql/query_polls_voters.sql",
    "content": "CREATE TABLE `polls_voters` (\n\t`pollID` int not null,\n\t`uid` int not null,\n\t`option` int DEFAULT 0 not null\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_polls_votes.sql",
    "content": "CREATE TABLE `polls_votes`(\n\t`pollID` int not null,\n\t`uid` int not null,\n\t`option` int DEFAULT 0 not null,\n\t`castAt` datetime not null,\n\t`ip` varchar(200) DEFAULT '' not null\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_postchunks.sql",
    "content": "CREATE TABLE `postchunks`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_registration_logs.sql",
    "content": "CREATE TABLE `registration_logs`(\n\t`rlid` int not null AUTO_INCREMENT,\n\t`username` varchar(100) not null,\n\t`email` varchar(100) not null,\n\t`failureReason` varchar(100) not null,\n\t`success` boolean DEFAULT 0 not null,\n\t`ipaddress` varchar(200) not null,\n\t`doneAt` datetime not null,\n\tprimary key(`rlid`)\n);"
  },
  {
    "path": "schema/mysql/query_replies.sql",
    "content": "CREATE TABLE `replies`(\n\t`rid` int not null AUTO_INCREMENT,\n\t`tid` int not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` datetime not null,\n\t`createdBy` int not null,\n\t`lastEdit` int DEFAULT 0 not null,\n\t`lastEditBy` int DEFAULT 0 not null,\n\t`lastUpdated` datetime not null,\n\t`ip` varchar(200) DEFAULT '' not null,\n\t`likeCount` int DEFAULT 0 not null,\n\t`attachCount` int DEFAULT 0 not null,\n\t`words` int DEFAULT 1 not null,\n\t`actionType` varchar(20) DEFAULT '' not null,\n\t`poll` int DEFAULT 0 not null,\n\tprimary key(`rid`),\n\tfulltext key(`content`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_revisions.sql",
    "content": "CREATE TABLE `revisions`(\n\t`reviseID` int not null AUTO_INCREMENT,\n\t`content` text not null,\n\t`contentID` int not null,\n\t`contentType` varchar(100) DEFAULT 'replies' not null,\n\t`createdAt` datetime not null,\n\tprimary key(`reviseID`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_settings.sql",
    "content": "CREATE TABLE `settings`(\n\t`name` varchar(180) not null,\n\t`content` varchar(250) not null,\n\t`type` varchar(50) not null,\n\t`constraints` varchar(200) DEFAULT '' not null,\n\tunique(`name`)\n);"
  },
  {
    "path": "schema/mysql/query_sync.sql",
    "content": "CREATE TABLE `sync`(\n\t`last_update` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_themes.sql",
    "content": "CREATE TABLE `themes`(\n\t`uname` varchar(180) not null,\n\t`default` boolean DEFAULT 0 not null,\n\tunique(`uname`)\n);"
  },
  {
    "path": "schema/mysql/query_topicchunks.sql",
    "content": "CREATE TABLE `topicchunks`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null\n);"
  },
  {
    "path": "schema/mysql/query_topics.sql",
    "content": "CREATE TABLE `topics`(\n\t`tid` int not null AUTO_INCREMENT,\n\t`title` varchar(100) not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` datetime not null,\n\t`lastReplyAt` datetime not null,\n\t`lastReplyBy` int not null,\n\t`lastReplyID` int DEFAULT 0 not null,\n\t`createdBy` int not null,\n\t`is_closed` boolean DEFAULT 0 not null,\n\t`sticky` boolean DEFAULT 0 not null,\n\t`parentID` int DEFAULT 2 not null,\n\t`ip` varchar(200) DEFAULT '' not null,\n\t`postCount` int DEFAULT 1 not null,\n\t`likeCount` int DEFAULT 0 not null,\n\t`attachCount` int DEFAULT 0 not null,\n\t`words` int DEFAULT 0 not null,\n\t`views` int DEFAULT 0 not null,\n\t`weekEvenViews` int DEFAULT 0 not null,\n\t`weekOddViews` int DEFAULT 0 not null,\n\t`css_class` varchar(100) DEFAULT '' not null,\n\t`poll` int DEFAULT 0 not null,\n\t`data` varchar(200) DEFAULT '' not null,\n\tprimary key(`tid`),\n\tfulltext key(`title`),\n\tfulltext key(`content`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_updates.sql",
    "content": "CREATE TABLE `updates`(\n\t`dbVersion` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/mysql/query_users.sql",
    "content": "CREATE TABLE `users`(\n\t`uid` int not null AUTO_INCREMENT,\n\t`name` varchar(100) not null,\n\t`password` varchar(100) not null,\n\t`salt` varchar(80) DEFAULT '' not null,\n\t`group` int not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`is_super_admin` boolean DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`lastActiveAt` datetime not null,\n\t`session` varchar(200) DEFAULT '' not null,\n\t`last_ip` varchar(200) DEFAULT '' not null,\n\t`profile_comments` int DEFAULT 0 not null,\n\t`who_can_convo` int DEFAULT 0 not null,\n\t`enable_embeds` int DEFAULT -1 not null,\n\t`email` varchar(200) DEFAULT '' not null,\n\t`avatar` varchar(100) DEFAULT '' not null,\n\t`message` text not null,\n\t`url_prefix` varchar(20) DEFAULT '' not null,\n\t`url_name` varchar(100) DEFAULT '' not null,\n\t`level` smallint DEFAULT 0 not null,\n\t`score` int DEFAULT 0 not null,\n\t`posts` int DEFAULT 0 not null,\n\t`bigposts` int DEFAULT 0 not null,\n\t`megaposts` int DEFAULT 0 not null,\n\t`topics` int DEFAULT 0 not null,\n\t`liked` int DEFAULT 0 not null,\n\t`oldestItemLikedCreatedAt` datetime not null,\n\t`lastLiked` datetime not null,\n\t`temp_group` int DEFAULT 0 not null,\n\tprimary key(`uid`),\n\tunique(`name`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_users_2fa_keys.sql",
    "content": "CREATE TABLE `users_2fa_keys`(\n\t`uid` int not null,\n\t`secret` varchar(100) not null,\n\t`scratch1` varchar(50) not null,\n\t`scratch2` varchar(50) not null,\n\t`scratch3` varchar(50) not null,\n\t`scratch4` varchar(50) not null,\n\t`scratch5` varchar(50) not null,\n\t`scratch6` varchar(50) not null,\n\t`scratch7` varchar(50) not null,\n\t`scratch8` varchar(50) not null,\n\t`createdAt` datetime not null,\n\tprimary key(`uid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_users_avatar_queue.sql",
    "content": "CREATE TABLE `users_avatar_queue`(\n\t`uid` int not null,\n\tprimary key(`uid`)\n);"
  },
  {
    "path": "schema/mysql/query_users_blocks.sql",
    "content": "CREATE TABLE `users_blocks`(\n\t`blocker` int not null,\n\t`blockedUser` int not null\n);"
  },
  {
    "path": "schema/mysql/query_users_groups.sql",
    "content": "CREATE TABLE `users_groups`(\n\t`gid` int not null AUTO_INCREMENT,\n\t`name` varchar(100) not null,\n\t`permissions` text not null,\n\t`plugin_perms` text not null,\n\t`is_mod` boolean DEFAULT 0 not null,\n\t`is_admin` boolean DEFAULT 0 not null,\n\t`is_banned` boolean DEFAULT 0 not null,\n\t`user_count` int DEFAULT 0 not null,\n\t`tag` varchar(50) DEFAULT '' not null,\n\tprimary key(`gid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_users_groups_promotions.sql",
    "content": "CREATE TABLE `users_groups_promotions`(\n\t`pid` int not null AUTO_INCREMENT,\n\t`from_gid` int not null,\n\t`to_gid` int not null,\n\t`two_way` boolean DEFAULT 0 not null,\n\t`level` int not null,\n\t`posts` int DEFAULT 0 not null,\n\t`minTime` int not null,\n\t`registeredFor` int DEFAULT 0 not null,\n\tprimary key(`pid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_users_groups_scheduler.sql",
    "content": "CREATE TABLE `users_groups_scheduler`(\n\t`uid` int not null,\n\t`set_group` int not null,\n\t`issued_by` int not null,\n\t`issued_at` datetime not null,\n\t`revert_at` datetime not null,\n\t`temporary` boolean not null,\n\tprimary key(`uid`)\n);"
  },
  {
    "path": "schema/mysql/query_users_replies.sql",
    "content": "CREATE TABLE `users_replies`(\n\t`rid` int not null AUTO_INCREMENT,\n\t`uid` int not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` datetime not null,\n\t`createdBy` int not null,\n\t`lastEdit` int DEFAULT 0 not null,\n\t`lastEditBy` int DEFAULT 0 not null,\n\t`ip` varchar(200) DEFAULT '' not null,\n\tprimary key(`rid`)\n) CHARSET=utf8mb4 COLLATE utf8mb4_general_ci;"
  },
  {
    "path": "schema/mysql/query_viewchunks.sql",
    "content": "CREATE TABLE `viewchunks`(\n\t`count` int DEFAULT 0 not null,\n\t`avg` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`route` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_viewchunks_agents.sql",
    "content": "CREATE TABLE `viewchunks_agents`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`browser` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_viewchunks_forums.sql",
    "content": "CREATE TABLE `viewchunks_forums`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`forum` int not null\n);"
  },
  {
    "path": "schema/mysql/query_viewchunks_langs.sql",
    "content": "CREATE TABLE `viewchunks_langs`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`lang` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_viewchunks_referrers.sql",
    "content": "CREATE TABLE `viewchunks_referrers`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`domain` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_viewchunks_systems.sql",
    "content": "CREATE TABLE `viewchunks_systems`(\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` datetime not null,\n\t`system` varchar(200) not null\n);"
  },
  {
    "path": "schema/mysql/query_widgets.sql",
    "content": "CREATE TABLE `widgets`(\n\t`wid` int not null AUTO_INCREMENT,\n\t`position` int not null,\n\t`side` varchar(100) not null,\n\t`type` varchar(100) not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`location` varchar(100) not null,\n\t`data` text not null,\n\tprimary key(`wid`)\n);"
  },
  {
    "path": "schema/mysql/query_word_filters.sql",
    "content": "CREATE TABLE `word_filters`(\n\t`wfid` int not null AUTO_INCREMENT,\n\t`find` varchar(200) not null,\n\t`replacement` varchar(200) not null,\n\tprimary key(`wfid`)\n);"
  },
  {
    "path": "schema/pgsql/inserts.sql",
    "content": "INSERT INTO \"sync\"(\"last_update\") VALUES (UTC_TIMESTAMP());\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\",\"constraints\") VALUES ('activation_type','1','list','1-3');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\") VALUES ('bigpost_min_words','250','int');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\") VALUES ('megapost_min_words','1000','int');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\") VALUES ('meta_desc','','html-attribute');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\") VALUES ('rapid_loading','1','bool');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\") VALUES ('google_site_verify','','html-attribute');\nINSERT INTO \"settings\"(\"name\",\"content\",\"type\",\"constraints\") VALUES ('avatar_visibility','0','list','0-1');\nINSERT INTO \"themes\"(\"uname\",\"default\") VALUES ('cosora',1);\nINSERT INTO \"emails\"(\"email\",\"uid\",\"validated\") VALUES ('admin@localhost',1,1);\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Administrator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserEmail\":true,\"EditUserPassword\":true,\"EditUserGroup\":true,\"EditUserGroupSuperMod\":true,\"EditGroup\":true,\"EditGroupLocalPerms\":true,\"EditGroupGlobalPerms\":true,\"EditGroupSuperMod\":true,\"ManageForums\":true,\"EditSettings\":true,\"ManageThemes\":true,\"ManagePlugins\":true,\"ViewAdminLogs\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,1,0,'Admin');\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Moderator','{\"BanUsers\":true,\"ActivateUsers\":true,\"EditUser\":true,\"EditUserGroup\":true,\"ViewIPs\":true,\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"CreateReply\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}','{}',1,0,0,'Mod');\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Member','{\"UploadFiles\":true,\"UploadAvatars\":true,\"UseConvos\":true,\"UseConvosOnlyWithMod\":true,\"CreateProfileReply\":true,\"AutoEmbed\":true,\"AutoLink\":true,\"ViewTopic\":true,\"LikeItem\":true,\"CreateTopic\":true,\"CreateReply\":true}','{}',0,0,0,\"\");\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Banned','{\"ViewTopic\":true}','{}',0,0,1,\"\");\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Awaiting Activation','{\"UseConvosOnlyWithMod\":true,\"ViewTopic\":true}','{}',0,0,0,\"\");\nINSERT INTO \"users_groups\"(\"name\",\"permissions\",\"plugin_perms\",\"is_mod\",\"is_admin\",\"is_banned\",\"tag\") VALUES ('Not Loggedin','{\"ViewTopic\":true}','{}',0,0,0,'Guest');\nINSERT INTO \"forums\"(\"name\",\"active\",\"desc\",\"tmpl\") VALUES ('Reports',0,'All the reports go here','');\nINSERT INTO \"forums\"(\"name\",\"lastTopicID\",\"lastReplyerID\",\"desc\",\"tmpl\") VALUES ('General',1,1,'A place for general discussions which don''t fit elsewhere','');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (1,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"PinTopic\":true,\"CloseTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (2,1,'{\"ViewTopic\":true,\"CreateReply\":true,\"CloseTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (3,1,'{}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (4,1,'{}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (5,1,'{}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (6,1,'{}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (1,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (2,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true,\"EditTopic\":true,\"DeleteTopic\":true,\"EditReply\":true,\"DeleteReply\":true,\"PinTopic\":true,\"CloseTopic\":true,\"MoveTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (3,2,'{\"ViewTopic\":true,\"CreateReply\":true,\"CreateTopic\":true,\"LikeItem\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (4,2,'{\"ViewTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (5,2,'{\"ViewTopic\":true}');\nINSERT INTO \"forums_permissions\"(\"gid\",\"fid\",\"permissions\") VALUES (6,2,'{\"ViewTopic\":true}');\nINSERT INTO \"topics\"(\"title\",\"content\",\"parsed_content\",\"createdAt\",\"lastReplyAt\",\"lastReplyBy\",\"createdBy\",\"parentID\",\"ip\") VALUES ('Test Topic','A topic automatically generated by the software.','A topic automatically generated by the software.',UTC_TIMESTAMP(),UTC_TIMESTAMP(),1,1,2,'');\nINSERT INTO \"replies\"(\"tid\",\"content\",\"parsed_content\",\"createdAt\",\"createdBy\",\"lastUpdated\",\"lastEdit\",\"lastEditBy\",\"ip\") VALUES (1,'A reply!','A reply!',UTC_TIMESTAMP(),1,UTC_TIMESTAMP(),0,0,'');\nINSERT INTO \"menus\"() VALUES ();\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"htmlID\",\"position\",\"path\",\"aria\",\"tooltip\",\"order\") VALUES (1,'{lang.menu_forums}','menu_forums','left','/forums/','{lang.menu_forums_aria}','{lang.menu_forums_tooltip}',0);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"htmlID\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"order\") VALUES (1,'{lang.menu_topics}','menu_topics','menu_topics','left','/topics/','{lang.menu_topics_aria}','{lang.menu_topics_tooltip}',1);\nINSERT INTO \"menu_items\"(\"mid\",\"htmlID\",\"cssClass\",\"position\",\"tmplName\",\"order\") VALUES (1,'general_alerts','menu_alerts','right','menu_alerts',2);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"memberOnly\",\"order\") VALUES (1,'{lang.menu_account}','menu_account','left','/user/edit/','{lang.menu_account_aria}','{lang.menu_account_tooltip}',1,3);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"memberOnly\",\"order\") VALUES (1,'{lang.menu_profile}','menu_profile','left','{me.Link}','{lang.menu_profile_aria}','{lang.menu_profile_tooltip}',1,4);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"memberOnly\",\"staffOnly\",\"order\") VALUES (1,'{lang.menu_panel}','menu_panel menu_account','left','/panel/','{lang.menu_panel_aria}','{lang.menu_panel_tooltip}',1,1,5);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"memberOnly\",\"order\") VALUES (1,'{lang.menu_logout}','menu_logout','left','/accounts/logout/?s={me.Session}','{lang.menu_logout_aria}','{lang.menu_logout_tooltip}',1,6);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"guestOnly\",\"order\") VALUES (1,'{lang.menu_register}','menu_register','left','/accounts/create/','{lang.menu_register_aria}','{lang.menu_register_tooltip}',1,7);\nINSERT INTO \"menu_items\"(\"mid\",\"name\",\"cssClass\",\"position\",\"path\",\"aria\",\"tooltip\",\"guestOnly\",\"order\") VALUES (1,'{lang.menu_login}','menu_login','left','/accounts/login/','{lang.menu_login_aria}','{lang.menu_login_tooltip}',1,8);\n"
  },
  {
    "path": "schema/pgsql/query_activity_stream.sql",
    "content": "CREATE TABLE \"activity_stream\" (\n\t`asid` serial not null,\n\t`actor` int not null,\n\t`targetUser` int not null,\n\t`event` varchar (50) not null,\n\t`elementType` varchar (50) not null,\n\t`elementID` int not null,\n\t`createdAt` timestamp not null,\n\t`extra` varchar (200) DEFAULT '' not null,\n\tprimary key(`asid`)\n);"
  },
  {
    "path": "schema/pgsql/query_activity_stream_matches.sql",
    "content": "CREATE TABLE \"activity_stream_matches\" (\n\t`watcher` int not null,\n\t`asid` int not null,\n\tforeign key(`asid`,`asid`)\n);"
  },
  {
    "path": "schema/pgsql/query_activity_subscriptions.sql",
    "content": "CREATE TABLE \"activity_subscriptions\" (\n\t`user` int not null,\n\t`targetID` int not null,\n\t`targetType` varchar (50) not null,\n\t`level` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/pgsql/query_administration_logs.sql",
    "content": "CREATE TABLE \"administration_logs\" (\n\t`action` varchar (100) not null,\n\t`elementID` int not null,\n\t`elementType` varchar (100) not null,\n\t`ipaddress` varchar (200) not null,\n\t`actorID` int not null,\n\t`doneAt` timestamp not null,\n\t`extra` text not null\n);"
  },
  {
    "path": "schema/pgsql/query_attachments.sql",
    "content": "CREATE TABLE \"attachments\" (\n\t`attachID` serial not null,\n\t`sectionID` int DEFAULT 0 not null,\n\t`sectionTable` varchar (200) DEFAULT 'forums' not null,\n\t`originID` int not null,\n\t`originTable` varchar (200) DEFAULT 'replies' not null,\n\t`uploadedBy` int not null,\n\t`path` varchar (200) not null,\n\t`extra` varchar (200) not null,\n\tprimary key(`attachID`)\n);"
  },
  {
    "path": "schema/pgsql/query_conversations.sql",
    "content": "CREATE TABLE \"conversations\" (\n\t`cid` serial not null,\n\t`createdBy` int not null,\n\t`createdAt` timestamp not null,\n\t`lastReplyAt` timestamp not null,\n\t`lastReplyBy` int not null,\n\tprimary key(`cid`)\n);"
  },
  {
    "path": "schema/pgsql/query_conversations_participants.sql",
    "content": "CREATE TABLE \"conversations_participants\" (\n\t`uid` int not null,\n\t`cid` int not null\n);"
  },
  {
    "path": "schema/pgsql/query_conversations_posts.sql",
    "content": "CREATE TABLE \"conversations_posts\" (\n\t`pid` serial not null,\n\t`cid` int not null,\n\t`createdBy` int not null,\n\t`body` varchar (50) not null,\n\t`post` varchar (50) DEFAULT '' not null,\n\tprimary key(`pid`)\n);"
  },
  {
    "path": "schema/pgsql/query_emails.sql",
    "content": "CREATE TABLE \"emails\" (\n\t`email` varchar (200) not null,\n\t`uid` int not null,\n\t`validated` boolean DEFAULT 0 not null,\n\t`token` varchar (200) DEFAULT '' not null\n);"
  },
  {
    "path": "schema/pgsql/query_forums.sql",
    "content": "CREATE TABLE \"forums\" (\n\t`fid` serial not null,\n\t`name` varchar (100) not null,\n\t`desc` varchar (200) not null,\n\t`tmpl` varchar (200) DEFAULT '' not null,\n\t`active` boolean DEFAULT 1 not null,\n\t`order` int DEFAULT 0 not null,\n\t`topicCount` int DEFAULT 0 not null,\n\t`preset` varchar (100) DEFAULT '' not null,\n\t`parentID` int DEFAULT 0 not null,\n\t`parentType` varchar (50) DEFAULT '' not null,\n\t`lastTopicID` int DEFAULT 0 not null,\n\t`lastReplyerID` int DEFAULT 0 not null,\n\tprimary key(`fid`)\n);"
  },
  {
    "path": "schema/pgsql/query_forums_actions.sql",
    "content": "CREATE TABLE \"forums_actions\" (\n\t`faid` serial not null,\n\t`fid` int not null,\n\t`runOnTopicCreation` boolean DEFAULT 0 not null,\n\t`runDaysAfterTopicCreation` int DEFAULT 0 not null,\n\t`runDaysAfterTopicLastReply` int DEFAULT 0 not null,\n\t`action` varchar (50) not null,\n\t`extra` varchar (200) DEFAULT '' not null,\n\tprimary key(`faid`)\n);"
  },
  {
    "path": "schema/pgsql/query_forums_permissions.sql",
    "content": "CREATE TABLE \"forums_permissions\" (\n\t`fid` int not null,\n\t`gid` int not null,\n\t`preset` varchar (100) DEFAULT '' not null,\n\t`permissions` text DEFAULT '{}' not null,\n\tprimary key(`fid`,`gid`)\n);"
  },
  {
    "path": "schema/pgsql/query_likes.sql",
    "content": "CREATE TABLE \"likes\" (\n\t`weight` tinyint DEFAULT 1 not null,\n\t`targetItem` int not null,\n\t`targetType` varchar (50) DEFAULT 'replies' not null,\n\t`sentBy` int not null,\n\t`createdAt` timestamp not null,\n\t`recalc` tinyint DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/pgsql/query_login_logs.sql",
    "content": "CREATE TABLE \"login_logs\" (\n\t`lid` serial not null,\n\t`uid` int not null,\n\t`success` boolean DEFAULT 0 not null,\n\t`ipaddress` varchar (200) not null,\n\t`doneAt` timestamp not null,\n\tprimary key(`lid`)\n);"
  },
  {
    "path": "schema/pgsql/query_memchunks.sql",
    "content": "CREATE TABLE \"memchunks\" (\n\t`count` int DEFAULT 0 not null,\n\t`stack` int DEFAULT 0 not null,\n\t`heap` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_menu_items.sql",
    "content": "CREATE TABLE \"menu_items\" (\n\t`miid` serial not null,\n\t`mid` int not null,\n\t`name` varchar (200) DEFAULT '' not null,\n\t`htmlID` varchar (200) DEFAULT '' not null,\n\t`cssClass` varchar (200) DEFAULT '' not null,\n\t`position` varchar (100) not null,\n\t`path` varchar (200) DEFAULT '' not null,\n\t`aria` varchar (200) DEFAULT '' not null,\n\t`tooltip` varchar (200) DEFAULT '' not null,\n\t`tmplName` varchar (200) DEFAULT '' not null,\n\t`order` int DEFAULT 0 not null,\n\t`guestOnly` boolean DEFAULT 0 not null,\n\t`memberOnly` boolean DEFAULT 0 not null,\n\t`staffOnly` boolean DEFAULT 0 not null,\n\t`adminOnly` boolean DEFAULT 0 not null,\n\tprimary key(`miid`)\n);"
  },
  {
    "path": "schema/pgsql/query_menus.sql",
    "content": "CREATE TABLE \"menus\" (\n\t`mid` serial not null,\n\tprimary key(`mid`)\n);"
  },
  {
    "path": "schema/pgsql/query_meta.sql",
    "content": "CREATE TABLE \"meta\" (\n\t`name` varchar (200) not null,\n\t`value` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_moderation_logs.sql",
    "content": "CREATE TABLE \"moderation_logs\" (\n\t`action` varchar (100) not null,\n\t`elementID` int not null,\n\t`elementType` varchar (100) not null,\n\t`ipaddress` varchar (200) not null,\n\t`actorID` int not null,\n\t`doneAt` timestamp not null,\n\t`extra` text not null\n);"
  },
  {
    "path": "schema/pgsql/query_pages.sql",
    "content": "CREATE TABLE \"pages\" (\n\t`pid` serial not null,\n\t`name` varchar (200) not null,\n\t`title` varchar (200) not null,\n\t`body` text not null,\n\t`allowedGroups` text not null,\n\t`menuID` int DEFAULT -1 not null,\n\tprimary key(`pid`)\n);"
  },
  {
    "path": "schema/pgsql/query_password_resets.sql",
    "content": "CREATE TABLE \"password_resets\" (\n\t`email` varchar (200) not null,\n\t`uid` int not null,\n\t`validated` varchar (200) not null,\n\t`token` varchar (200) not null,\n\t`createdAt` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_perfchunks.sql",
    "content": "CREATE TABLE \"perfchunks\" (\n\t`low` int DEFAULT 0 not null,\n\t`high` int DEFAULT 0 not null,\n\t`avg` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_plugins.sql",
    "content": "CREATE TABLE \"plugins\" (\n\t`uname` varchar (180) not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`installed` boolean DEFAULT 0 not null,\n\tunique(`uname`)\n);"
  },
  {
    "path": "schema/pgsql/query_polls.sql",
    "content": "CREATE TABLE \"polls\" (\n\t`pollID` serial not null,\n\t`parentID` int DEFAULT 0 not null,\n\t`parentTable` varchar (100) DEFAULT 'topics' not null,\n\t`type` int DEFAULT 0 not null,\n\t`options` json not null,\n\t`votes` int DEFAULT 0 not null,\n\tprimary key(`pollID`)\n);"
  },
  {
    "path": "schema/pgsql/query_polls_options.sql",
    "content": "CREATE TABLE \"polls_options\" (\n\t`pollID` int not null,\n\t`option` int DEFAULT 0 not null,\n\t`votes` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/pgsql/query_polls_votes.sql",
    "content": "CREATE TABLE \"polls_votes\" (\n\t`pollID` int not null,\n\t`uid` int not null,\n\t`option` int DEFAULT 0 not null,\n\t`castAt` timestamp not null,\n\t`ip` varchar (200) DEFAULT '' not null\n);"
  },
  {
    "path": "schema/pgsql/query_postchunks.sql",
    "content": "CREATE TABLE \"postchunks\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_registration_logs.sql",
    "content": "CREATE TABLE \"registration_logs\" (\n\t`rlid` serial not null,\n\t`username` varchar (100) not null,\n\t`email` varchar (100) not null,\n\t`failureReason` varchar (100) not null,\n\t`success` boolean DEFAULT 0 not null,\n\t`ipaddress` varchar (200) not null,\n\t`doneAt` timestamp not null,\n\tprimary key(`rlid`)\n);"
  },
  {
    "path": "schema/pgsql/query_replies.sql",
    "content": "CREATE TABLE \"replies\" (\n\t`rid` serial not null,\n\t`tid` int not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` timestamp not null,\n\t`createdBy` int not null,\n\t`lastEdit` int DEFAULT 0 not null,\n\t`lastEditBy` int DEFAULT 0 not null,\n\t`lastUpdated` timestamp not null,\n\t`ip` varchar (200) DEFAULT '' not null,\n\t`likeCount` int DEFAULT 0 not null,\n\t`attachCount` int DEFAULT 0 not null,\n\t`words` int DEFAULT 1 not null,\n\t`actionType` varchar (20) DEFAULT '' not null,\n\t`poll` int DEFAULT 0 not null,\n\tprimary key(`rid`),\n\tfulltext key(`content`)\n);"
  },
  {
    "path": "schema/pgsql/query_revisions.sql",
    "content": "CREATE TABLE \"revisions\" (\n\t`reviseID` serial not null,\n\t`content` text not null,\n\t`contentID` int not null,\n\t`contentType` varchar (100) DEFAULT 'replies' not null,\n\t`createdAt` timestamp not null,\n\tprimary key(`reviseID`)\n);"
  },
  {
    "path": "schema/pgsql/query_settings.sql",
    "content": "CREATE TABLE \"settings\" (\n\t`name` varchar (180) not null,\n\t`content` varchar (250) not null,\n\t`type` varchar (50) not null,\n\t`constraints` varchar (200) DEFAULT '' not null,\n\tunique(`name`)\n);"
  },
  {
    "path": "schema/pgsql/query_sync.sql",
    "content": "CREATE TABLE \"sync\" (\n\t`last_update` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_themes.sql",
    "content": "CREATE TABLE \"themes\" (\n\t`uname` varchar (180) not null,\n\t`default` boolean DEFAULT 0 not null,\n\tunique(`uname`)\n);"
  },
  {
    "path": "schema/pgsql/query_topicchunks.sql",
    "content": "CREATE TABLE \"topicchunks\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null\n);"
  },
  {
    "path": "schema/pgsql/query_topics.sql",
    "content": "CREATE TABLE \"topics\" (\n\t`tid` serial not null,\n\t`title` varchar (100) not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` timestamp not null,\n\t`lastReplyAt` timestamp not null,\n\t`lastReplyBy` int not null,\n\t`lastReplyID` int DEFAULT 0 not null,\n\t`createdBy` int not null,\n\t`is_closed` boolean DEFAULT 0 not null,\n\t`sticky` boolean DEFAULT 0 not null,\n\t`parentID` int DEFAULT 2 not null,\n\t`ip` varchar (200) DEFAULT '' not null,\n\t`postCount` int DEFAULT 1 not null,\n\t`likeCount` int DEFAULT 0 not null,\n\t`attachCount` int DEFAULT 0 not null,\n\t`words` int DEFAULT 0 not null,\n\t`views` int DEFAULT 0 not null,\n\t`weekEvenViews` int DEFAULT 0 not null,\n\t`weekOddViews` int DEFAULT 0 not null,\n\t`css_class` varchar (100) DEFAULT '' not null,\n\t`poll` int DEFAULT 0 not null,\n\t`data` varchar (200) DEFAULT '' not null,\n\tprimary key(`tid`),\n\tfulltext key(`title`),\n\tfulltext key(`content`)\n);"
  },
  {
    "path": "schema/pgsql/query_updates.sql",
    "content": "CREATE TABLE \"updates\" (\n\t`dbVersion` int DEFAULT 0 not null\n);"
  },
  {
    "path": "schema/pgsql/query_users.sql",
    "content": "CREATE TABLE \"users\" (\n\t`uid` serial not null,\n\t`name` varchar (100) not null,\n\t`password` varchar (100) not null,\n\t`salt` varchar (80) DEFAULT '' not null,\n\t`group` int not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`is_super_admin` boolean DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`lastActiveAt` timestamp not null,\n\t`session` varchar (200) DEFAULT '' not null,\n\t`last_ip` varchar (200) DEFAULT '' not null,\n\t`profile_comments` int DEFAULT 0 not null,\n\t`who_can_convo` int DEFAULT 0 not null,\n\t`enable_embeds` int DEFAULT -1 not null,\n\t`email` varchar (200) DEFAULT '' not null,\n\t`avatar` varchar (100) DEFAULT '' not null,\n\t`message` text not null,\n\t`url_prefix` varchar (20) DEFAULT '' not null,\n\t`url_name` varchar (100) DEFAULT '' not null,\n\t`level` smallint DEFAULT 0 not null,\n\t`score` int DEFAULT 0 not null,\n\t`posts` int DEFAULT 0 not null,\n\t`bigposts` int DEFAULT 0 not null,\n\t`megaposts` int DEFAULT 0 not null,\n\t`topics` int DEFAULT 0 not null,\n\t`liked` int DEFAULT 0 not null,\n\t`oldestItemLikedCreatedAt` timestamp not null,\n\t`lastLiked` timestamp not null,\n\t`temp_group` int DEFAULT 0 not null,\n\tprimary key(`uid`),\n\tunique(`name`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_2fa_keys.sql",
    "content": "CREATE TABLE \"users_2fa_keys\" (\n\t`uid` int not null,\n\t`secret` varchar (100) not null,\n\t`scratch1` varchar (50) not null,\n\t`scratch2` varchar (50) not null,\n\t`scratch3` varchar (50) not null,\n\t`scratch4` varchar (50) not null,\n\t`scratch5` varchar (50) not null,\n\t`scratch6` varchar (50) not null,\n\t`scratch7` varchar (50) not null,\n\t`scratch8` varchar (50) not null,\n\t`createdAt` timestamp not null,\n\tprimary key(`uid`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_avatar_queue.sql",
    "content": "CREATE TABLE \"users_avatar_queue\" (\n\t`uid` int not null,\n\tprimary key(`uid`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_blocks.sql",
    "content": "CREATE TABLE \"users_blocks\" (\n\t`blocker` int not null,\n\t`blockedUser` int not null\n);"
  },
  {
    "path": "schema/pgsql/query_users_groups.sql",
    "content": "CREATE TABLE \"users_groups\" (\n\t`gid` serial not null,\n\t`name` varchar (100) not null,\n\t`permissions` text not null,\n\t`plugin_perms` text not null,\n\t`is_mod` boolean DEFAULT 0 not null,\n\t`is_admin` boolean DEFAULT 0 not null,\n\t`is_banned` boolean DEFAULT 0 not null,\n\t`user_count` int DEFAULT 0 not null,\n\t`tag` varchar (50) DEFAULT '' not null,\n\tprimary key(`gid`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_groups_promotions.sql",
    "content": "CREATE TABLE \"users_groups_promotions\" (\n\t`pid` serial not null,\n\t`from_gid` int not null,\n\t`to_gid` int not null,\n\t`two_way` boolean DEFAULT 0 not null,\n\t`level` int not null,\n\t`posts` int DEFAULT 0 not null,\n\t`minTime` int not null,\n\t`registeredFor` int DEFAULT 0 not null,\n\tprimary key(`pid`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_groups_scheduler.sql",
    "content": "CREATE TABLE \"users_groups_scheduler\" (\n\t`uid` int not null,\n\t`set_group` int not null,\n\t`issued_by` int not null,\n\t`issued_at` timestamp not null,\n\t`revert_at` timestamp not null,\n\t`temporary` boolean not null,\n\tprimary key(`uid`)\n);"
  },
  {
    "path": "schema/pgsql/query_users_replies.sql",
    "content": "CREATE TABLE \"users_replies\" (\n\t`rid` serial not null,\n\t`uid` int not null,\n\t`content` text not null,\n\t`parsed_content` text not null,\n\t`createdAt` timestamp not null,\n\t`createdBy` int not null,\n\t`lastEdit` int DEFAULT 0 not null,\n\t`lastEditBy` int DEFAULT 0 not null,\n\t`ip` varchar (200) DEFAULT '' not null,\n\tprimary key(`rid`)\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks.sql",
    "content": "CREATE TABLE \"viewchunks\" (\n\t`count` int DEFAULT 0 not null,\n\t`avg` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`route` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks_agents.sql",
    "content": "CREATE TABLE \"viewchunks_agents\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`browser` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks_forums.sql",
    "content": "CREATE TABLE \"viewchunks_forums\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`forum` int not null\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks_langs.sql",
    "content": "CREATE TABLE \"viewchunks_langs\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`lang` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks_referrers.sql",
    "content": "CREATE TABLE \"viewchunks_referrers\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`domain` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_viewchunks_systems.sql",
    "content": "CREATE TABLE \"viewchunks_systems\" (\n\t`count` int DEFAULT 0 not null,\n\t`createdAt` timestamp not null,\n\t`system` varchar (200) not null\n);"
  },
  {
    "path": "schema/pgsql/query_widgets.sql",
    "content": "CREATE TABLE \"widgets\" (\n\t`wid` serial not null,\n\t`position` int not null,\n\t`side` varchar (100) not null,\n\t`type` varchar (100) not null,\n\t`active` boolean DEFAULT 0 not null,\n\t`location` varchar (100) not null,\n\t`data` text not null,\n\tprimary key(`wid`)\n);"
  },
  {
    "path": "schema/pgsql/query_word_filters.sql",
    "content": "CREATE TABLE \"word_filters\" (\n\t`wfid` serial not null,\n\t`find` varchar (200) not null,\n\t`replacement` varchar (200) not null,\n\tprimary key(`wfid`)\n);"
  },
  {
    "path": "schema/schema.json",
    "content": "{\n\t\"DBVersion\":\"10\",\n\t\"DynamicFileVersion\":\"0\",\n\t\"MinGoVersion\":\"1.11\",\n\t\"MinVersion\":\"\"\n}"
  },
  {
    "path": "templates/account.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"account_{{.HTMLID}}\" class=\"colstack account\">\n\t{{template \"account_menu.html\" . }}\n\t<main class=\"colstack_right\">{{dyntmpl .TmplName .Inner .Header}}</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/account_blocked.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\"><h1>{{lang \"account_blocked_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item rowlist\">\n\t{{range .Users}}\n\t<div class=\"rowitem\">\n\t\t<a href=\"{{.Link}}\">{{.Name}}</a>\n\t\t<span class=\"to_right\"><a href=\"/user/block/remove/{{.ID}}\"><button>{{lang \"account_blocked_remove\"}}</button></a></span>\n\t\t<!--<div style=\"clear:both;\"></div>-->\n\t</div>\n\t{{else}}\n\t<div class=\"rowitem rowmsg\">\n\t\t<a>{{lang \"account_blocked_no_users\"}}</a>\n\t</div>\n\t{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/account_logins.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\"><h1>{{lang \"account_logins_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item rowlist loglist\">\n\t<!-- TODO: Do we need this inline CSS? -->\n\t{{range .ItemList}}\n\t<div class=\"rowitem{{if not .Success}} bg_red{{end}}\">\n\t\t<span class=\"to_left\">\n\t\t\t<span>{{if .Success}}{{lang \"account_logins_success\"}}{{else}}{{lang \"account_logins_failure\"}}\"{{end}}</span><br>\n\t\t\t<small title=\"{{.IP}}\">{{.IP}}</small>\n\t\t</span>\n\t\t<span class=\"to_right\">\n\t\t\t<span title=\"{{.DoneAt}}\">{{.DoneAt}}</span>\n\t\t</span>\n\t\t<div style=\"clear:both;\"></div>\n\t</div>\n\t{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/account_menu.html",
    "content": "<nav class=\"colstack_left\">\n\t<div class=\"colstack_item colstack_head rowhead menuhead\">\n\t\t<div class=\"rowitem\">\n\t\t\t<a href=\"/user/edit/\"><h1>{{lang \"account_menu_head\"}}</h1></a>\n\t\t\t</div>\n\t</div>\n\t<div class=\"colstack_item rowmenu\">\n\t\t<div class=\"rowitem passive\"><a href=\"/user/edit/password/\">{{lang \"account_menu_password\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/user/edit/email/\">{{lang \"account_menu_email\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/user/edit/privacy/\">{{lang \"account_menu_privacy\"}}</a></div>\n\t\t<!--<div class=\"rowitem passive\"><a href=\"/user/edit/notifications/\">{{lang \"account_menu_notifications\"}}</a> <span class=\"account_soon\">Coming Soon</span></div>-->\n\t\t<div class=\"rowitem passive\"><a href=\"/user/edit/logins/\">{{lang \"account_menu_logins\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/user/edit/blocked/\">{{lang \"account_menu_blocked\"}}</a></div>\n\t\t<!--<div class=\"rowitem passive\"><a href=\"/user/edit/penalties/\">{{lang \"account_menu_penalties\"}}</a></div>-->\n\t\t<div class=\"rowitem passive\"><a href=\"/user/convos/\">{{lang \"account_menu_messages\"}}</a></div>\n\t\t{{/** TODO: Add an alerts page with pagination to go through alerts which either don't fit in the alerts drop-down or which have already been dismissed. Bear in mind though that dismissed alerts older than two weeks might be purged to save space and to speed up the database **/}}\n\t</div>\n</nav>"
  },
  {
    "path": "templates/account_own_edit.html",
    "content": "<form id=\"avatar_form\"action=\"/user/edit/avatar/submit/?s={{.CurrentUser.Session}}\"method=\"post\"enctype=\"multipart/form-data\"></form>\n<div class=\"coldyn_block\">\n\t<div id=\"dash_left\" class=\"coldyn_item\">\n\t\t<div class=\"rowitem\">\n\t\t\t<span id=\"dash_username\">\n\t\t\t\t<form id=\"dash_username_form\"action=\"/user/edit/username/submit/?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t\t\t\t<form id=\"revoke_avatar_form\"action=\"/user/edit/avatar/revoke/submit/?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t\t\t\t<input form=\"dash_username_form\"name=\"new-name\"value=\"{{.CurrentUser.Name}}\">\n\t\t\t\t<button form=\"dash_username_form\"class=\"formbutton\">{{lang \"account_username_save\"}}</button>\n\t\t\t</span>\n\t\t\t<img src=\"{{.CurrentUser.Avatar}}\"height=\"128px\">\n\t\t\t<span id=\"dash_avatar_buttons\">\n\t\t\t\t{{if .CurrentUser.Perms.UploadAvatars}}\n\t\t\t\t<input form=\"avatar_form\" id=\"select_avatar\" name=\"account-avatar\" type=\"file\" required class=\"auto_hide\">\n\t\t\t\t<label for=\"select_avatar\" class=\"formbutton\">{{lang \"account_avatar_select\"}}</label>\n\t\t\t\t<button form=\"avatar_form\" name=\"account-button\" class=\"formbutton\">{{lang \"account_avatar_update_button\"}}</button>\n\t\t\t\t{{else if .CurrentUser.RawAvatar}}<button form=\"revoke_avatar_form\" id=\"revoke_avatars\" name=\"revoke-button\" class=\"formbutton\">{{lang \"account_avatar_revoke_button\"}}</button>{{end}}\n\t\t\t</span>\n\t\t</div>\n\t</div>\n\t<div id=\"dash_right\" class=\"coldyn_item\">\n\t\t<div class=\"rowitem\">{{if not .MFASetup}}<a href=\"/user/edit/mfa/setup/\">{{lang \"account_dash_2fa_setup\"}}</a>{{else}}<a href=\"/user/edit/mfa/\">{{lang \"account_dash_2fa_manage\"}}</a>{{end}} <span class=\"dash_security\">{{lang \"account_dash_security_notice\"}}</span></div>\n\t\t{{template \"account_own_edit_level.html\" .}}\n\t</div>\n</div>"
  },
  {
    "path": "templates/account_own_edit_email.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\"><h1>{{lang \"account_email_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem\">\n\t\t<a>{{.Email}}</a>\n\t\t<span class=\"to_right\">\n\t\t\t<span class=\"username\">{{if .Primary}}{{lang \"account_email_primary\"}}{{else}}{{lang \"account_email_secondary\"}}{{end}}</span>\n\t\t\t{{if .Validated}}<span class=\"username validated_email\">{{lang \"account_email_verified\"}}</span>{{else}}<a class=\"username invalid_email\">{{lang \"account_email_resend_email\"}}</a>{{end}}\n\t\t</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"account_email_none\"}}</div>{{end}}\n</div>"
  },
  {
    "path": "templates/account_own_edit_level.html",
    "content": "<div class=\"rowitem level_inprogress{{if eq .CurrentScore 0}} level_zero{{end}}\">\n\t<div class=\"levelBit\"{{if ne .CurrentScore 0}}style=\"width:{{.Percentage}}%;\"{{end}}>\n\t\t<a href=\"/user/levels/\">{{level .CurrentUser.Level}}</a>\n\t</div>\n\t<div class=\"progressWrap\"{{/**{{if ne .CurrentScore 0}}style=\"width:{{.Percentage}}%;\"{{end}}**/}}>\n\t\t<div>{{.CurrentScore}} / {{.NextScore}}</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/account_own_edit_mfa.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"account_edit_mfa\" class=\"colstack account\">\n\t{{template \"account_menu.html\" . }}\n\t<main class=\"colstack_right\">\n\t\t<div class=\"colstack_item colstack_head rowhead\">\n\t\t\t<div class=\"rowitem\"><h1>{{lang \"account_mfa_head\"}}</h1></div>\n\t\t</div>\n\t\t<div class=\"colstack_item the_form\">\n\t\t\t<form action=\"/user/edit/mfa/disable/submit/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_mfa_disable_explanation\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem\"><button class=\"formbutton\">{{lang \"account_mfa_disable_button\"}}</button></div>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t\t<div class=\"colstack_item colstack_head rowhead\">\n\t\t\t<div class=\"rowitem\"><h1>{{lang \"account_mfa_scratch_head\"}}</h1></div>\n\t\t</div>\n\t\t<div class=\"colstack_item\">{{/** TODO: Don't inline this, figure a way of implementing it properly in the template system **/}}\n\t\t\t<div class=\"rowitem rowmsg\" style=\"white-space:pre-wrap;\">{{lang \"account_mfa_scratch_explanation\"}}</div>\n\t\t</div>\n\t\t<div id=\"panel_mfa_scratches\" class=\"colstack_item rowlist\">\n\t\t\t{{range .Something}}<div class=\"rowitem\">{{.}}</div>{{end}}\n\t\t</div>\n\t</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/account_own_edit_mfa_setup.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"account_edit_mfa_setup\" class=\"colstack account\">\n\t{{template \"account_menu.html\" . }}\n\t<main class=\"colstack_right\">\n\t\t<div class=\"colstack_item colstack_head rowhead\">\n\t\t\t<div class=\"rowitem\"><h1>{{lang \"account_mfa_setup_head\"}}</h1></div>\n\t\t</div>\n\t\t<div class=\"colstack_item the_form\">\n\t\t\t<form action=\"/user/edit/mfa/setup/submit/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t\t\t<input name=\"code\"value=\"{{.Something}}\"type=\"hidden\">\n\t\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_mfa_setup_explanation\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem formlabel\">{{.Something}}</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_mfa_setup_verify\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem\"><input name=\"otp\" type=\"text\" autocomplete=\"off\" required></div> {{/** TODO: Make this a password? **/}}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow\">\n\t\t\t\t\t<div class=\"formitem\"><button name=\"account-button\" class=\"formbutton form_middle_button\">{{lang \"account_mfa_setup_button\"}}</button></div>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/account_own_edit_password.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"account_edit_password\" class=\"colstack account\">\n\t{{template \"account_menu.html\" .}}\n\t<main class=\"colstack_right\">\n\t\t<div class=\"colstack_item colstack_head rowhead\">\n\t\t\t<div class=\"rowitem\"><h1>{{lang \"account_password_head\"}}</h1></div>\n\t\t</div>\n\t\t<div class=\"colstack_item the_form\">\n\t\t\t<form action=\"/user/edit/password/submit/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_password_current_password\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem\"><input name=\"current-password\" type=\"password\" placeholder=\"*****\" autocomplete=\"current-password\" required></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_password_new_password\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem\"><input name=\"new-password\" type=\"password\" placeholder=\"*****\" autocomplete=\"new-password\" required></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow\">\n\t\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_password_confirm_password\"}}</a></div>\n\t\t\t\t\t<div class=\"formitem\"><input name=\"confirm-password\" type=\"password\" placeholder=\"*****\" autocomplete=\"new-password\" required></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow\">\n\t\t\t\t\t<div class=\"formitem\"><button name=\"account-button\" class=\"formbutton form_middle_button\">{{lang \"account_password_update_button\"}}</button></div>\n\t\t\t\t</div>\n\t\t\t</form>\n\t\t</div>\n\t</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/account_own_edit_privacy.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\"><h1>{{lang \"account_privacy_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/user/edit/privacy/submit/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<input name=\"o_profile_comments\" value=\"{{.ProfileComments}}\" type=\"hidden\">\n\t\t<!--<input name=\"o_receive_convos\" value=\"{{if .ReceiveConvos}}1{{else}}0{{end}}\" type=\"hidden\">-->\n\t\t<input name=\"o_enable_embeds\" value=\"{{if .EnableEmbeds}}1{{else}}0{{end}}\" type=\"hidden\">\n\t\t<div class=\"formrow real_first_child\">\n\t\t\t<div class=\"formitem formlabel\">{{lang \"account_privacy_profile_comments\"}}</div>\n\t\t\t<div class=\"formitem\"><select name=\"profile_comments\">\n\t\t\t\t<option{{if eq .ProfileComments 1}} selected{{end}} value=1>{{lang \"account_privacy_profile_comments_public\"}}</option>\n\t\t\t\t<option{{if eq .ProfileComments 2}} selected{{end}} value=2>{{lang \"account_privacy_profile_comments_registered\"}}</option>\n\t\t\t\t<option{{if eq .ProfileComments 4}} selected{{end}} value=4>{{lang \"account_privacy_profile_comments_self\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<!--<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>Receive Conversations</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"receive_convos\">\n\t\t\t\t<option{{if .ReceiveConvos}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t<option{{if not .ReceiveConvos}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t</select></div>\n\t\t</div>-->\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"account_privacy_enable_embeds\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"enable_embeds\">\n\t\t\t\t<option{{if .EnableEmbeds}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t<option{{if not .EnableEmbeds}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"account-button\" class=\"formbutton form_middle_button\">{{lang \"account_privacy_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/account_test.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"account_{{.Name}}\" class=\"colstack account\">\n\t{{template \"account_menu.html\" . }}\n\t<main class=\"colstack_right\">\n\t\t{{dyntmpl .Name }}\n\t</main>\n</div>\n{{template \"footer.html\" . }}\n"
  },
  {
    "path": "templates/alert.html",
    "content": "{{if .Avatar}}<div class='alertItem withAvatar'style='background-image:url(\"{{.Avatar}}\");'><img src='{{.Avatar}}'class='bgsub'><a class='text'data-asid='{{.ASID}}'href=\"{{.Path}}\">{{.Message}}</a></div>{{else}}<div class='alertItem'><a href=\"{{.Path}}\"class='text'>{{.Message}}</a></div>{{end}}"
  },
  {
    "path": "templates/are_you_sure.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"are_you_sure\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"areyousure_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock\">\n\t\t<div class=\"rowitem passive rowmsg\">{{.Something.Message}}<br><br>\n\t\t\t<a class=\"username\" href=\"{{.Something.URL}}?s={{.CurrentUser.Session}}\">{{lang \"areyousure_continue\"}}</a>\n\t\t</div>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/convo.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"convo_head\"}}</h1>\n\t</div>\n</div>\n<div class=\"colstack_item parti\">\n\t<div class=\"rowitem\">\n\t\t<div>{{lang \"convo_users\"}}:&nbsp;</div>\n\t\t{{range .Users}}<div class=\"parti_user\"><a href=\"{{.Link}}\">{{.Name}}</a></div>&nbsp;{{end}}\n\t</div>\n</div>\n<div class=\"colstack_item convo_row_box\">{{template \"convo_row.html\" .}}</div>\n{{if .CanReply}}\n<form action=\"/user/convo/create/submit/{{.Convo.ID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<div class=\"colstack_item topic_reply_form\"style=\"border-top:none;\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><textarea class=\"input_content\"name=\"content\"placeholder=\"{{lang \"profile.comments_form_content\"}}\"></textarea></div>\n\t\t</div>\n\t\t<div class=\"formrow quick_button_row\">\n\t\t\t<div class=\"formitem\"><button name=\"reply-button\"class=\"formbutton\">{{lang \"profile.comments_form_button\"}}</button></div>\n\t\t</div>\n\t</div>\n</form>\n{{end}}"
  },
  {
    "path": "templates/convo_row.html",
    "content": "{{/** TODO: Temporary hack until we find a more granular way of doing this. Perhaps, a custom include function? **/}}\n{{if .Header.Theme.BgAvatars}}\n{{range .Posts}}\n<div id=\"post-{{.ID}}\"class=\"rowitem passive deletable_block editable_parent simple {{.ClassName}}\"style=\"background-image:url({{.User.Avatar}}),url(/s/post-avatar-bg.jpg);background-position:0px {{if le .ContentLines 5}}-1{{end}}0px;\">\n\t<span class=\"editable_block user_content simple\">{{.Body}}</span>\n\t<span class=\"controls\">\n\t\t<a href=\"{{.User.Link}}\"class=\"real_username username\">{{.User.Name}}</a>&nbsp;&nbsp;\n\n\t\t{{if .CanModify}}<a href=\"/user/convo/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_edit_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_edit_aria\"}}\"><button class=\"username edit_item edit_label\"></button></a>\n\n\t\t<a href=\"/user/convo/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_delete_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_delete_aria\"}}\"><button class=\"username delete_item delete_label\"></button></a>{{end}}\n\n\t\t<a class=\"mod_button\"href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&type=convo-reply\"><button class=\"username report_item flag_label\"title=\"{{lang \"profile.comments_report_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_report_aria\"}}\"></button></a>\n\n\t\t{{if .User.Tag}}<a class=\"username hide_on_mobile user_tag\"style=\"float:right;\">{{.User.Tag}}</a>{{end}}\n\t</span>\n</div>\n{{end}}\n{{else}}\n{{template \"convo_row_alt.html\" . }}\n{{end}}"
  },
  {
    "path": "templates/convo_row_alt.html",
    "content": "{{range .Posts}}\n<div id=\"post-{{.ID}}\"class=\"rowitem passive deletable_block editable_parent comment {{.ClassName}}\">\n\t<div class=\"topRow\">\n\t\t<div class=\"userbit\">\n\t\t\t<img src=\"{{.User.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.User.Name}}'s Avatar\"aria-hidden=\"true\">\n\t\t\t<span class=\"nameAndTitle\">\n\t\t\t\t<a href=\"{{.User.Link}}\"class=\"real_username username\">{{.User.Name}}</a>\n\t\t\t\t{{if .User.Tag}}<a class=\"username hide_on_mobile user_tag\"style=\"float:right;\">{{.User.Tag}}</a>{{end}}\n\t\t\t</span>\n\t\t</div>\n\t\t<span class=\"controls\">\n\t\t\t{{if .CanModify}}\n\t\t\t<a href=\"/user/convo/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_edit_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_edit_aria\"}}\"><button class=\"username edit_item edit_label\"></button></a>\n\t\t\t\n\t\t\t<a href=\"/user/convo/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_delete_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_delete_aria\"}}\"><button class=\"username delete_item delete_label\"></button></a>\n\t\t\t{{end}}\n\n\t\t\t<a class=\"mod_button\"href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&type=convo-reply\"><button class=\"username report_item flag_label\"title=\"{{lang \"profile.comments_report_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_report_aria\"}}\"></button></a>\n\t\t</span>\n\t</div>\n\t<div class=\"content_column\">\n\t\t<span class=\"editable_block user_content\">{{.Body}}</span>\n\t</div>\n</div>\n<div class=\"after_comment\"></div>\n{{end}}"
  },
  {
    "path": "templates/convos.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"convos_head\"}}</h1>\n\t\t{{if .CurrentUser.Perms.UseConvos or .CurrentUser.Perms.UseConvosIfWithMod}}<h2><a class=\"create_convo_link\"href=\"/user/convos/create/\">{{lang \"convos_create\"}}</a></h2>{{end}}\n\t</div>\n</div>\n{{if .CurrentUser.Perms.UseConvos or .CurrentUser.Perms.UseConvosIfWithMod}}\n<div class=\"colstack_item the_form convo_create_form auto_hide\">\n\t<form action=\"/user/convos/create/submit/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t<div class=\"formrow real_first_child\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"create_convo_recp\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"recp\"type=\"text\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><textarea name=\"body\"></textarea></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<button name=\"panel-button\"class=\"formbutton\">{{lang \"create_convo_button\"}}</button>\n\t\t\t\t<button class=\"formbutton close_form\">{{lang \"quick_topic.cancel_button\"}}</button>\n\t\t\t</div>\n\t\t</div>\n\t</form>\n</div>\n{{end}}\n<div class=\"colstack_item convos_list rowlist\">\n\t{{range .Convos}}\n\t<div class=\"rowitem\">\n\t\t<span class=\"to_left\">\n\t\t\t{{if .OneOnOne}}{{range .ShortUsers}}<img class=\"bgsub\"src=\"{{.MicroAvatar}}\"height=48 width=48>{{end}}{{end}}\n\t\t\t<a href=\"/user/convo/{{.ID}}\">{{range .ShortUsers}}<span class=\"convos_item_user\">{{.Name}}</span>&nbsp;{{end}}</a></span></a>\n\t\t</span>\n\t\t<span title=\"{{abstime .LastReplyAt}}\"class=\"to_right\">{{reltime .LastReplyAt}}</span>\n\t\t<div style=\"clear:both;\"></div>\n\t</div>{{else}}\n\t<div class=\"rowitem\">{{lang \"convos_none\"}}</div>\n\t{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/create_convo.html",
    "content": "<div class=\"colstack_item colstack_head rowhead\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"create_convo_head\"}}</h1>\n\t</div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/user/convos/create/submit/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<div class=\"formrow real_first_child\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"create_convo_recp\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"recp\"type=\"text\"{{if .RecpName}}value=\"{{.RecpName}}\"{{end}}></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><textarea name=\"body\"></textarea></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton form_middle_button\">{{lang \"create_convo_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/create_topic.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"create_topic_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"create_topic_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form id=\"quick_post_form\" enctype=\"multipart/form-data\" action=\"/topic/create/submit/?s={{.CurrentUser.Session}}\" method=\"post\"></form>\n\t\t<div class=\"formrow real_first_child\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"create_topic_board\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select form=\"quick_post_form\" id=\"topic_board_input\" name=\"board\">\n\t\t\t\t{{range .ItemList}}<option{{if eq .ID $.FID}} selected{{end}} value=\"{{.ID}}\">{{.Name}}</option>{{end}}\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"create_topic_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input form=\"quick_post_form\" name=\"name\" type=\"text\" placeholder=\"{{lang \"create_topic_name\"}}\" required></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"create_topic_content\"}}</a></div>\n\t\t\t<div class=\"formitem\"><textarea form=\"quick_post_form\" class=\"large\" id=\"topic_content\" name=\"content\" placeholder=\"{{lang \"create_topic_placeholder\"}}\" required></textarea></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<button form=\"quick_post_form\" name=\"topic-button\" class=\"formbutton\">{{lang \"create_topic_create_button\"}}</button>\n\t\t\t{{if .CurrentUser.Perms.UploadFiles}}\n\t\t\t<input name=\"quick_topic_upload_files\" form=\"quick_post_form\" id=\"quick_topic_upload_files\" multiple type=\"file\" class=\"auto_hide\">\n\t\t\t<label for=\"quick_topic_upload_files\" class=\"formbutton add_file_button\">{{lang \"create_topic_add_file_button\"}}</label>{{end}}\n\t\t\t<div id=\"upload_file_dock\"></div>\n\t\t</div>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/custom_page.html",
    "content": "{{template \"header.html\" . }}\n<main>\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{.Page.Title}}</h1></div>\n\t</div>\n\t<div class=\"rowblock\">\n\t\t<div class=\"rowitem passive rowmsg\">{{.Page.Body}}</div>\n\t</div>\n</main>\n{{template \"footer.html\" . }}\n"
  },
  {
    "path": "templates/error.html",
    "content": "{{template \"header.html\" . }}\n<main>\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"error_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock\">\n\t\t<div class=\"rowitem passive rowmsg\">{{.Message}}</div>\n\t</div>\n</main>\n{{template \"footer.html\" . }}\n"
  },
  {
    "path": "templates/footer.html",
    "content": "\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<aside class=\"midRight sidebar\">{{dock \"rightSidebar\" .Header}}</aside>\n\t\t\t\t\t</div>\n<div class=\"footBlock\">\n<div class=\"footLeft\"></div>\n<div class=\"footer\">\n\t{{dock \"footer\" .Header}}\n\t<div id=\"poweredByHolder\"class=\"footerBit\">\n\t\t<div id=\"poweredBy\">\n\t\t\t<a id=\"poweredByName\"href=\"https://github.com/Azareal/Gosora\">{{lang \"footer_powered_by\"}}</a><span id=\"poweredByDash\"> - </span><span id=\"poweredByMaker\">{{lang \"footer_made_with_love\"}}</span>\n\t\t</div>\n\t\t{{if .CurrentUser.IsAdmin}}<div title=\"start to before tmpl\"class=\"elapsed\">{{.Header.Elapsed1}}</div><div title=\"start to footer\"class=\"elapsed\">{{elapsed .Header.StartedAt}}</div>{{end}}\n\t\t<form action=\"/theme/\"method=\"post\">\n\t\t\t<div id=\"themeSelector\">\n\t\t\t\t<select id=\"themeSelectorSelect\"name=\"theme\"aria-label=\"{{lang \"footer_theme_selector_aria\"}}\">{{range .Header.ThemesSlice}}{{if not .HideFromThemes}}\n\t\t\t\t\t<option value=\"{{.Name}}\"{{if eq $.Header.Theme.Name .Name}}selected{{end}}>{{.FriendlyName}}</option>\n\t\t\t\t{{end}}{{end}}</select>\n\t\t\t\t<noscript><input type=\"submit\"></noscript>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</div>\n<div class=\"footRight\"></div>\n</div>\n\t\t\t\t<div style=\"clear:both;\"></div>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>"
  },
  {
    "path": "templates/forum.html",
    "content": "{{template \"header.html\" . }}\n\n<main id=\"forumItemList\"itemscope itemtype=\"http://schema.org/ItemList\">\n\n{{if gt .Page 1}}<div id=\"prevFloat\"class=\"prev_button\"><a class=\"prev_link\"aria-label=\"{{lang \"paginator.prev_page_aria\"}}\" rel=\"prev\"href=\"{{.Forum.Link}}?page={{subtract .Page 1}}\">{{lang \"paginator.less_than\"}}</a></div>{{end}}\n{{if ne .LastPage .Page}}<div id=\"nextFloat\"class=\"next_button\"><a class=\"next_link\"aria-label=\"{{lang \"paginator.next_page_aria\"}}\" rel=\"next\"href=\"{{.Forum.Link}}?page={{add .Page 1}}\">{{lang \"paginator.greater_than\"}}</a></div>{{end}}\n{{if not .CurrentUser.Loggedin}}<link rel=\"canonical\"href=\"//{{.Site.URL}}{{.Forum.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}\">{{end}}\n\n\t<div id=\"forum_head_block\"class=\"rowblock rowhead topic_list_title_block{{if .CurrentUser.Loggedin}} has_opt{{end}}\">\n\t\t<div class=\"rowitem forum_title\">\n\t\t\t<h1 itemprop=\"name\">{{.Title}}</h1>\n\t\t</div>\n\t\t{{if .CurrentUser.Loggedin}}\n\t\t<div class=\"optbox\">\n\t\t\t{{if .CurrentUser.Perms.CreateTopic}}\n\t\t\t<div class=\"opt dummy_opt\"></div>\n\t\t\t<div class=\"pre_opt auto_hide\"></div>\n\t\t\t<div class=\"opt create_topic_opt\"title=\"{{lang \"topic_list.create_topic_tooltip\"}}\"aria-label=\"{{lang \"topic_list.create_topic_aria\"}}\"><a class=\"create_topic_link\"href=\"/topics/create/{{.Forum.ID}}\"></a></div>\n\t\t\t{{/** TODO: Add a permissions check for this **/}}\n\t\t\t<div class=\"opt mod_opt\"title=\"{{lang \"topic_list.moderate_tooltip\"}}\">\n\t\t\t\t<a class=\"moderate_link\"href=\"#\"aria-label=\"{{lang \"topic_list.moderate_aria\"}}\"></a>\n\t\t\t</div>\n\t\t\t{{else}}<div class=\"opt locked_opt\"title=\"{{lang \"forum_locked_tooltip\"}}\"aria-label=\"{{lang \"forum_locked_aria\"}}\"><a></a></div>{{end}}\n\t\t</div>\n\t\t<div style=\"clear:both;\"></div>\n\t{{end}}\n\t</div>\n\t{{if .CurrentUser.Loggedin}}\n\t{{template \"topics_mod_floater.html\" .}}\n\t\n\t{{if .CurrentUser.Perms.CreateTopic}}\n\t<div id=\"forum_topic_create_form\"class=\"rowblock topic_create_form quick_create_form auto_hide\"aria-label=\"{{lang \"quick_topic.aria\"}}\">\n\t\t<form id=\"quick_post_form\"enctype=\"multipart/form-data\"action=\"/topic/create/submit/?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t\t<img class=\"little_row_avatar\"src=\"{{.CurrentUser.MicroAvatar}}\"height=64 alt=\"{{lang \"quick_topic.avatar_alt\"}}\"title=\"{{lang \"quick_topic.avatar_tooltip\"}}\">\n\t\t<input form=\"quick_post_form\"id=\"topic_board_input\"name=\"board\"value=\"{{.Forum.ID}}\"type=\"hidden\">\n\t\t<div class=\"main_form\">\n\t\t\t<div class=\"topic_meta\">\n\t\t\t\t<div class=\"formrow topic_name_row real_first_child\">\n\t\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t\t<input form=\"quick_post_form\"name=\"name\"placeholder=\"{{lang \"quick_topic.whatsup\"}}\"required>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{{template \"topics_quick_topic.html\" . }}\n\t\t</div>\n\t</div>\n\t{{end}}\n\t{{end}}\n\t<div id=\"forum_topic_list\"class=\"rowblock topic_list single_forum\"aria-label=\"{{lang \"forum_list_aria\"}}\">\n\t\t{{range .ItemList}}<div class=\"topic_row{{if .Sticky}} topic_sticky{{else if .IsClosed}} topic_closed{{end}}{{if .CanMod}} can_mod{{end}}\"data-tid=\"{{.ID}}\">\n\t\t<div class=\"rowitem topic_left passive datarow\">\n\t\t\t<span class=\"selector\"></span>\n\t\t\t<a href=\"{{.Creator.Link}}\"><img src=\"{{.Creator.MicroAvatar}}\"height=64 alt=\"Avatar\"title=\"{{.Creator.Name}}'s Avatar\"aria-hidden=\"true\"></a>\n\t\t\t<span class=\"topic_inner_left\">\n\t\t\t\t<a class=\"rowtopic\"href=\"{{.Link}}\"itemprop=\"itemListElement\"title=\"{{.Title}}\"><span>{{.Title}}</span></a>\n\t\t\t\t<br><a class=\"rowsmall starter\"href=\"{{.Creator.Link}}\"title=\"{{.Creator.Name}}\">{{.Creator.Name}}</a>\n\t\t\t\t{{/** TODO: Avoid the double '|' when both .IsClosed and .Sticky are set to true. We could probably do this with CSS **/}}\n\t\t\t\t{{if .IsClosed}}<span class=\"rowsmall topic_status_e topic_status_closed\"title=\"{{lang \"status.closed_tooltip\"}}\"> | &#x1F512;&#xFE0E</span>{{end}}\n\t\t\t\t{{if .Sticky}}<span class=\"rowsmall topic_status_e topic_status_sticky\"title=\"{{lang \"status.pinned_tooltip\"}}\"> | &#x1F4CD;&#xFE0E</span>{{end}}\n\t\t\t</span>\n\t\t\t{{/** TODO: Phase this out of Cosora and remove it **/}}\n\t\t\t<div class=\"topic_inner_right rowsmall\">\n\t\t\t\t<span class=\"replyCount\">{{.PostCount}}</span><br>\n\t\t\t\t<span class=\"likeCount\">{{.LikeCount}}</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"topic_middle\">\n\t\t\t<div class=\"topic_middle_inside rowsmall\">\n\t\t\t\t<span class=\"replyCount\">{{.PostCount}}&nbsp;{{lang \"topic_list.replies_suffix\"}}</span>\n\t\t\t\t<span class=\"likeCount\">{{.LikeCount}}&nbsp;{{lang \"topic_list.likes_suffix\"}}</span>\n\t\t\t\t<span class=\"viewCount\">{{.ViewCount}}&nbsp;{{lang \"topic_list.views_suffix\"}}</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"rowitem topic_right passive datarow\">\n\t\t\t<div class=\"topic_right_inside\">\n\t\t\t\t<a href=\"{{.LastUser.Link}}\"><img src=\"{{.LastUser.MicroAvatar}}\"height=64 alt=\"Avatar\"title=\"{{.LastUser.Name}}'s Avatar\"aria-hidden=\"true\"></a>\n\t\t\t\t<span>\n\t\t\t\t\t<a href=\"{{.LastUser.Link}}\"class=\"lastName\"title=\"{{.LastUser.Name}}\">{{.LastUser.Name}}</a><br>\n\t\t\t\t\t<a href=\"{{.Link}}?page={{.LastPage}}{{if .LastReplyID}}#post-{{.LastReplyID}}{{end}}\"class=\"rowsmall lastReplyAt\"title=\"{{abstime .LastReplyAt}}\">{{reltime .LastReplyAt}}</a>\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t</div>\n\t\t</div>{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"forum_no_topics\"}}{{if .CurrentUser.Loggedin}}{{if .CurrentUser.Perms.CreateTopic}}&nbsp;<a href=\"/topics/create/{{.Forum.ID}}\">{{lang \"forum_start_one\"}}</a>{{end}}{{end}}</div>{{end}}\n\t</div>\n\n{{template \"paginator.html\" . }}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/forum_gallery.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"forumItemList\"itemscope itemtype=\"http://schema.org/ItemList\">\n<link rel=\"canonical\"href=\"//{{.Site.URL}}{{.Forum.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}\">\n\n\t<div id=\"forum_head_block\"class=\"rowblock rowhead topic_list_title_block{{if .CurrentUser.Loggedin}} has_opt{{end}}\">\n\t\t<div class=\"rowitem forum_title\">\n\t\t\t<h1 itemprop=\"name\">{{.Title}}</h1>\n\t\t</div>\n\t\t{{if .CurrentUser.Loggedin}}\n\t\t<div class=\"optbox\">\n\t\t\t{{if .CurrentUser.Perms.CreateTopic}}\n\t\t\t<div class=\"opt dummy_opt\"></div>\n\t\t\t<div class=\"pre_opt auto_hide\"></div>\n\t\t\t<div class=\"opt create_topic_opt\" title=\"{{lang \"topic_list.create_topic_tooltip\"}}\" aria-label=\"{{lang \"topic_list.create_topic_aria\"}}\"><a class=\"create_topic_link\" href=\"/topics/create/{{.Forum.ID}}\"></a></div>\n\t\t\t{{/** TODO: Add a permissions check for this **/}}\n\t\t\t<div class=\"opt mod_opt\" title=\"{{lang \"topic_list.moderate_tooltip\"}}\">\n\t\t\t\t<a class=\"moderate_link\" href=\"#\" aria-label=\"{{lang \"topic_list.moderate_aria\"}}\"></a>\n\t\t\t</div>\n\t\t\t{{else}}<div class=\"opt locked_opt\" title=\"{{lang \"forum_locked_tooltip\"}}\" aria-label=\"{{lang \"forum_locked_aria\"}}\"><a></a></div>{{end}}\n\t\t</div><div style=\"clear:both;\"></div>\n\t\t{{end}}\n\t</div>\n\t{{if .CurrentUser.Loggedin}}\n\t{{template \"topics_mod_floater.html\"}}\n\t\n\t{{if .CurrentUser.Perms.CreateTopic}}\n\t<div id=\"forum_topic_create_form\" class=\"rowblock topic_create_form quick_create_form auto_hide\" aria-label=\"{{lang \"quick_topic.aria\"}}\">\n\t\t<form id=\"quick_post_form\" enctype=\"multipart/form-data\" action=\"/topic/create/submit/?s={{.CurrentUser.Session}}\" method=\"post\"></form>\n\t\t<img class=\"little_row_avatar\" src=\"{{.CurrentUser.MicroAvatar}}\" height=64 alt=\"{{lang \"quick_topic.avatar_alt\"}}\" title=\"{{lang \"quick_topic.avatar_tooltip\"}}\">\n\t\t<input form=\"quick_post_form\" id=\"topic_board_input\" name=\"board\" value=\"{{.Forum.ID}}\" type=\"hidden\">\n\t\t<div class=\"main_form\">\n\t\t\t<div class=\"topic_meta\">\n\t\t\t\t<div class=\"formrow topic_name_row real_first_child\">\n\t\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t\t<input form=\"quick_post_form\" name=\"name\" placeholder=\"{{lang \"quick_topic.whatsup\"}}\" required>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{{template \"topics_quick_topic.html\" . }}\n\t\t</div>\n\t</div>\n\t{{end}}\n\t{{end}}\n\t<div id=\"forum_topic_list\" class=\"rowblock micro_grid\" aria-label=\"{{lang \"forum_list_aria\"}}\" style=\"grid-template-columns:repeat(auto-fit,minmax(130px,1fr));\">\n\t\t{{range .ItemList}}<div class=\"rowitem\" data-tid=\"{{.ID}}\">\n\t\t<div>\n\t\t\t<a class=\"rowtopic\" href=\"{{.Link}}\" itemprop=\"itemListElement\"><img src=\"{{.Content}}\" style=\"width:100%;height:160px;\"></a>\n\t\t\t<br><a class=\"rowsmall starter\" href=\"{{.Link}}\">{{.Title}}</a>\n\t\t</div>\n\t\t<!--<div class=\"topic_left passive datarow\">\n\t\t\t<a href=\"{{.Creator.Link}}\"><img src=\"{{.Creator.MicroAvatar}}\" height=64 alt=\"Avatar\" title=\"{{.Creator.Name}}'s Avatar\" aria-hidden=\"true\"></a>\n\t\t\t<span class=\"topic_inner_left\">\n\t\t\t\t<a class=\"rowtopic\" href=\"{{.Link}}\" itemprop=\"itemListElement\" title=\"{{.Title}}\"><span>{{.Title}}</span></a>\n\t\t\t\t<br><a class=\"rowsmall starter\" href=\"{{.Creator.Link}}\" title=\"{{.Creator.Name}}\">{{.Creator.Name}}</a>\n\t\t\t</span>\n\t\t</div>-->\n\t\t<!--<div class=\"topic_middle\">\n\t\t\t<div class=\"topic_middle_inside rowsmall\">\n\t\t\t\t<span class=\"replyCount\">{{.PostCount}}&nbsp;{{lang \"topic_list.replies_suffix\"}}</span>\n\t\t\t\t<span class=\"likeCount\">{{.LikeCount}}&nbsp;{{lang \"topic_list.likes_suffix\"}}</span>\n\t\t\t\t<span class=\"viewCount\">{{.ViewCount}}&nbsp;{{lang \"topic_list.views_suffix\"}}</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"topic_right passive datarow\">\n\t\t\t<div class=\"topic_right_inside\">\n\t\t\t\t<a href=\"{{.LastUser.Link}}\"><img src=\"{{.LastUser.MicroAvatar}}\" height=64 alt=\"Avatar\" title=\"{{.LastUser.Name}}'s Avatar\" aria-hidden=\"true\"></a>\n\t\t\t\t<span>\n\t\t\t\t\t<a href=\"{{.LastUser.Link}}\" class=\"lastName\" style=\"font-size: 14px;\" title=\"{{.LastUser.Name}}\">{{.LastUser.Name}}</a><br>\n\t\t\t\t\t<a href=\"{{.Link}}?page={{.LastPage}}{{if .LastReplyID}}#post-{{.LastReplyID}}{{end}}\" class=\"rowsmall lastReplyAt\" title=\"{{abstime .LastReplyAt}}\">{{reltime .LastReplyAt}}</a>\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t</div>-->\n\t\t</div>{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"forum_no_topics\"}}{{if .CurrentUser.Loggedin}}{{if .CurrentUser.Perms.CreateTopic}} <a href=\"/topics/create/{{.Forum.ID}}\">{{lang \"forum_start_one\"}}</a>{{end}}{{end}}</div>{{end}}\n\t</div>\n\n{{template \"paginator.html\" . }}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/forums.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"forumsItemList\"itemscope itemtype=\"http://schema.org/ItemList\">\n\n<div class=\"rowblock opthead\">\n\t<div class=\"rowitem\"><h1 itemprop=\"name\">{{lang \"forums_head\"}}</h1></div>\n</div>\n<div class=\"rowblock forum_list\">\n\t{{range .ItemList}}<div id=\"forum_{{.ID}}\"class=\"rowitem{{if (.Desc) or (.LastTopic.Title)}} datarow{{end}}\"itemprop=\"itemListElement\"itemscope\n      itemtype=\"http://schema.org/ListItem\">\n\t\t<span class=\"forum_left shift_left\">\n\t\t\t<a href=\"{{.Link}}\"itemprop=\"item\">{{.Name}}</a><br>\n\t\t{{if .Desc}}\n\t\t\t<span class=\"rowsmall\"itemprop=\"description\">{{.Desc}}</span>\n\t\t{{else}}\n\t\t\t<span class=\"rowsmall forum_nodesc\">{{lang \"forums_no_desc\"}}</span>\n\t\t{{end}}\n\t\t</span>\n\t\t<span class=\"forum_right shift_right\">\n\t\t\t{{if .LastReplyer.MicroAvatar}}<a href=\"{{.LastReplyer.Link}}\"><img class=\"extra_little_row_avatar\"src=\"{{.LastReplyer.MicroAvatar}}\"height=64 width=64 alt=\"Avatar\"title=\"{{.LastReplyer.Name}}'s Avatar\"aria-hidden=\"true\"></a>{{end}}\n\t\t\t<span>\n\t\t\t\t<a class={{if .LastTopic.Link}}\"forum_poster\"href=\"{{.LastTopic.Link}}\"{{else}}\"forum_no_poster\"{{end}}>{{if .LastTopic.Title}}{{.LastTopic.Title}}{{else}}{{lang \"forums_none\"}}{{end}}</a>\n\t\t\t\t{{/**{{if .LastTopicTime}}<br><span class=\"rowsmall\"title=\"{{abstime .LastTopic.LastReplyAt}}\">{{.LastTopicTime}}</span>{{end}}**/}}\n\t\t\t\t<br><a href=\"{{.LastTopic.Link}}{{if ne .LastPage 1}}?page={{.LastPage}}{{end}}{{if .LastTopic.LastReplyID}}#post-{{.LastTopic.LastReplyID}}{{end}}\"class=\"rowsmall lastReplyAt\"title=\"{{abstime .LastTopic.LastReplyAt}}\">{{.LastTopicTime}}</a>\n\t\t\t</span>\n\t\t</span><div style=\"clear:both;\"></div>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"forums_no_forums\"}}</div>{{end}}\n</div>\n\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/guilds_create_guild.html",
    "content": "{{template \"header.html\" . }}\n<main>\n\n<div class=\"rowblock rowhead\">\n\t<div class=\"rowitem\"><h1>Create Guild</h1></div>\n</div>\n<div class=\"rowblock\">\n\t<form action=\"/guild/create/submit/\" method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>Guild Name</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"group_name\" type=\"text\" placeholder=\"Group Name\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>Description</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"group_desc\" type=\"text\" placeholder=\"Description\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>Visibility</a></div>\n\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t<select name=\"group_privacy\">\n\t\t\t\t\t\t<option value=0>Public</option>\n\t\t\t\t\t\t<option value=1>Protected</option>\n\t\t\t\t\t\t<option value=2>Private</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"group_button\" class=\"formbutton\">Create Guild</button></div>\n\t\t</div>\n\t</form>\n</div>\n\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/guilds_css.html",
    "content": "<style>\n.miniMenu {\n\tmin-height: 41px;\n\tborder-top: 1px solid rgba(255,255,255,0.5);\n\tbackground-color: rgba(255,255,255,0.5);\n\tposition: relative;\n\ttop: 0px;\n\tz-index: 0;\n\tpadding-top: 3px;\n}\n.menuItem:first-child {\n\tmargin-left: 5px;\n}\n.menuItem {\n\tfloat: left;\n\tmin-height: 38px;\n\tpadding: 10px;\n\tborder-right: 1px solid rgba(204,204,204,0.9);\n\tbackground-color: rgba(255,255,255,0.9);\n\tmargin-right: 5px;\n\tmax-height: 30px;\n\tbox-shadow: 0 7px 15px rgba(0,0,0,0.1);\n\tborder-top: white;\n\tborder-left: white;\n\tposition: relative;\n\ttop: 2px;\n}\n.menuItem:hover {\n\ttop: 4px;\n}\n.menuItem a {\n\tcolor: black;\n\ttext-decoration: none;\n}\n.rightMenu {\n\tfloat: right;\n\tborder-right: none;\n\tborder-left: 1px solid #ccc;\n}\n.sgBackdrop {\n\tbackground-image: url('/uploads/socialgroup_1.jpg');\n\tmin-height: 150px;\n\twidth: 100%;\n\tborder: 1px solid #ccc;\n\tpadding-top: calc(150px - 38px);\n\tborder-bottom: none;\n}\n</style>"
  },
  {
    "path": "templates/guilds_guild_list.html",
    "content": "{{template \"header.html\" . }}\n<main>\n\t<div class=\"rowblock opthead\">\n\t\t<div class=\"rowitem\"><a>Guild List</a></div>\n\t</div>\n\t<div class=\"rowblock\">\n\t\t{{range .GuildList}}<div class=\"rowitem datarow\">\n\t\t\t<span style=\"float:left;\">\n\t\t\t\t<a href=\"{{.Link}}\"style=\"\">{{.Name}}</a>\n\t\t\t\t<br><span class=\"rowsmall\">{{.Desc}}</span>\n\t\t\t</span>\n\t\t\t<span style=\"float:right;\">\n\t\t\t\t<span style=\"float:right;font-size:14px;\">{{.MemberCount}} members</span>\n\t\t\t\t<br><span class=\"rowsmall\">{{.LastUpdateTime}}</span>\n\t\t\t</span>\n\t\t\t<div style=\"clear:both;\"></div>\n\t\t</div>\n\t\t{{else}}<div class=\"rowitem passive\">There aren't any visible guilds.</div>{{end}}\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/guilds_member_list.html",
    "content": "{{template \"header.html\" . }}\n{{/** TODO: Move this into a per-theme CSS file **/}}\n{{template \"guilds_css.html\" . }}\n\n{{/** TODO: Add <link> next / prev bits **/}}\n{{/** TODO: Port the page template functions to the template interpreter **/}}\n{{if gt .Page 1}}<div id=\"prevFloat\" class=\"prev_button\"><a class=\"prev_link\" href=\"/guild/members/{{.Guild.ID}}?page={{subtract .Page 1}}\">&lt;</a></div>{{end}}\n{{if ne .LastPage .Page}}<link rel=\"prerender\" href=\"/guild/members/{{.Guild.ID}}?page={{add .Page 1}}\" />\n<div id=\"nextFloat\" class=\"next_button\"><a class=\"next_link\" href=\"/guild/members/{{.Guild.ID}}?page={{add .Page 1}}\">&gt;</a></div>{{end}}\n\n<div class=\"sgBackdrop\">\n\t<nav class=\"miniMenu\">\n\t\t<div class=\"menuItem\"><a href=\"/guild/{{.Guild.ID}}\">{{.Guild.Name}}</a></div>\n\t\t<div class=\"menuItem\"><a href=\"#\">About</a></div>\n\t\t<div class=\"menuItem\"><a href=\"/guild/members/{{.Guild.ID}}\">Members</a></div>\n\t\t<div class=\"menuItem rightMenu\"><a href=\"#\">Edit</a></div>\n\t\t<div class=\"menuItem rightMenu\"><a href=\"/guild/join/{{.Guild.ID}}\">Join</a></div>\n\t</nav>\n\t<div style=\"clear:both;\"></div>\n</div>\n<main id=\"socialgroups_member_list\" class=\"rowblock member_list\" style=\"position:relative;z-index:50;\">\n\t{{range .ItemList}}<div class=\"rowitem passive datarow\" style=\"background-image:url({{.User.Avatar}});background-position:left;background-repeat:no-repeat;background-size:64px;padding-left:78px;{{if .Offline}}background-color:#eaeaea;{{else if gt .Rank 0}}background-color:#e6f3ff;{{end}}\">\n\t\t<span style=\"float:right;\">\n\t\t\t<span class=\"rank\" style=\"font-size:15px;\">{{.RankString}}</span><br>\n\t\t\t<span class=\"joinedAt rowsmall\">{{.JoinedAt}}</span>\n\t\t</span>\n\t\t<span>\n\t\t\t<a class=\"rowtopic\" href=\"{{.Link}}\">{{.User.Name}}</a>\n      {{/** Use this for badges instead of rank? Both? Guild Titles? **/}}\n\t\t\t<br><span class=\"rowsmall postCount\">{{.PostCount}} posts</span>\n\t\t</span>\n\t</div>\n  {{end}}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/guilds_view_guild.html",
    "content": "{{template \"header.html\" . }}\n{{/** TODO: Move this into a CSS file **/}}\n{{template \"socialgroups_css.html\" . }}\n\n{{/** TODO: Port the page template functions to the template interpreter **/}}\n{{if gt .Page 1}}<div id=\"prevFloat\" class=\"prev_button\"><a class=\"prev_link\" href=\"/guild/{{.Guild.ID}}?page={{subtract .Page 1}}\">&lt;</a></div>{{end}}\n{{if ne .LastPage .Page}}<link rel=\"prerender\" href=\"/guild/{{.Guild.ID}}?page={{add .Page 1}}\" />\n<div id=\"nextFloat\" class=\"next_button\"><a class=\"next_link\" href=\"/guild/{{.Guild.ID}}?page={{add .Page 1}}\">&gt;</a></div>{{end}}\n\n<div class=\"sgBackdrop\">\n\t<nav class=\"miniMenu\">\n\t\t<div class=\"menuItem\"><a href=\"/guild/{{.Guild.ID}}\">{{.Guild.Name}}</a></div>\n\t\t<div class=\"menuItem\"><a href=\"#\">About</a></div>\n\t\t<div class=\"menuItem\"><a href=\"/guild/members/{{.Guild.ID}}\">Members</a></div>\n\t\t<div class=\"menuItem rightMenu\"><a href=\"#\">Edit</a></div>\n\t\t<div class=\"menuItem rightMenu\"><a href=\"/topics/create/{{.Forum.ID}}\">Reply</a></div>\n\t\t<div class=\"menuItem rightMenu\"><a href=\"/guild/join/{{.Guild.ID}}\">Join</a></div>\n\t</nav>\n\t<div style=\"clear: both;\"></div>\n</div>\n<main id=\"forum_topic_list\" class=\"rowblock topic_list\" style=\"position:relative;z-index:50;\">\n\t{{range .ItemList}}<div class=\"rowitem topic_left passive datarow\" style=\"background-image:url({{.Creator.Avatar}});background-position:left;background-repeat:no-repeat;background-size:64px;padding-left:72px;{{if .Sticky}}background-color:#FFFFCC;{{else if .IsClosed}}background-color:#eaeaea;{{end}}\">\n\t\t<span class=\"topic_inner_right rowsmall\" style=\"float:right;\">\n\t\t\t<span class=\"replyCount\">{{.PostCount}} replies</span><br>\n\t\t\t<span class=\"lastReplyAt\">{{.LastReplyAt}}</span>\n\t\t</span>\n\t\t<span>\n\t\t\t<a class=\"rowtopic\" href=\"{{.Link}}\">{{.Title}}</a>\n\t\t\t<br><a class=\"rowsmall\" href=\"{{.Creator.Link}}\">Starter: {{.Creator.Name}}</a>\n\t\t\t{{if .IsClosed}}<span class=\"rowsmall topic_status_e topic_status_closed\" title=\"{{lang \"status.closed_tooltip\"}}\"> | &#x1F512;&#xFE0E{{end}}</span>\n\t\t</span>\n\t</div>\n\t<div class=\"rowitem topic_right passive datarow\" style=\"background-image:url({{.LastUser.Avatar}});background-position:left;background-repeat:no-repeat;background-size:64px;padding-left:72px;\">\n\t\t<span>\n\t\t\t<a href=\"{{.LastUser.Link}}\" class=\"lastName\" style=\"font-size:14px;\">{{.LastUser.Name}}</a><br>\n\t\t\t<span class=\"rowsmall lastReplyAt\">Last: {{.LastReplyAt}}</span>\n\t\t</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive\">There aren't any topics in here yet.{{if .CurrentUser.Perms.CreateTopic}} <a href=\"/topics/create/{{.Forum.ID}}\">Start one?</a>{{end}}</div>{{end}}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/header.html",
    "content": "<!doctype html>\n<html{{if .Header.IsoCode}} lang=\"{{.Header.IsoCode}}\"{{end}}>\n\t<head>\n\t\t<title>{{.Title}} | {{.Header.Site.Name}}</title>\n\t\t{{range .Header.Stylesheets}}\n\t\t<link href=\"{{.Name}}\"rel=\"stylesheet\"type=\"text/css\"{{if .Hash}}integrity=\"sha256-{{.Hash}}\"{{end}}>{{end}}\n\t\t{{range .Header.PreScriptsAsync}}\n\t\t<script async src=\"{{.Name}}\"{{if .Hash}}integrity=\"sha256-{{.Hash}}\"{{end}}></script>{{end}}\n\t\t{{if .CurrentUser.Loggedin}}<meta property=\"x-mem\"content=\"1\">{{end}}\n\t\t<script src=\"{{res \"init.js\"}}\"></script>\n\t\t{{range .Header.ScriptsAsync}}\n\t\t<script async src=\"{{.Name}}\"{{if .Hash}}integrity=\"sha256-{{.Hash}}\"{{end}}></script>{{end}}\n\t\t<script src=\"{{res \"jquery-3.1.1.min.js\"}}\"></script>\n\t\t{{range .Header.Scripts}}\n\t\t<script src=\"{{.Name}}\"{{if .Hash}}integrity=\"sha256-{{.Hash}}\"{{end}}></script>{{end}}\n\t\t<meta name=\"viewport\"content=\"width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no\">\n\t\t{{if .Header.MetaDesc}}<meta name=\"description\"content=\"{{.Header.MetaDesc}}\">{{end}}\n\t\t{{/** TODO: Have page / forum / topic level tags and descriptions below as-well **/}}\n\t\t<meta property=\"og:type\"content=\"website\">\n\t\t<meta property=\"og:site_name\"content=\"{{.Header.Site.Name}}\">\n\t\t<meta property=\"og:title\"content=\"{{.Title}} | {{.Header.Site.Name}}\">\n\t\t<meta name=\"twitter:title\"content=\"{{.Title}} | {{.Header.Site.Name}}\">\n\t\t{{if .OGDesc}}<meta property=\"og:description\"content=\"{{.OGDesc}}\">\n\t\t<meta property=\"twitter:description\"content=\"{{.OGDesc}}\">{{end}}\n\t\t{{if .GoogSiteVerify}}<meta name=\"google-site-verification\"content=\"{{.GoogSiteVerify}}\">{{end}}\n\t\t<link rel=\"search\"type=\"application/opensearchdescription+xml\"title=\"{{.Header.Site.Name}}\"href=\"/opensearch.xml\">\n\t</head>\n\t<body>\n\t\t{{/**{{if not .CurrentUser.IsSuperMod}}<style>.supermod_only { display: none !important; }</style>{{end}}**/}}{{flush}}\n\t\t<div id=\"container\"class=\"container\">\n\t\t{{/**<!--<div class=\"navrow\">-->**/}}\n\t\t\t<div class=\"left_of_nav\">{{dock \"leftOfNav\" .Header }}</div>\n\t\t\t<nav class=\"nav\">\n\t\t\t\t<div class=\"move_left\">\n\t\t\t\t<div class=\"move_right\">\n\t\t\t\t<ul id=\"main_menu\"class=\"zone_{{.Header.Zone}}\">{{/** TODO: Have the theme control whether the long or short form of the name is used **/}}\n\t\t\t\t\t<li id=\"menu_overview\"class=\"menu_left\"><a href=\"/\"rel=\"home\">{{if eq .Header.Theme.Name \"nox\"}}{{.Header.Site.Name}}{{else}}{{.Header.Site.ShortName}}{{end}}</a></li>\n\t\t\t\t\t{{dock \"topMenu\" .Header }}\n\t\t\t\t\t<li class=\"menu_left menu_hamburger\"title=\"{{lang \"menu_hamburger_tooltip\"}}\"><a></a></li>\n\t\t\t\t</ul>\n\t\t\t\t</div>\n\t\t\t\t</div><div style=\"clear:both;\"></div>\n\t\t\t</nav>\n\t\t\t<div class=\"right_of_nav\">{{/**<!--{{dock \"rightOfNav\" .Header }}-->**/}}\n{{/** TODO: Make this a separate template and load it via the theme docks, here for now so we can rapidly prototype the Nox theme **/}}\n{{if eq .Header.Theme.Name \"nox\"}}\n<div class=\"user_box\">\n\t<a href=\"{{.CurrentUser.Link}}\"><img alt=\"Avatar\"src=\"{{.CurrentUser.MicroAvatar}}\"></a>\n\t<div class=\"option_box\">\n\t\t<a href=\"{{.CurrentUser.Link}}\"class=\"username\">{{.CurrentUser.Name}}</a>\n\t\t<span class=\"alerts\">{{lang \"alerts.no_alerts_short\"}}</span>\n\t</div>\n</div>\n{{end}}\n\t\t\t</div>\n\t\t{{/**<!--</div>-->**/}}\n\t\t\t<div class=\"midRow\">\n\t\t\t\t<div class=\"midLeft\"></div>\n\t\t\t\t<div id=\"back\"class=\"zone_{{.Header.Zone}}{{if hasWidgets \"rightSidebar\" .Header }} shrink_main{{end}}\">\n\t\t\t\t\t<div id=\"main\">\n\t\t\t\t\t\t<div class=\"alertbox initial_alertbox\">{{range .Header.NoticeList}}\n\t\t\t\t{{template \"notice.html\" . }}{{end}}\n\t\t\t\t\t\t</div>"
  },
  {
    "path": "templates/ip_search.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"ip_search_container\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\">\n\t\t\t<h1>{{lang \"ip_search_head\"}}</h1>\n\t\t</div>\n\t</div>\n\t<form action=\"/users/ips/\"method=\"get\"id=\"ip-search-form\"></form>\n\t<div class=\"rowblock ip_search_block\">\n\t\t<div class=\"rowitem passive\">\n\t\t\t<input form=\"ip-search-form\"name=\"ip\"class=\"ip_search_input\"type=\"search\"placeholder=\"🔍︎\"{{if .IP}}value=\"{{.IP}}\"{{end}}>\n\t\t\t<input form=\"ip-search-form\"class=\"ip_search_search\"type=\"submit\"value=\"{{lang \"ip_search_search_button\"}}\">\n\t\t</div>\n\t</div>\n\t{{if .IP}}<div class=\"rowblock rowlist bgavatars micro_grid{{if .ItemList}} has_items{{end}}\">\n\t\t{{range .ItemList}}<div class=\"rowitem\"style=\"background-image:url('{{.Avatar}}');\">\n\t\t\t<img src=\"{{.Avatar}}\"class=\"bgsub\"alt=\"Avatar\"aria-hidden=\"true\">\n\t\t\t<a class=\"rowTitle\"href=\"{{.Link}}\">{{.Name}}</a>\n\t\t</div>\n\t\t{{else}}<div class=\"rowitem rowmsg\">{{lang \"ip_search_no_users\"}}</div>{{end}}\n\t</div>{{end}}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/level_list.html",
    "content": "{{template \"header.html\" . }}\n<main>\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{.Title}}</h1></div>\n\t</div>\n\t{{range .Levels}}\n\t<div class=\"rowblock\">\n\t\t<div class=\"rowitem passive rowmsg level_{{.Status}}{{if eq $.CurrentUser.Score 0}} level_zero{{end}}\">\n\t\t\t<div class=\"levelBit\"{{if ne $.CurrentUser.Score 0}}{{if eq .Status \"inprogress\"}}style=\"width:{{.Percentage}}%;\"{{end}}{{end}}>{{level .Level}}</div>\n\t\t\t<div class=\"progressWrap\"{{/**{{if eq .Status \"inprogress\"}}style=\"width:{{.Percentage}}%;\"{{end}}**/}}>\n\t\t\t\t<div>{{if eq .Status \"inprogress\"}}{{$.CurrentUser.Score}} / {{.Score}}{{else if eq .Status \"complete\"}}{{.Score}} / {{.Score}}{{else}}Next: {{.Score}}{{end}}</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t{{end}}\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/login.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"login_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"login_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/login/submit/\"method=\"post\">\n\t\t\t<div class=\"formrow login_name_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_name_label\">{{lang \"login_account_name\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"username\"type=\"text\"placeholder=\"{{lang \"login_account_name\"}}\"aria-labelledby=\"login_name_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_password_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_password_label\">{{lang \"login_account_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\"type=\"password\"autocomplete=\"current-password\"placeholder=\"*****\"aria-labelledby=\"login_password_label\" required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\"class=\"formbutton\">{{lang \"login_submit_button\"}}</button></div>\n\t\t\t\t<div class=\"formitem dont_have_account\">\n\t\t\t\t\t<a href=\"/accounts/create/\">{{lang \"login_no_account\"}}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formitem forgot_password\">\n\t\t\t\t\t<a href=\"/accounts/password-reset/\">{{lang \"login_forgot_password\"}}</a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/login_mfa_verify.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"login_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"login_mfa_verify_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/mfa_verify/submit/\"method=\"post\">\n\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"login_mfa_verify_explanation\"}}</a></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_mfa_token_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_mfa_verify_label\">{{lang \"login_mfa_token\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"mfa_token\"type=\"text\"autocomplete=\"off\"placeholder=\"*****\"aria-labelledby=\"login_mfa_verify_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\"class=\"formbutton\">{{lang \"login_mfa_verify_button\"}}</button></div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/menu_alerts.html",
    "content": "<li id=\"general_alerts\"class=\"menu_right menu_alerts\">\n\t<div class=\"alert_bell\"></div>\n\t<div class=\"alert_counter\"aria-label=\"{{lang \"menu_alert_counter_aria\"}}\"></div>\n\t<div class=\"alert_aftercounter\"></div>\n\t<div class=\"alertList\"aria-label=\"{{lang \"menu_alert_list_aria\"}}\"></div>\n</li>"
  },
  {
    "path": "templates/menu_item.html",
    "content": "<li id=\"{{.HTMLID}}\"class=\"menu_{{.Position}} {{.CSSClass}}{{.CSSActive}}\"><a href=\"{{.Path}}\"aria-label=\"{{.Aria}}\"title=\"{{.Tooltip}}\">{{.Name}}</a></li>"
  },
  {
    "path": "templates/notice.html",
    "content": "<div class=\"alert\">{{.}}</div>"
  },
  {
    "path": "templates/overrides/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "templates/overview.html",
    "content": "{{template \"header.html\" . }}\n<main></main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/paginator.html",
    "content": "{{if gt .LastPage 1}}\n<div class=\"pageset\">\n\t{{if gt .Page 1}}<div class=\"pageitem pagefirst\"><a href=\"?page=1\"aria-label=\"{{lang \"paginator.first_page_aria\"}}\">{{lang \"paginator.first_page\"}}</a></div>\n\t<div class=\"pageitem pageprev\"><a href=\"?page={{subtract .Page 1}}\"rel=\"prev\"aria-label=\"{{lang \"paginator.prev_page_aria\"}}\">{{lang \"paginator.prev_page\"}}</a></div>\n\t<link rel=\"prev\"href=\"?page={{subtract .Page 1}}\">{{end}}\n\t{{range .PageList}}\n\t<div class=\"pageitem{{if eq . $.Page}} pagecurrent{{end}}\"><a href=\"?page={{.}}\">{{.}}</a></div>\n\t{{end}}\n\t{{if ne .LastPage .Page}}\n\t<link rel=\"next\"href=\"?page={{add .Page 1}}\">\n\t<div class=\"pageitem pagenext\"><a href=\"?page={{add .Page 1}}\"rel=\"next\"aria-label=\"{{lang \"paginator.next_page_aria\"}}\">{{lang \"paginator.next_page\"}}</a></div>\n\t<div class=\"pageitem pagelast\"><a href=\"?page={{.LastPage}}\"aria-label=\"{{lang \"paginator.last_page_aria\"}}\">{{lang \"paginator.last_page\"}}</a></div>{{end}}\n</div>\n{{end}}"
  },
  {
    "path": "templates/paginator_mod.html",
    "content": "{{if gt .LastPage 1}}\n<div class=\"pageset\">\n\t{{if gt .Page 1}}<div class=\"pageitem pagefirst\"><a href=\"?{{.Params}}page=1\"aria-label=\"{{lang \"paginator.first_page_aria\"}}\">{{lang \"paginator.first_page\"}}</a></div>\n\t<div class=\"pageitem pageprev\"><a href=\"?{{.Params}}page={{subtract .Page 1}}\"rel=\"prev\"aria-label=\"{{lang \"paginator.prev_page_aria\"}}\">{{lang \"paginator.prev_page\"}}</a></div>\n\t<link rel=\"prev\"href=\"?{{.Params}}page={{subtract .Page 1}}\">{{end}}\n\t{{range .PageList}}\n\t<div class=\"pageitem{{if eq . $.Page}} pagecurrent{{end}}\"><a href=\"?{{$.Params}}page={{.}}\">{{.}}</a></div>\n\t{{end}}\n\t{{if ne .LastPage .Page}}\n\t<link rel=\"next\"href=\"?{{.Params}}page={{add .Page 1}}\">\n\t<div class=\"pageitem pagenext\"><a href=\"?{{.Params}}page={{add .Page 1}}\"rel=\"next\"aria-label=\"{{lang \"paginator.next_page_aria\"}}\">{{lang \"paginator.next_page\"}}</a></div>\n\t<div class=\"pageitem pagelast\"><a href=\"?{{.Params}}page={{.LastPage}}\"aria-label=\"{{lang \"paginator.last_page_aria\"}}\">{{lang \"paginator.last_page\"}}</a></div>{{end}}\n</div>\n{{end}}"
  },
  {
    "path": "templates/panel.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"colstack panel_stack\">\n{{template \"panel_menu.html\" . }}\n<main{{if .HTMLID}} id=\"{{.HTMLID}}\"{{end}} class=\"colstack_right {{.ClassNames}}\">\n{{template \"panel_before_head.html\" . }}\n{{dyntmpl .TmplName .Inner .Header}}\n</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/panel_adminlogs.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_logs_admin_head\"}}</h1></div>\n</div>\n<div id=\"panel_modlogs\" class=\"colstack_item rowlist loglist\">\n\t{{range .Logs}}\n\t<div class=\"rowitem panel_compactrow\">\n\t\t<span class=\"to_left\">\n\t\t\t<span>{{.Action}}</span>\n\t\t\t{{if $.CurrentUser.Perms.ViewIPs}}<br><small title=\"{{.IP}}\">{{.IP}}</small>{{end}}\n\t\t</span>\n\t\t<span class=\"to_right\">\n\t\t\t<span title=\"{{.DoneAt}}\">{{.DoneAt}}</span>\n\t\t</span>\n\t\t<div style=\"clear:both;\"></div>\n\t</div>\n\t{{else}}\n\t<div class=\"rowitem rowmsg\">\n\t\t<a>{{lang \"panel_logs_admin_no_logs\"}}</a>\n\t</div>\n\t{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_active_memory.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_active_memory_head\"}}</h1>\n\t\t<select form=\"timeRangeForm\"class=\"typeSelector to_right autoSubmitRedirect\"name=\"mtype\">\n\t\t\t<option value=\"0\"{{if eq .MemType 0}}selected{{end}}>{{lang \"panel_stats_memory_type_total\"}}</option>\n\t\t\t<option value=\"1\"{{if eq .MemType 1}}selected{{end}}>{{lang \"panel_stats_memory_type_stack\"}}</option>\n\t\t\t<option value=\"2\"{{if eq .MemType 2}}selected{{end}}>{{lang \"panel_stats_memory_type_heap\"}}</option>\n\t\t</select>\n\t\t<noscript><input form=\"timeRangeForm\"type=\"submit\"></noscript>\n\t\t{{template \"panel_analytics_time_range_month.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/active-memory/\"method=\"get\"></form>\n<div id=\"panel_analytics_memory\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_memory_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_memory_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_memory_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_to_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}24_hour_time{{else}}date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{.Unit}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_memory_no_memory\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script_memory.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_agent_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.FriendlyAgent}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/agent/{{.Agent}}\"method=\"get\"></form>\n<div id=\"panel_analytics_views\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_agents.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_user_agents_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/agents/\"method=\"get\"></form>\n<div id=\"panel_analytics_agents_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_agents\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/agent/{{.Agent}}\"class=\"panel_upshift\">{{.FriendlyAgent}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_user_agents_no_user_agents\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_forum_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.FriendlyAgent}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/forum/{{.Agent}}\"method=\"get\"></form>\n<div id=\"panel_analytics_views\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_forums.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_forums_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/forums/\"method=\"get\"></form>\n<div id=\"panel_analytics_forums_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_routes\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/forum/{{.Agent}}\"class=\"panel_upshift\">{{.FriendlyAgent}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_forums_no_forums\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_lang_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.FriendlyAgent}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/lang/{{.Agent}}\"method=\"get\"></form>\n<div id=\"panel_analytics_langs\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_langs.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_languages_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/langs/\"method=\"get\"></form>\n<div id=\"panel_analytics_langs_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_langs\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/lang/{{.Agent}}\"class=\"panel_upshift\">{{.FriendlyAgent}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_languages_no_languages\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_memory.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_memory_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range_month.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/memory/\"method=\"get\"></form>\n<div id=\"panel_analytics_memory\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_memory_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_memory_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_memory_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}to_24_hour_time{{else}}to_date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{.Unit}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_memory_no_memory\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script_memory.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_performance.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_perf_head\"}}</h1>\n\t\t<select form=\"timeRangeForm\"class=\"typeSelector to_right autoSubmitRedirect\" name=\"type\">\n\t\t\t<option value=\"0\"{{if eq .PerfType 0}}selected{{end}}>{{lang \"panel_stats_perf_low\"}}</option>\n\t\t\t<option value=\"1\"{{if eq .PerfType 1}}selected{{end}}>{{lang \"panel_stats_perf_high\"}}</option>\n\t\t\t<option value=\"2\"{{if eq .PerfType 2}}selected{{end}}>{{lang \"panel_stats_perf_avg\"}}</option>\n\t\t</select>\n\t\t<noscript><input form=\"timeRangeForm\"type=\"submit\"></noscript>\n\t\t{{template \"panel_analytics_time_range_month.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/perf/\" method=\"get\"></form>\n<div id=\"panel_analytics_memory\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_perf_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_perf_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_perf_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_to_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}24_hour_time{{else}}date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{.Unit}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_perf_no_perf\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script_perf.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_posts.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_post_counts_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/posts/\"method=\"get\"></form>\n<div id=\"panel_analytics_posts\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_post_counts_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_posts_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_post_counts_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}to_24_hour_time{{else}}to_date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_posts_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_post_counts_no_post_counts\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_referrer_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.Agent}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/referrer/{{.Agent}}\"method=\"get\"></form>\n<div id=\"panel_analytics_referrers\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_referrers.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_referrers_head\"}}</h1>\n\t\t<select form=\"timeRangeForm\"class=\"spamSelector to_right autoSubmitRedirect\"name=\"spam\">\n\t\t\t<option value=\"0\"{{if not .ShowSpam}}selected{{end}}>{{lang \"panel_stats_spam_hide\"}}</option>\n\t\t\t<option value=\"1\"{{if .ShowSpam}}selected{{end}}>{{lang \"panel_stats_spam_show\"}}</option>\n\t\t</select>\n\t\t<noscript><input form=\"timeRangeForm\"type=\"submit\"></noscript>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/referrers/\"method=\"get\"></form>\n<div id=\"panel_analytics_referrers\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/referrer/{{.Agent}}\"class=\"panel_upshift\">{{.Agent}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_referrers_no_referrers\"}}</div>{{end}}\n</div>"
  },
  {
    "path": "templates/panel_analytics_route_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.Route}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/route/{{.Route}}\"method=\"get\"></form>\n<div id=\"panel_analytics_views\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a>{{lang \"panel_stats_details_head\"}}</a></div>\n</div>\n<div id=\"panel_analytics_views_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_route_views_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_to_24_hour_time\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_routes.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_routes_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/routes/\"method=\"get\"></form>\n<div id=\"panel_analytics_routes_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_routes\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/route/{{.Route}}\"class=\"panel_upshift\">{{.Route}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_routes_no_routes\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_routes_perf.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_routes_perf_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/routes-perf/\"method=\"get\"></form>\n<div id=\"panel_analytics_routes_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_routes\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/route/{{.Route}}\"class=\"panel_upshift\">{{.Route}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{.Unit}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_routes_no_routes\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script_perf.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_script.html",
    "content": "\n<script>\nlet rawLabels = [{{range .Graph.Labels}}\n{{.}},{{end}}\n];\nlet seriesData = [{{range .Graph.Series}}[{{range .}}\n{{.}},{{end}}\n],{{end}}\n];\nlet legendNames = [{{range .Graph.Legends}}\n{{.}},{{end}}\n];\naddInitHook(\"after_phrases\", () => {\n\taddInitHook(\"end_init\", () => {\n\t\taddInitHook(\"analytics_loaded\", () => {\n\t\t\tbuildStatsChart(rawLabels,seriesData,\"{{.TimeRange}}\",legendNames);\n\t\t});\n\t});\n});\n</script>"
  },
  {
    "path": "templates/panel_analytics_script_memory.html",
    "content": "<script>\nlet rawLabels = [{{range .Graph.Labels}}\n{{.}},{{end}}\n];\nlet seriesData = [{{range .Graph.Series}}[{{range .}}\n{{.}},{{end}}\n],{{end}}\n];\nlet legendNames = [{{range .Graph.Legends}}\n{{.}},{{end}}\n];\n\naddInitHook(\"after_phrases\", () => {\n\taddInitHook(\"end_init\", () => {\n\t\taddInitHook(\"analytics_loaded\", () => {\n\t\t\tmemStuff(window,document,Chartist);\n\t\t\tbuildStatsChart(rawLabels,seriesData,\"{{.TimeRange}}\",legendNames,1);\n\t\t});\n\t});\n});\n</script>"
  },
  {
    "path": "templates/panel_analytics_script_perf.html",
    "content": "<script>\nlet rawLabels = [{{range .Graph.Labels}}\n{{.}},{{end}}\n];\nlet seriesData = [{{range .Graph.Series}}[{{range .}}\n{{.}},{{end}}\n],{{end}}\n];\nlet legendNames = [{{range .Graph.Legends}}\n{{.}},{{end}}\n];\n\naddInitHook(\"after_phrases\", () => {\n\taddInitHook(\"end_init\", () => {\n\t\taddInitHook(\"analytics_loaded\", () => {\n\t\t\tperfStuff(window,document,Chartist);\n\t\t\tbuildStatsChart(rawLabels,seriesData,\"{{.TimeRange}}\",legendNames,2);\n\t\t});\n\t});\n});\n</script>"
  },
  {
    "path": "templates/panel_analytics_system_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{.FriendlyAgent}}{{lang \"panel_stats_views_head_suffix\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/system/{{.Agent}}\"method=\"get\"></form>\n<div id=\"panel_analytics_systems\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_systems.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_operating_systems_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/systems/\"method=\"get\"></form>\n<div id=\"panel_analytics_systems_chart\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"></div>\n</div>\n<div id=\"panel_analytics_systems\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/analytics/system/{{.Agent}}\"class=\"panel_upshift\">{{.FriendlyAgent}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"panel_stats_operating_systems_no_operating_systems\"}}</div>{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_time_range.html",
    "content": "<select form=\"timeRangeForm\"class=\"timeRangeSelector to_right autoSubmitRedirect\"name=\"timeRange\">\n\t<option value=\"one-year\"{{if eq .TimeRange \"one-year\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_year\"}}</option>\n\t<option value=\"three-months\"{{if eq .TimeRange \"three-months\"}}selected{{end}}>{{lang \"panel_stats_time_range_three_months\"}}</option>\n\t<option value=\"one-month\"{{if eq .TimeRange \"one-month\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_month\"}}</option>\n\t<option value=\"one-week\"{{if eq .TimeRange \"one-week\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_week\"}}</option>\n\t<option value=\"two-days\"{{if eq .TimeRange \"two-days\"}}selected{{end}}>{{lang \"panel_stats_time_range_two_days\"}}</option>\n\t<option value=\"one-day\"{{if eq .TimeRange \"one-day\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_day\"}}</option>\n\t<option value=\"twelve-hours\"{{if eq .TimeRange \"twelve-hours\"}}selected{{end}}>{{lang \"panel_stats_time_range_twelve_hours\"}}</option>\n\t<option value=\"six-hours\"{{if eq .TimeRange \"six-hours\"}}selected{{end}}>{{lang \"panel_stats_time_range_six_hours\"}}</option>\n</select>\n<noscript><input form=\"timeRangeForm\"type=\"submit\"></noscript>"
  },
  {
    "path": "templates/panel_analytics_time_range_month.html",
    "content": "<select form=\"timeRangeForm\"class=\"timeRangeSelector to_right autoSubmitRedirect\"sname=\"timeRange\">\n\t<option value=\"one-month\"{{if eq .TimeRange \"one-month\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_month\"}}</option>\n\t<option value=\"one-week\"{{if eq .TimeRange \"one-week\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_week\"}}</option>\n\t<option value=\"two-days\"{{if eq .TimeRange \"two-days\"}}selected{{end}}>{{lang \"panel_stats_time_range_two_days\"}}</option>\n\t<option value=\"one-day\"{{if eq .TimeRange \"one-day\"}}selected{{end}}>{{lang \"panel_stats_time_range_one_day\"}}</option>\n\t<option value=\"twelve-hours\"{{if eq .TimeRange \"twelve-hours\"}}selected{{end}}>{{lang \"panel_stats_time_range_twelve_hours\"}}</option>\n\t<option value=\"six-hours\"{{if eq .TimeRange \"six-hours\"}}selected{{end}}>{{lang \"panel_stats_time_range_six_hours\"}}</option>\n</select>\n<noscript><input form=\"timeRangeForm\"type=\"submit\"></noscript>"
  },
  {
    "path": "templates/panel_analytics_topics.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_topic_counts_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/topics/\"method=\"get\"></form>\n<div id=\"panel_analytics_topics\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_topic_counts_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_topics_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_topic_counts_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_to_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}24_hour_time{{else}}date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_topics_suffix\"}}</span>\n\t</div>\n\t{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_analytics_views.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_requests_head\"}}</h1>\n\t\t{{template \"panel_analytics_time_range.html\" . }}\n\t</div>\n</div>\n<form id=\"timeRangeForm\"name=\"timeRangeForm\"action=\"/panel/analytics/views/\"method=\"get\"></form>\n<div id=\"panel_analytics_views\"class=\"colstack_graph_holder\">\n\t<div class=\"ct_chart\"aria-label=\"{{lang \"panel_stats_requests_chart_aria\"}}\"></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_stats_details_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_analytics_views_table\"class=\"colstack_item rowlist\"aria-label=\"{{lang \"panel_stats_requests_table_aria\"}}\">\n\t{{range .ViewItems}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift unix_to_{{if or (or (or (eq $.TimeRange \"six-hours\") (eq $.TimeRange \"twelve-hours\")) (eq $.TimeRange \"one-day\")) (eq $.TimeRange \"two-days\")}}24_hour_time{{else}}date{{end}}\">{{.Time}}</a>\n\t\t<span class=\"panel_compacttext to_right\">{{.Count}}{{lang \"panel_stats_views_suffix\"}}</span>\n\t</div>\n\t{{end}}\n</div>\n{{template \"panel_analytics_script.html\" . }}"
  },
  {
    "path": "templates/panel_are_you_sure.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"colstack panel_stack\">\n{{template \"panel_menu.html\" . }}\n\t<main class=\"colstack_right\">\n{{template \"panel_before_head.html\" . }}\n\t\t<div class=\"colstack_item colstack_head\">\n\t\t\t<div class=\"rowitem\"><h1>{{lang \"areyousure_head\"}}</h1></div>\n\t\t</div>\n\t\t<div class=\"colstack_item\">\n\t\t\t<div class=\"rowitem passive rowmsg\">{{.Something.Message}}<br><br>\n\t\t\t\t<a class=\"username\" href=\"{{.Something.URL}}?s={{.CurrentUser.Session}}\">{{lang \"areyousure_continue\"}}</a>\n\t\t\t</div>\n\t\t</div>\n\t</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/panel_backups.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_backups_head\"}}</h1></div>\n</div>\n<div id=\"panel_backups\"class=\"colstack_item rowlist\">\n\t{{range .Backups}}\n\t<div class=\"rowitem panel_compactrow\">\n\t\t<span>{{.SQLURL}}</span>\n\t\t<span class=\"panel_floater\">\n\t\t\t<a href=\"/panel/backups/{{.SQLURL}}\"class=\"panel_tag panel_right_button\">{{lang \"panel_backups_download\"}}</a>\n\t\t</span>\n\t</div>\n\t{{else}}<div class=\"rowitem rowmsg\">{{lang \"panel_backups_no_backups\"}}</div>{{end}}\n</div>"
  },
  {
    "path": "templates/panel_before_head.html",
    "content": ""
  },
  {
    "path": "templates/panel_dashboard.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_dashboard_head\"}}</h1></div>\n</div>{{flush}}\n<div class=\"colstack_grid panel_dashboard grid1\">\n{{range .Grid1}}\n\t<div id=\"{{.ID}}\"class=\"grid_item {{.Class}}\"title=\"{{.Note}}\"style=\"{{if .TextColour}}color:{{.TextColour}};{{end}}{{if .Background}}background-color:{{.Background}};{{end}}\">\n\t\t{{if .Href}}<a href=\"{{.Href}}\">{{.Body}}</a>{{else}}<span>{{.Body}}</span>{{end}}\n\t</div>\n{{end}}\n</div>{{flush}}\n<div class=\"colstack_grid panel_dashboard grid2\">\n{{range .Grid2}}\n\t<div id=\"{{.ID}}\"class=\"grid_item {{.Class}}\"title=\"{{.Note}}\"style=\"{{if .TextColour}}color:{{.TextColour}};{{end}}{{if .Background}}background-color:{{.Background}};{{end}}\">\n\t\t{{if .Href}}<a href=\"{{.Href}}\">{{.Body}}</a>{{else}}<span>{{.Body}}</span>{{end}}\n\t</div>\n{{end}}\n</div>"
  },
  {
    "path": "templates/panel_debug.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_debug_head\"}}</h1></div>\n</div>{{flush}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_uptime_label\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_go_version_label\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_version_label\"}}\n\t{{template \"panel_debug_stat.html\" .Uptime}}\n\t{{template \"panel_debug_stat.html\" .GoVersion}}\n\t{{template \"panel_debug_stat.html\" .DBVersion}}\n\t\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_open_database_connections_label\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_adapter_label\"}}\n\t{{/** TODO: Use this for active database connections when Go 1.11 lands **/}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat.html\" .DBConns}}\n\t{{template \"panel_debug_stat.html\" .DBAdapter}}\n\t{{template \"panel_debug_stat_q.html\"}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_goroutine_count_label\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_cpu_count_label\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_http_conns_label\"}}\n\t{{template \"panel_debug_stat.html\" .Goroutines}}\n\t{{template \"panel_debug_stat.html\" .CPUs}}\n\t{{template \"panel_debug_stat.html\" .HttpConns}}\n</div>\n{{template \"panel_debug_subhead.html\" \"panel_debug_tasks\"}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_tasks_half_second\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_tasks_second\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_tasks_fifteen_minute\"}}\n\t{{template \"panel_debug_stat.html\" .Tasks.HalfSecond}}\n\t{{template \"panel_debug_stat.html\" .Tasks.Second}}\n\t{{template \"panel_debug_stat.html\" .Tasks.FifteenMinute}}\n\t\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_tasks_hour\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_tasks_shutdown\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat.html\" .Tasks.Hour}}\n\t{{template \"panel_debug_stat.html\" .Tasks.Shutdown}}\n\t{{template \"panel_debug_stat_q.html\"}}\n</div>\n{{template \"panel_debug_subhead.html\" \"panel_debug_memory_stats\"}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_sys\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_heapsys\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_heapalloc\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.Sys}} ({{bunit .MemStats.Sys}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.HeapSys}} ({{bunit .MemStats.HeapSys}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.HeapAlloc}} ({{bunit .MemStats.HeapAlloc}})</span></div>\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_heapidle\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_heapobjects\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_stackinuse\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.HeapIdle}} ({{bunit .MemStats.HeapIdle}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.HeapObjects}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.StackInuse}} ({{bunit .MemStats.StackInuse}})</span></div>\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_mspaninuse\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_mcacheinuse\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_mspansys\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.MSpanInuse}} ({{bunit .MemStats.MSpanInuse}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.MCacheInuse}} ({{bunit .MemStats.MCacheInuse}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.MSpanSys}} ({{bunit .MemStats.MSpanSys}})</span></div>\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_mcachesys\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_gcsys\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_memory_stats_othersys\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.MCacheSys}} ({{bunit .MemStats.MCacheSys}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.GCSys}} ({{bunit .MemStats.GCSys}})</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.MemStats.OtherSys}} ({{bunit .MemStats.OtherSys}})</span></div>\n</div>\n{{template \"panel_debug_subhead.html\" \"panel_debug_caches\"}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_caches_topic\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_caches_user\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_caches_reply\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{.Cache.Topics}} / {{.Cache.TCap}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.Cache.Users}} / {{.Cache.UCap}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{.Cache.Replies}} / {{.Cache.RCap}}</span></div>\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_caches_topic_list\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{if .Cache.TopicListThaw}}Thawed{{else}}Sleeping{{end}}</span></div>\n\t{{template \"panel_debug_stat_q.html\"}}\n\t{{template \"panel_debug_stat_q.html\"}}\n</div>\n{{template \"panel_debug_subhead.html\" \"panel_debug_database\"}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_topics\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_users\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_replies\"}}\n\t{{template \"panel_debug_stat.html\" .Database.Topics}}\n\t{{template \"panel_debug_stat.html\" .Database.Users}}\n\t{{template \"panel_debug_stat.html\" .Database.Replies}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_profile_replies\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_activity_stream\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_likes\"}}\n\t{{template \"panel_debug_stat.html\" .Database.ProfileReplies}}\n\t{{template \"panel_debug_stat.html\" .Database.ActivityStream}}\n\t{{template \"panel_debug_stat.html\" .Database.Likes}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_attachments\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_polls\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat.html\" .Database.Attachments}}\n\t{{template \"panel_debug_stat.html\" .Database.Polls}}\n\t{{template \"panel_debug_stat_q.html\"}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_login_logs\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_reg_logs\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_mod_logs\"}}\n\t{{template \"panel_debug_stat.html\" .Database.LoginLogs}}\n\t{{template \"panel_debug_stat.html\" .Database.RegLogs}}\n\t{{template \"panel_debug_stat.html\" .Database.ModLogs}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_admin_logs\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat.html\" .Database.AdminLogs}}\n\t{{template \"panel_debug_stat_q.html\"}}\n\t{{template \"panel_debug_stat_q.html\"}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views_agents\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views_forums\"}}\n\t{{template \"panel_debug_stat.html\" .Database.Views}}\n\t{{template \"panel_debug_stat.html\" .Database.ViewsAgents}}\n\t{{template \"panel_debug_stat.html\" .Database.ViewsForums}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views_langs\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views_referrers\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_views_systems\"}}\n\t{{template \"panel_debug_stat.html\" .Database.ViewsLangs}}\n\t{{template \"panel_debug_stat.html\" .Database.ViewsReferrers}}\n\t{{template \"panel_debug_stat.html\" .Database.ViewsSystems}}\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_post_analytics\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_database_topic_analytics\"}}\n\t{{template \"panel_debug_stat_head_q.html\"}}\n\t{{template \"panel_debug_stat.html\" .Database.PostChunks}}\n\t{{template \"panel_debug_stat.html\" .Database.TopicChunks}}\n\t{{template \"panel_debug_stat_q.html\"}}\n</div>\n{{template \"panel_debug_subhead.html\" \"panel_debug_disk\"}}\n<div id=\"panel_debug\"class=\"colstack_grid\">\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_static_files\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_attachments\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_avatars\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Static}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Attachments}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Avatars}}</span></div>\n\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_log_files\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_backups\"}}\n\t{{template \"panel_debug_stat_head.html\" \"panel_debug_disk_git\"}}\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Logs}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Backups}}</span></div>\n\t<div class=\"grid_item grid_stat\"><span>{{bunit .Disk.Git}}</span></div>\n</div>{{flush}}"
  },
  {
    "path": "templates/panel_debug_stat.html",
    "content": "<div class=\"grid_item grid_stat\"><span>{{.}}</span></div>"
  },
  {
    "path": "templates/panel_debug_stat_head.html",
    "content": "<div class=\"grid_item grid_stat grid_stat_head\"><span>{{lang .}}</span></div>"
  },
  {
    "path": "templates/panel_debug_stat_head_q.html",
    "content": "<div class=\"grid_item grid_stat grid_stat_head\"><span>???</span></div>"
  },
  {
    "path": "templates/panel_debug_stat_q.html",
    "content": "<div class=\"grid_item grid_stat\"><span>?</span></div>"
  },
  {
    "path": "templates/panel_debug_subhead.html",
    "content": "<div class=\"colstack_item colstack_head colstack_sub_head\">\n\t<div class=\"rowitem\"><h2>{{lang .}}</h2></div>\n</div>{{flush}}"
  },
  {
    "path": "templates/panel_forum_edit.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{.Name}}{{lang \"panel.forum_head_suffix\"}}</h1></div>\n</div>\n<div id=\"panel_forum\"class=\"colstack_item the_form\">\n\t<form action=\"/panel/forums/edit/submit/{{.ID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_name\"}}</a></div>\n\t\t<div class=\"formitem\"><input name=\"forum_name\"type=\"text\"value=\"{{.Name}}\"placeholder=\"{{lang \"panel.forum_name_placeholder\"}}\"></div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_desc\"}}</a></div>\n\t\t<div class=\"formitem\"><input name=\"forum_desc\"type=\"text\"value=\"{{.Desc}}\"placeholder=\"{{lang \"panel.forum_desc_placeholder\"}}\"></div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_active\"}}</a></div>\n\t\t<div class=\"formitem\"><select name=\"forum_active\">\n\t\t\t<option{{if .Active}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t<option{{if not .Active}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t</select></div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_preset\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<select name=\"forum_preset\">\n\t\t\t\t<option{{if eq .Preset \"all\"}} selected{{end}} value=\"all\">{{lang \"panel.preset_everyone\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"announce\"}} selected{{end}} value=\"announce\">{{lang \"panel.preset_announcements\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"members\"}} selected{{end}} value=\"members\">{{lang \"panel.preset_member_only\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"staff\"}} selected{{end}} value=\"staff\">{{lang \"panel.preset_staff_only\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"admins\"}} selected{{end}} value=\"admins\">{{lang \"panel.preset_admin_only\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"archive\"}} selected{{end}} value=\"archive\">{{lang \"panel.preset_archive\"}}</option>\n\t\t\t\t<option{{if eq .Preset \"custom\"}} selected{{end}} value=\"custom\">{{lang \"panel.preset_custom\"}}</option>\n\t\t\t</select>\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton form_middle_button\">{{lang \"panel.forum_update_button\"}}</button></div>\n\t</div>\n\t</form>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel.forum_permissions_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"forum_quick_perms\"class=\"colstack_item rowlist formlist the_form\">\n\t{{range .Groups}}\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem editable_parent\">\n\t\t\t<a>{{.Group.Name}}</a>\n\t\t\t<input name=\"gid\"value=\"{{.Group.ID}}\"type=\"hidden\"class=\"editable_block\"data-field=\"gid\"data-type=\"hidden\"data-value=\"{{.Group.ID}}\">\n\t\t\t<span class=\"edit_fields hide_on_edit rowsmall\">{{lang \"panel.forum_edit_button\"}}</span>\n\t\t\t<div class=\"panel_floater\">\n\t\t\t\t<span data-field=\"perm_preset\"data-type=\"list\"data-value=\"{{.Preset}}\"class=\"editable_block perm_preset perm_preset_{{.Preset}}\"></span>\n\t\t\t\t<a class=\"panel_right_button has_inner_button show_on_edit\"href=\"/panel/forums/edit/perms/submit/{{$.ID}}\"><button class='panel_tag submit_edit'type='submit'>{{lang \"panel.forum_short_update_button\"}}</button></a>\n\t\t\t\t<a class=\"panel_right_button has_inner_button show_on_edit\"href=\"/panel/forums/edit/perms/{{$.ID}}-{{.Group.ID}}\"><button class='panel_tag'type='submit'>{{lang \"panel.forum_full_edit_button\"}}</button></a>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t{{end}}\n</div>\n{{if .Actions}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel.forum_actions_head\"}}</h1></div>\n</div>\n<div id=\"panel_forum_actions\"class=\"colstack_item rowlist\">\n\t{{range .Actions}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a class=\"panel_upshift\">{{.ActionName}}{{if .RunDaysAfterTopicCreation}} - {{.RunDaysAfterTopicCreation}} days after topic creation{{end}}{{if .RunDaysAfterTopicLastReply}} - {{.RunDaysAfterTopicLastReply}} days after topic last reply{{end}}</a>\n\t\t<span class=\"panel_floater\">\n\t\t\t<a href=\"/panel/forums/action/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}&ret={{$.ID}}\"class=\"panel_tag panel_right_button delete_button\"></a>\n\t\t</span>\n\t</div>\n\t{{end}}\n</div>\n{{end}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel.forum_actions_create_head\"}}</h1>\n\t</div>\n</div>\n<div id=\"panel_forum_action_create\"class=\"colstack_item the_form\">\n\t<form action=\"/panel/forums/action/create/submit/{{.ID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<!--<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_action_run_on_topic_creation\"}}</a></div>\n\t\t<div class=\"formitem\"><select name=\"action_run_on_topic_creation\">\n\t\t\t<option value=1>{{lang \"option_yes\"}}</option>\n\t\t\t<option selected value=0>{{lang \"option_no\"}}</option>\n\t\t</select></div>\n\t</div>-->\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_action_run_days_after_topic_creation\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<input name=\"action_run_days_after_topic_creation\"value=\"0\"type=\"number\">\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_action_run_days_after_topic_last_reply\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<input name=\"action_run_days_after_topic_last_reply\"value=\"0\"type=\"number\">\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_action_action\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<select name=\"action_action\">\n\t\t\t\t<option value=\"delete\"selected>{{lang \"panel.forum_action_action_delete\"}}</option>\n\t\t\t\t<option value=\"lock\">{{lang \"panel.forum_action_action_lock\"}}</option>\n\t\t\t\t<option value=\"unlock\">{{lang \"panel.forum_action_action_unlock\"}}</option>\n\t\t\t\t<option value=\"move\">{{lang \"panel.forum_action_action_move\"}}</option>\n\t\t\t</select>\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forum_action_extra\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<input name=\"action_extra\"type=\"text\">\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton form_middle_button\">{{lang \"panel.forum_action_create_button\"}}</button></div>\n\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/panel_forum_edit_perms.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{.Name}}{{lang \"panel.forum_head_suffix\"}}</h1></div>\n</div>\n<form action=\"/panel/forums/edit/perms/adv/submit/{{.ForumID}}-{{.GroupID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<div class=\"colstack_item rowlist formlist the_form panel_forum_perms\">\n\t\t{{range .Perms}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<a>{{.LangStr}}</a>\n\t\t\t\t<div class=\"to_right\">\n\t\t\t\t\t<select name=\"perm-{{.Name}}\">\n\t\t\t\t\t\t<option{{if .Toggle}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t\t<option{{if not .Toggle}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>{{end}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton form_middle_button\">{{lang \"panel.forum_update_button\"}}</button></div>\n\t\t</div>\n\t</div>\n</form>"
  },
  {
    "path": "templates/panel_forums.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel.forums_head\"}}</h1>\n\t\t<h2 class=\"hguide\">{{lang \"panel_hints_reorder\"}}</h2>\n\t</div>\n</div>\n<div id=\"panel_forums\" class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div data-fid=\"{{.ID}}\"class=\"rowitem editable_parent panel_forum_item{{if not .Desc}} forum_no_desc{{end}}\">\n\t\t<span class=\"grip\"></span>\n\t\t<span id=\"panel_forums_left_box\">\n\t\t\t{{/** TODO: Make sure the forum_active_name class is set and unset when the activity status of this forum is changed **/}}\n\t\t\t<a data-field=\"forum_name\" data-type=\"text\" class=\"editable_block forum_name{{if not .Active}} forum_active_name{{end}}\">{{.Name}}</a>\n\t\t\t<br><span data-field=\"forum_desc\" data-type=\"text\" class=\"editable_block forum_desc rowsmall\">{{.Desc}}</span>\n\t\t</span>\n\t\t<span class=\"panel_floater\">\n\t\t\t<span data-field=\"forum_active\" data-type=\"list\" class=\"panel_tag editable_block forum_active forum_active_{{if .Active}}Show\" data-value=\"1{{else}}Hide\" data-value=\"0{{end}}\" title=\"{{lang \"panel.forums_hidden\"}}\"></span>\n\t\t\t<span data-field=\"forum_preset\" data-type=\"list\" data-value=\"{{.Preset}}\" class=\"panel_tag editable_block forum_preset forum_preset_{{.Preset}}\" title=\"{{.PresetLang}}\"></span>\n\t\t</span>\n\t\t<span class=\"panel_buttons\">\n\t\t\t<a class=\"panel_tag edit_fields hide_on_edit panel_right_button edit_button\"title=\"{{lang \"panel.forums_edit_button_tooltip\"}}\" aria-label=\"{{lang \"panel.forums_edit_button_aria\"}}\"></a>\n\t\t\t<a class=\"panel_right_button has_inner_button show_on_edit\" href=\"/panel/forums/edit/submit/{{.ID}}\"><button class='panel_tag submit_edit' type='submit'>{{lang \"panel.forums_update_button\"}}</button></a>\n\t\t\t{{if gt .ID 1}}<a href=\"/panel/forums/delete/{{.ID}}?s={{$.CurrentUser.Session}}\" class=\"panel_tag panel_right_button hide_on_edit delete_button\" title=\"{{lang \"panel.forums_delete_button_tooltip\"}}\" aria-label=\"{{lang \"panel.forums_delete_button_aria\"}}\"></a>{{end}}\n\t\t\t<a href=\"/panel/forums/edit/{{.ID}}\" class=\"panel_tag panel_right_button has_inner_button show_on_edit\"><button>{{lang \"panel.forums_full_edit_button\"}}</button></a>\n\t\t</span>\n\t</div>\n\t{{end}}\n</div>\n<div class=\"colstack_item rowlist panel_submitrow\">\n\t<div class=\"rowitem\"><button id=\"panel_forums_order_button\" class=\"formbutton\">{{lang \"panel.forums_update_order_button\"}}</button></div>\n</div>\n\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel.forums_create_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/forums/create/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forums_create_name_label\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"name\" type=\"text\" placeholder=\"{{lang \"panel.forums_create_name\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forums_create_desc_label\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"desc\" type=\"text\" placeholder=\"{{lang \"panel.forums_create_desc\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forums_active_label\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"active\">\n\t\t\t\t<option selected value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t<option value=0>{{lang \"option_no\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel.forums_preset_label\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"preset\">\n\t\t\t\t<option selected value=\"all\">{{lang \"panel.preset_everyone\"}}</option>\n\t\t\t\t<option value=\"announce\">{{lang \"panel.preset_announcements\"}}</option>\n\t\t\t\t<option value=\"members\">{{lang \"panel.preset_member_only\"}}</option>\n\t\t\t\t<option value=\"staff\">{{lang \"panel.preset_staff_only\"}}</option>\n\t\t\t\t<option value=\"admins\">{{lang \"panel.preset_admin_only\"}}</option>\n\t\t\t\t<option value=\"archive\">{{lang \"panel.preset_archive\"}}</option>\n\t\t\t\t<option value=\"custom\">{{lang \"panel.preset_custom\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton\">{{lang \"panel.forums_create_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/panel_group_edit.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"colstack panel_stack\">\n{{template \"panel_group_menu.html\" . }}\n<main class=\"colstack_right\">\n{{template \"panel_before_head.html\" . }}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{.Name}}{{lang \"panel_group_head_suffix\"}} - #{{.ID}}</h1></div>\n\t</div>\n\t<div id=\"panel_group\" class=\"colstack_item the_form\">\n\t\t<form action=\"/panel/groups/edit/submit/{{.ID}}?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"name\" type=\"text\" value=\"{{.Name}}\" placeholder=\"{{lang \"panel_group_name_placeholder\"}}\"></div>\n\t\t</div>\n\t\t{{if .CurrentUser.Perms.EditGroup}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_type\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"type\"{{if .DisableRank}} disabled{{end}}>\n\t\t\t\t\t<option value=\"Guest\"{{if eq .Rank \"Guest\"}} selected{{end}} disabled>{{lang \"panel_groups_rank_guest\"}}</option>\n\t\t\t\t\t<option value=\"Member\"{{if eq .Rank \"Member\"}} selected{{end}}>{{lang \"panel_groups_rank_member\"}}</option>\n\t\t\t\t\t<option value=\"Mod\"{{if eq .Rank \"Mod\"}} selected{{end}}{{if not .CurrentUser.Perms.EditGroupSuperMod}} disabled{{end}}>{{lang \"panel_groups_rank_mod\"}}</option>\n\t\t\t\t\t<option value=\"Admin\"{{if eq .Rank \"Admin\"}} selected{{end}}{{if not .CurrentUser.Perms.EditGroupAdmin}} disabled{{end}}>{{lang \"panel_groups_rank_admin\"}}</option>\n\t\t\t\t\t<option value=\"Banned\"{{if eq .Rank \"Banned\"}} selected{{end}}>{{lang \"panel_groups_rank_banned\"}}</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>{{end}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_tag\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"tag\" type=\"text\" value=\"{{.Tag}}\" placeholder=\"{{lang \"panel_group_tag_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow form_button_row\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton\">{{lang \"panel_group_update_button\"}}</button></div>\n\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/panel_group_edit_perms.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"colstack panel_stack\">\n{{template \"panel_group_menu.html\" . }}\n<main class=\"colstack_right\">\n{{template \"panel_before_head.html\" . }}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{.Name}}{{lang \"panel_group_head_suffix\"}} - #{{.ID}}</h1></div>\n\t</div>\n\t<form action=\"/panel/groups/edit/perms/submit/{{.ID}}?s={{.CurrentUser.Session}}\" method=\"post\">\n\t{{if .CurrentUser.Perms.EditGroupLocalPerms}}\n\t<div class=\"colstack_item rowlist formlist the_form panel_group_perms\">\n\t\t{{range .LocalPerms}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<a>{{.LangStr}}</a>\n\t\t\t\t<div class=\"to_right\">\n\t\t\t\t\t<select name=\"perm-{{.Name}}\">\n\t\t\t\t\t\t<option{{if .Toggle}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t\t<option{{if not .Toggle}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t{{end}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton form_middle_button\">{{lang \"panel_group_update_button\"}}</button></div>\n\t\t</div>\n\t</div>\n\t{{end}}\n\t{{if .CurrentUser.Perms.EditGroupGlobalPerms}}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"panel_group_extended_permissions\"}}</h1></div>\n\t</div>\n\t<div class=\"colstack_item rowlist formlist the_form panel_group_perms\">\n\t\t{{range .GlobalPerms}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<a>{{.LangStr}}</a>\n\t\t\t\t<div class=\"to_right\">\n\t\t\t\t\t<select name=\"perm-{{.Name}}\">\n\t\t\t\t\t\t<option{{if .Toggle}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t\t<option{{if not .Toggle}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t{{end}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton form_middle_button\">{{lang \"panel_group_update_button\"}}</button></div>\n\t\t</div>\n\t</div>\n\t{{end}}\n\t{{if .CurrentUser.Perms.EditGroupGlobalPerms}}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"panel_group_mod_permissions\"}}</h1></div>\n\t</div>\n\t<div class=\"colstack_item rowlist formlist the_form panel_group_perms\">\n\t\t{{range .ModPerms}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<a>{{.LangStr}}</a>\n\t\t\t\t<div class=\"to_right\">\n\t\t\t\t\t<select name=\"perm-{{.Name}}\">\n\t\t\t\t\t\t<option{{if .Toggle}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t\t<option{{if not .Toggle}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t\t</select>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t{{end}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton form_middle_button\">{{lang \"panel_group_update_button\"}}</button></div>\n\t\t</div>\n\t</div>\n\t{{end}}\n\t</form>\n</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/panel_group_edit_promotions.html",
    "content": "{{template \"header.html\" . }}\n<div class=\"colstack panel_stack\">\n{{template \"panel_group_menu.html\" . }}\n<main class=\"colstack_right\">\n{{template \"panel_before_head.html\" . }}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{.Name}}{{lang \"panel_group_head_suffix\"}}</h1></div>\n\t</div>\n\t<form action=\"/panel/groups/edit/promotions/submit/{{.ID}}?s={{.CurrentUser.Session}}\" method=\"post\">\n\t<div class=\"colstack_item panel_group_promotions\">\n\t\t{{range .Promotions}}\n\t\t<div class=\"rowitem\">\n\t\t\t<a href=\"#p-{{.ID}}\">{{.FromGroup.Name}} -> {{.ToGroup.Name}}{{if .TwoWay}} (two way){{end}}</a>\n\t\t\t{{if .Level}}<span>&nbsp;-&nbsp;{{lang \"panel_group_promotions_row_level_prefix\"}}{{.Level}}</span>{{end}}\n\t\t\t{{if .Posts}}<span>&nbsp;-&nbsp;{{lang \"panel_group_promotions_row_posts_prefix\"}}{{.Posts}}</span>{{end}}\n\t\t\t{{if .RegisteredFor}}<span>&nbsp;-&nbsp;{{langf \"panel_group_promotions_row_registered_minutes\" .RegisteredFor}}</span>{{end}}\n\t\t\t<div class=\"to_right\">\n\t\t\t\t<a href=\"/panel/groups/promotions/delete/submit/{{$.ID}}-{{.ID}}?s={{$.CurrentUser.Session}}\"><button form=\"nn\">{{lang \"panel_group_promotions_row_delete_button\"}}</button></a>\n\t\t\t</div>\n\t\t</div>{{end}}\n\t\t<div class=\"rowitem\">\n\t\t\t<button name=\"panel-button\" class=\"formbutton form_middle_button\">{{lang \"panel_group_update_button\"}}</button>\n\t\t</div>\n\t</div>\n\t</form>\n\n{{if .CurrentUser.Perms.EditGroup}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_group_promotions_create_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/groups/promotions/create/submit/{{.ID}}?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotions_from\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"from\">\n\t\t\t\t{{range .Groups}}<option value=\"{{.ID}}\">{{.Name}}</option>{{end}}\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotions_to\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"to\">\n\t\t\t\t{{range .Groups}}<option value=\"{{.ID}}\">{{.Name}}</option>{{end}}\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotions_two_way\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"two-way\" disabled>\n\t\t\t\t\t<option value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t<option selected value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotions_level\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"level\" type=\"number\" value=\"0\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotions_posts\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"posts\" type=\"number\" value=\"0\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_group_promotion_reg_for\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<input name=\"reg_months\" type=\"number\" value=\"0\">{{lang \"panel_group_promotion_reg_months_suffix\"}}<br>\n\t\t\t\t<input name=\"reg_days\" type=\"number\" value=\"0\">{{lang \"panel_group_promotion_reg_days_suffix\"}}<br>\n\t\t\t\t<input name=\"reg_hours\" type=\"number\" value=\"0\">{{lang \"panel_group_promotion_reg_hours_suffix\"}}\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow form_button_row\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton\">{{lang \"panel_group_promotions_create_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>\n{{end}}\n\n</main>\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/panel_group_menu.html",
    "content": "<nav class=\"colstack_left\"aria-label=\"{{lang \"panel_menu_aria\"}}\">\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><a href=\"/panel/groups/edit/{{.ID}}\">{{lang \"panel_group_menu_head\"}}</a></div>\n\t</div>\n\t<div class=\"colstack_item rowmenu\">\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/{{.ID}}\">{{lang \"panel_group_menu_general\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/promotions/{{.ID}}\">{{lang \"panel_group_menu_promotions\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/perms/{{.ID}}\">{{lang \"panel_group_menu_permissions\"}}</a></div>\n\t</div>\n{{template \"panel_inner_menu.html\" . }}\n</nav>"
  },
  {
    "path": "templates/panel_groups.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_groups_head\"}}</h1></div>\n</div>\n<div id=\"panel_groups\" class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a{{if .CanEdit}} href=\"/panel/groups/edit/{{.ID}}\"{{end}} class=\"panel_upshift\">{{.Name}}</a>\n\t\t<span class=\"panel_floater\">\n\t\t\t{{if .RankClass}}<a class=\"panel_tag panel_rank_tag panel_rank_tag_{{.RankClass}}\" title=\"{{.Rank}}\" aria-label=\"{{lang \"panel_groups_rank_prefix\"}}{{.Rank}}\"></a>\n\t\t\t{{else}}<span class=\"panel_tag\">{{.Rank}}</span>{{end}}\n\n\t\t\t{{if .CanEdit}}<a href=\"/panel/groups/edit/{{.ID}}\" class=\"panel_tag panel_right_button edit_button\" aria-label=\"{{lang \"panel_groups_edit_group_button_aria\"}}\"></a>{{end}}\n\t\t</span>\n\t</div>\n\t{{end}}\n</div>\n{{template \"paginator.html\" . }}\n\t\n{{if .CurrentUser.Perms.EditGroup}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_groups_create_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/groups/create/?s={{.CurrentUser.Session}}\" method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_groups_create_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"name\" type=\"text\" placeholder=\"{{lang \"panel_groups_create_name_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_groups_create_type\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"type\"{{if not .CurrentUser.Perms.EditGroupGlobalPerms}} disabled{{end}}>\n\t\t\t\t\t<option selected>Member</option>\n\t\t\t\t\t<option{{if not .CurrentUser.Perms.EditGroupSuperMod}} disabled{{end}}>Mod</option>\n\t\t\t\t\t<option{{if not .CurrentUser.Perms.EditGroupAdmin}} disabled{{end}}>Admin</option>\n\t\t\t\t\t<option>Banned</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_groups_create_tag\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"tag\" type=\"text\"></div>\n\t\t</div>\n\t\t<div class=\"formrow form_button_row\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton\">{{lang \"panel_groups_create_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>\n{{end}}"
  },
  {
    "path": "templates/panel_inner_menu.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"/panel/\">{{lang \"panel_menu_head\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/users/\">{{lang \"panel_menu_users\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Users}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/groups/\">{{lang \"panel_menu_groups\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Groups}})</a>\n\t</div>\n\t{{if .CurrentUser.Perms.ManageForums}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/forums/\">{{lang \"panel_menu_forums\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Forums}})</a>\n\t</div>{{end}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/pages/\">{{lang \"panel_menu_pages\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Pages}})</a>\n\t</div>\n\t{{if .CurrentUser.Perms.EditSettings}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/settings/\">{{lang \"panel_menu_settings\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Settings}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/settings/word-filters/\">{{lang \"panel_menu_word_filters\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.WordFilters}})</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.Perms.ManageThemes}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/themes/\">{{lang \"panel_menu_themes\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Themes}})</a>\n\t</div>\n\t{{if eq .Zone \"themes\"}}\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/themes/menus/\">{{lang \"panel_menu_menus\"}}</a></div>\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/themes/widgets/\">{{lang \"panel_menu_widgets\"}}</a></div>\n\t{{end}}\n\t{{end}}\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"#\">{{lang \"panel_menu_events\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/analytics/views/\">{{lang \"panel_menu_stats\"}}</a>\n\t</div>\n\t{{if eq .Zone \"analytics\"}}\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/posts/\">{{lang \"panel_menu_stats_posts\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/topics/\">{{lang \"panel_menu_stats_topics\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/forums/\">{{lang \"panel_menu_stats_forums\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/routes/\">{{lang \"panel_menu_stats_routes\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/routes-perf/\">{{lang \"panel_menu_stats_routes_perf\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/agents/\">{{lang \"panel_menu_stats_agents\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/systems/\">{{lang \"panel_menu_stats_systems\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/langs/\">{{lang \"panel_menu_stats_languages\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/referrers/\">{{lang \"panel_menu_stats_referrers\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/memory/\">{{lang \"panel_menu_stats_memory\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/active-memory/\">{{lang \"panel_menu_stats_active_memory\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/perf/\">{{lang \"panel_menu_stats_perf\"}}</a>\n\t\t</div>\n\t{{end}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/forum/{{.ReportForumID}}\">{{lang \"panel_menu_reports\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Reports}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/logs/mod/\">{{lang \"panel_menu_logs\"}}</a>\n\t</div>\n\t{{if eq .Zone \"logs\"}}\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/regs/\">{{lang \"panel_menu_logs_registrations\"}}</a></div>\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/mod/\">{{lang \"panel_menu_logs_moderators\"}}</a></div>\n\t\t{{if .CurrentUser.Perms.ViewAdminLogs}}<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/admin/\">{{lang \"panel_menu_logs_administrators\"}}</a></div>{{end}}\n\t{{end}}\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"#\">{{lang \"panel_menu_system\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t{{if .CurrentUser.Perms.ManagePlugins}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/plugins/\">{{lang \"panel_menu_plugins\"}}</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.IsSuperAdmin}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/backups/\">{{lang \"panel_menu_backups\"}}</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.IsAdmin}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/debug/\">{{lang \"panel_menu_debug\"}}</a>\n\t</div>\n\t{{if .DebugAdmin}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/debug/tasks/\">{{lang \"panel_menu_debug\"}}</a>\n\t</div>{{end}}\n\t{{end}}\n</div>"
  },
  {
    "path": "templates/panel_menu.html",
    "content": "<nav class=\"colstack_left\" aria-label=\"{{lang \"panel_menu_aria\"}}\">{{template \"panel_inner_menu.html\" . }}</nav>"
  },
  {
    "path": "templates/panel_modlogs.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_logs_mod_head\"}}</h1></div>\n</div>\n<div id=\"panel_modlogs\"class=\"colstack_item rowlist loglist\">\n\t{{range .Logs}}\n\t<div class=\"rowitem panel_compactrow\">\n\t\t<span class=\"to_left\">\n\t\t\t<span>{{.Action}}</span>\n\t\t\t{{if $.CurrentUser.Perms.ViewIPs}}<br><small title=\"{{.IP}}\">{{.IP}}</small>{{end}}\n\t\t</span>\n\t\t<span class=\"to_right\">\n\t\t\t<span title=\"{{.DoneAt}}\">{{.DoneAt}}</span>\n\t\t</span>\n\t\t<div style=\"clear:both;\"></div>\n\t</div>\n\t{{else}}<div class=\"rowitem rowmsg\"><a>{{lang \"panel_logs_mod_no_logs\"}}</a></div>{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/panel_pages.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_pages_head\"}}</h1></div>\n</div>\n<div id=\"panel_pages\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow\">\n\t\t<a href=\"/panel/pages/edit/{{.ID}}\"class=\"panel_upshift\">{{.Title}}</a>&nbsp;<a href=\"/pages/{{.Name}}\">[{{.Name}}]</a>\n\t\t<span class=\"panel_buttons\">\n\t\t\t<a href=\"/panel/pages/edit/{{.ID}}\"class=\"panel_tag panel_right_button edit_button\"aria-label=\"{{lang \"panel_pages_edit_button_aria\"}}\"></a>\n\t\t\t<a href=\"/panel/pages/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button delete_button\"aria-label=\"{{lang \"panel_pages_delete_button_aria\"}}\"></a>\n\t\t</span>\n\t</div>\n\t{{else}}\n\t<div class=\"rowitem rowmsg\">\n\t\t<a>{{lang \"panel_pages_no_pages\"}}</a>\n\t</div>\n\t{{end}}\n</div>\n\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_pages_create_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/pages/create/submit/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_pages_create_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"name\"type=\"text\"placeholder=\"{{lang \"panel_pages_create_name_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_pages_create_title\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"title\"type=\"text\"placeholder=\"{{lang \"panel_pages_create_title_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<textarea name=\"body\"placeholder=\"{{lang \"panel_pages_create_body_placeholder\"}}\"></textarea>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton form_middle_button\">{{lang \"panel_pages_create_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/panel_pages_edit.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_pages_edit_head\"}}</h1></div>\n</div>\n<form action=\"/panel/pages/edit/submit/{{.Page.ID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<div id=\"panel_page_edit_item\"class=\"colstack_item the_form\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_pages_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"name\"type=\"text\"value=\"{{.Page.Name}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_pages_title\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"title\"type=\"text\"value=\"{{.Page.Title}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<textarea name=\"body\">{{.Page.Body}}</textarea>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton\">{{lang \"panel_pages_edit_update_button\"}}</button></div>\n\t\t</div>\n\t</div>\n</form>"
  },
  {
    "path": "templates/panel_plugins.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_plugins_head\"}}</h1></div>\n</div>\n<div id=\"panel_plugins\"class=\"colstack_item complex_rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem editable_parent\">\n\t\t<span class=\"panel_plugin_meta\">\n\t\t\t<a{{if .URL}} href=\"{{.URL}}\"{{end}}class=\"editable_block\"class=\"panel_upshift\">{{.Name}}</a><br>\n\t\t\t<small style=\"margin-left:2px;\">{{lang \"panel_plugins_author_prefix\"}}{{.Author}}</small>\n\t\t</span>\n\t\t<span class=\"to_right\">\n\t\t\t{{if .Settings}}<a href=\"/panel/settings/\"class=\"panel_tag panel_plugin_settings panel_right_button\">{{lang \"panel_plugins_settings\"}}</a>{{end}}\n\t\t\t{{if .Active}}<a href=\"/panel/plugins/deactivate/{{.UName}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_plugin_deactivate panel_right_button\">{{lang \"panel_plugins_deactivate\"}}</a>\n\t\t\t{{else if .Installable}}\n\t\t\t\t{{/** TODO: Write a custom template interpreter to fix this nonsense **/}}\n\t\t\t\t{{if .Installed}}<a href=\"/panel/plugins/activate/{{.UName}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_plugin_activate panel_right_button\">{{lang \"panel_plugins_activate\"}}</a>{{else}}<a href=\"/panel/plugins/install/{{.UName}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_plugin_install panel_right_button\">{{lang \"panel_plugins_install\"}}</a>{{end}}\n\t\t\t{{else}}<a href=\"/panel/plugins/activate/{{.UName}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_plugin_activate panel_right_button\">{{lang \"panel_plugins_activate\"}}</a>{{end}}\n\t\t</span>\n\t</div>\n\t{{end}}\n</div>"
  },
  {
    "path": "templates/panel_reglogs.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_logs_reg_head\"}}</h1></div>\n</div>\n<div id=\"panel_reglogs\"class=\"colstack_item rowlist loglist\">\n\t{{range .Logs}}\n\t<div class=\"rowitem panel_compactrow{{if not .Success}} bg_red{{end}}\">\n\t\t<span>{{if not .Success}}{{lang \"panel_logs_reg_attempt\"}}{{end}}{{.Username}}{{if .Email}} ({{lang \"panel_logs_reg_email\"}}{{.Email}}){{end}}{{if .ParsedReason}} ({{lang \"panel_logs_reg_reason\"}}{{.ParsedReason}}){{end}}</span>\n\t\t<div class=\"logdetail\">\n\t\t\t{{if $.CurrentUser.Perms.ViewIPs}}<small class=\"to_left\"title=\"{{.IP}}\">{{.IP}}</small>{{end}}\n\t\t\t<span class=\"to_right\"><small title=\"{{.DoneAt}}\">{{.DoneAt}}</small></span>\n\t\t\t<div style=\"clear:both;\"></div>\n\t\t</div>\n\t</div>\n\t{{else}}<div class=\"rowitem rowmsg\"><a>{{lang \"panel_logs_reg_no_logs\"}}</a></div>{{end}}\n</div>\n{{template \"paginator.html\" . }}"
  },
  {
    "path": "templates/panel_setting.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{.Setting.FriendlyName}}</h1></div>\n</div>\n<div id=\"panel_setting\"class=\"colstack_item the_form\">\n\t<form action=\"/panel/settings/edit/submit/{{.Setting.Name}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t{{if eq .Setting.Type \"list\"}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_setting_value\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"value\">\n\t\t\t\t{{range .ItemList}}<option{{if .Selected}} selected{{end}} value=\"{{.Value}}\">{{.Label}}</option>{{end}}\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t{{else if eq .Setting.Type \"bool\"}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_setting_value\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"value\">\n\t\t\t\t\t<option{{if eq .Setting.Content \"1\"}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t<option{{if eq .Setting.Content \"0\"}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t{{else if eq .Setting.Type \"textarea\"}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><textarea name=\"value\">{{.Setting.Content}}</textarea></div>\n\t\t</div>\n\t\t{{else}}<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_setting_value\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"value\"type=\"text\"value=\"{{.Setting.Content}}\"></div>\n\t\t</div>{{end}}\n\t\t<div class=\"formrow form_button_row\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\"class=\"formbutton\">{{lang \"panel_setting_update_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/panel_settings.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_settings_head\"}}</h1></div>\n</div>\n<div id=\"panel_settings\"class=\"colstack_item rowlist bgavatars micro_grid\">\n\t{{range .Something}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/settings/edit/{{.Name}}\"class=\"editable_block panel_upshift\"title=\"{{.FriendlyName}}\">{{.FriendlyName}}</a>\n\t\t<a class=\"panel_compacttext to_right\">{{.Content}}</a>\n\t</div>\n\t{{end}}\n</div>"
  },
  {
    "path": "templates/panel_themes.html",
    "content": "\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_primary_themes\"}}</h1></div>\n\t</div>\n\t<div id=\"panel_primary_themes\"class=\"colstack_item panel_themes complex_rowlist\">\n\t\t{{range .PrimaryThemes}}\n\t\t<div class=\"theme_row rowitem editable_parent\"{{if .FullImage}}style=\"background-image:url('/s/{{.FullImage}}');background-position:center;background-size:50%;background-repeat:no-repeat;\"{{end}}>\n\t\t\t<span style=\"float:left;\">\n\t\t\t\t<a href=\"/panel/themes/{{.Name}}\"class=\"editable_block\"style=\"font-size:17px;\">{{.FriendlyName}}</a><br>\n\t\t\t\t<small class=\"panel_theme_author\" style=\"margin-left:2px;\">{{lang \"panel_themes_author_prefix\"}}<a href=\"//{{.URL}}\">{{.Creator}}</a></small>\n\t\t\t</span>\n\t\t\t<span class=\"panel_floater\">\n\t\t\t\t{{if .MobileFriendly}}<span class=\"panel_tag panel_theme_mobile\"title=\"{{lang \"panel_themes_mobile_friendly_tooltip\"}}\" aria-label=\"{{lang \"panel_themes_mobile_friendly_aria\"}}\">📱</span>{{end}}\n\t\t\t\t{{if .Tag}}<span class=\"panel_tag panel_theme_tag\">{{.Tag}}</span>{{end}}\n\t\t\t\t{{if .Active}}<span class=\"panel_tag panel_right_button\">{{lang \"panel_themes_default\"}}</span>{{else}}<a href=\"/panel/themes/default/{{.Name}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button\">{{lang \"panel_themes_make_default\"}}</a>{{end}}\n\t\t\t</span>\n\t\t</div>\n\t\t{{end}}\n\t</div>\n\t{{if .VariantThemes}}\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_variant_themes\"}}</h1></div>\n\t</div>\n\t<div id=\"panel_variant_themes\"class=\"colstack_item panel_themes\">\n\t\t{{range .VariantThemes}}\n\t\t<div class=\"theme_row rowitem editable_parent\"{{if .FullImage}}style=\"background-image:url('/s/{{.FullImage}}');background-position:center;background-size:50%;background-repeat:no-repeat;\"{{end}}>\n\t\t\t<span style=\"float:left;\">\n\t\t\t\t<a href=\"/panel/themes/{{.Name}}\"class=\"editable_block\"style=\"font-size:17px;\">{{.FriendlyName}}</a><br>\n\t\t\t\t<small class=\"panel_theme_author\"style=\"margin-left:2px;\">{{lang \"panel_themes_author_prefix\"}}<a href=\"//{{.URL}}\">{{.Creator}}</a></small>\n\t\t\t</span>\n\t\t\t<span class=\"panel_floater\">\n\t\t\t\t{{if .MobileFriendly}}<span class=\"panel_tag panel_theme_mobile\"title=\"{{lang \"panel_themes_mobile_friendly_tooltip\"}}\" aria-label=\"{{lang \"panel_themes_mobile_friendly_aria\"}}\">📱</span>{{end}}\n\t\t\t\t{{if .Tag}}<span class=\"panel_tag panel_theme_tag\">{{.Tag}}</span>{{end}}\n\t\t\t\t{{if .Active}}<span class=\"panel_tag panel_right_button\">{{lang \"panel_themes_default\"}}</span>{{else}}<a href=\"/panel/themes/default/{{.Name}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button\">{{lang \"panel_themes_make_default\"}}</a>{{end}}\n\t\t\t</span>\n\t\t</div>\n\t\t{{end}}\n\t</div>\n\t{{end}}"
  },
  {
    "path": "templates/panel_themes_menus.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_menus_head\"}}</h1></div>\n</div>\n<div id=\"panel_menus\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a href=\"/panel/themes/menus/edit/{{.ID}}\"class=\"editable_block panel_upshift\">{{if .Name}}{{.Name}} - {{end}}#{{.ID}}</a>\n\t\t<a class=\"panel_compacttext to_right\">{{.ItemCount}}{{lang \"panel_themes_menus_items_suffix\"}}</a>\n\t</div>\n\t{{end}}\n</div>"
  },
  {
    "path": "templates/panel_themes_menus_item_edit.html",
    "content": "{{/** TODO: Set the order based on the order here **/}}\n{{/** TODO: Write the backend code and JS code for saving this menu **/}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_menus_edit_head\"}}</h1></div>\n</div>\n<form action=\"/panel/themes/menus/item/edit/submit/{{.Item.ID}}?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<div id=\"panel_themes_menu_item_edit\"class=\"colstack_item the_form\">\n\t\t{{/** TODO: Let an admin move a menu item from one menu to another? **/}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-name\"type=\"text\"value=\"{{.Item.Name}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_htmlid\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-htmlid\"type=\"text\"value=\"{{.Item.HTMLID}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_cssclass\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-cssclass\"type=\"text\"value=\"{{.Item.CSSClass}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_position\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"item-position\">\n\t\t\t\t\t<option{{if eq .Item.Position \"left\"}} selected{{end}} value=\"left\">left</option>\n\t\t\t\t\t<option{{if eq .Item.Position \"right\"}} selected{{end}} value=\"right\">right</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_path\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-path\"type=\"text\"value=\"{{.Item.Path}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_aria\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-aria\"type=\"text\"value=\"{{.Item.Aria}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_tooltip\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-tooltip\"type=\"text\"value=\"{{.Item.Tooltip}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_tmplname\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-tmplname\"type=\"text\"value=\"{{.Item.TmplName}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_permissions\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"item-permissions\">\n\t\t\t\t<option value=\"everyone\">{{lang \"panel_themes_menus_everyone\" }}</option>\n\t\t\t\t<option{{if .Item.GuestOnly}} selected{{end}} value=\"guest-only\">{{lang \"panel_themes_menus_guestonly\"}}</option>\n\t\t\t\t<option{{if .Item.MemberOnly}} selected{{end}} value=\"member-only\">{{lang \"panel_themes_menus_memberonly\"}}</option>\n\t\t\t\t<option{{if .Item.SuperModOnly}} selected{{end}} value=\"supermod-only\">{{lang \"panel_themes_menus_supermodonly\"}}</option>\n\t\t\t\t<option{{if .Item.AdminOnly}} selected{{end}} value=\"admin-only\">{{lang \"panel_themes_menus_adminonly\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<button name=\"panel-button\"class=\"formbutton\">{{lang \"panel_themes_menus_edit_update_button\"}}</button>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</form>"
  },
  {
    "path": "templates/panel_themes_menus_items.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\">\n\t\t<h1>{{lang \"panel_themes_menus_items_head\"}}</h1>\n\t\t<h2 class=\"hguide\">{{lang \"panel_hints_reorder\"}}</h2>\n\t</div>\n</div>\n<div id=\"panel_menu_item_holder\"class=\"colstack_item rowlist\">\n\t{{range .ItemList}}\n\t<div class=\"panel_menu_item rowitem panel_compactrow editable_parent\"data-miid=\"{{.ID}}\">\n\t\t<span class=\"grip\"></span>\n\t\t<a href=\"/panel/themes/menus/item/edit/{{.ID}}\"class=\"editable_block panel_upshift\">{{.Name}}</a>\n\t\t<span class=\"panel_buttons\">\n\t\t\t<a href=\"/panel/themes/menus/item/edit/{{.ID}}\"class=\"panel_tag panel_right_button edit_button\"aria-label=\"{{lang \"panel_themes_menus_items_edit_button_aria\"}}\"></a>\n\t\t\t<a href=\"/panel/themes/menus/item/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button delete_button\"aria-label=\"{{lang \"panel_themes_menus_items_delete_button_aria\"}}\"></a>\n\t\t</span>\n\t</div>{{end}}\n</div>\n<div class=\"colstack_item rowlist panel_submitrow\">\n\t<div class=\"rowitem\"><button id=\"panel_menu_items_order_button\" class=\"formbutton\">{{lang \"panel_themes_menus_items_update_button\"}}</button></div>\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_menus_create_head\"}}</h1></div>\n</div>\n<form action=\"/panel/themes/menus/item/create/submit/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t<input name=\"mid\"value=\"{{.MenuID}}\"type=\"hidden\">\n\t<div id=\"panel_themes_menu_item_create\"class=\"colstack_item the_form\">\n\t\t{{/** TODO: Let an admin move a menu item from one menu to another? **/}}\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-name\" type=\"text\" placeholder=\"{{lang \"panel_themes_menus_name_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_htmlid\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-htmlid\" type=\"text\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_cssclass\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-cssclass\" type=\"text\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_position\"}}</a></div>\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<select name=\"item-position\">\n\t\t\t\t\t<option selected value=\"left\">left</option>\n\t\t\t\t\t<option value=\"right\">right</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_path\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-path\" type=\"text\" value=\"/\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_aria\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-aria\" type=\"text\" placeholder=\"{{lang \"panel_themes_menus_aria_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_tooltip\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"item-tooltip\" type=\"text\" placeholder=\"{{lang \"panel_themes_menus_tooltip_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_menus_permissions\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"item-permissions\">\n\t\t\t\t<option selected value=\"everyone\">{{lang \"panel_themes_menus_everyone\" }}</option>\n\t\t\t\t<option value=\"guest-only\">{{lang \"panel_themes_menus_guestonly\"}}</option>\n\t\t\t\t<option value=\"member-only\">{{lang \"panel_themes_menus_memberonly\"}}</option>\n\t\t\t\t<option value=\"supermod-only\">{{lang \"panel_themes_menus_supermodonly\"}}</option>\n\t\t\t\t<option value=\"admin-only\">{{lang \"panel_themes_menus_adminonly\"}}</option>\n\t\t\t</select></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton\">{{lang \"panel_themes_menus_create_button\"}}</button></div>\n\t\t</div>\n\t</div>\n</form>"
  },
  {
    "path": "templates/panel_themes_widgets.html",
    "content": "{{/**\ntype Widget struct {\n\tEnabled  bool\n\tLocation string // Coming Soon: overview, topics, topic / topic_view, forums, forum, global\n\tPosition int\n\tBody     string\n\tSide     string\n\tType     string\n\tLiteral  bool\n}\n**/}}\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_themes_widgets_head\"}}</h1></div>\n</div>\n{{range $name, $dock := .Docks}}\n<div class=\"colstack_item colstack_head colstack_sub_head\">\n\t<div class=\"rowitem\"><h2>{{$name}}</h2></div>\n</div>\n<div id=\"panel_widgets_{{$name}}\" class=\"colstack_item rowlist panel_widgets\">\n\t{{range $widget := $dock}}\n\t<div id=\"widget_{{$widget.ID}}\" class=\"rowitem panel_compactrow editable_parent widget_item{{if not .Enabled}} bg_red{{end}}\">\n\t\t<div class=\"widget_normal editable_block hide_on_block_edit\">\n\t\t\t<a href=\"/panel/themes/widgets/edit/{{$widget.ID}}\" class=\"panel_upshift\">{{$widget.Type}} <span class=\"widget_disabled\">({{lang \"panel_themes_widgets_disabled\"}})</span></a>\n\t\t\t<a class=\"panel_compacttext to_right\">{{$widget.Location}}</a>\n\t\t</div>\n\t\t<div class=\"widget_edit show_on_block_edit\">\n\t\t\t<form action=\"/panel/themes/widgets/edit/submit/{{$widget.ID}}\"method=\"post\">\n\t\t\t<input class=\"wside\"name=\"wside\"value=\"{{$name}}\"type=\"hidden\">\n\t\t\t{{template \"panel_themes_widgets_widget.html\" $widget }}\n\t\t\t</form>\n\t\t</div>\n\t</div>\n\t{{end}}\n\t<div class=\"rowitem panel_compactrow editable_parent widget_new\">\n\t\t<a href=\"#\" data-dock=\"{{$name}}\" class=\"editable_block panel_upshift\">{{lang \"panel_themes_widgets_new\"}}</a>\n\t</div>\n</div>\n{{end}}\n<div id=\"widgetTmpl\">\n\t<div class=\"rowitem panel_compactrow editable_parent widget_item blank_widget bg_red\">\n\t\t<div class=\"widget_normal editable_block hide_on_block_edit\">\n\t\t\t<a href=\"#\"class=\"panel_upshift\">{{.BlankWidget.Type}} <span class=\"widget_disabled\">({{lang \"panel_themes_widgets_disabled\"}})</span></a>\n\t\t\t<a class=\"panel_compacttext to_right\">{{.BlankWidget.Location}}</a>\n\t\t</div>\n\t\t<div class=\"widget_edit show_on_block_edit\">\n\t\t\t<form action=\"/panel/themes/widgets/create/submit/\" method=\"post\">\n\t\t\t<input name=\"s\"value=\"{{.CurrentUser.Session}}\"type=\"hidden\">\n\t\t\t<input class=\"wside\"name=\"wside\"value=\"\"type=\"hidden\">\n\t\t\t{{template \"panel_themes_widgets_widget.html\" .BlankWidget }}\n\t\t\t</form>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/panel_themes_widgets_widget.html",
    "content": "<div class=\"formrow\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_type\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<select class=\"wtype_sel\" name=\"wtype\">\n\t\t\t<option value=\"about\"{{if eq .Type \"about\"}}selected{{end}}>{{lang \"panel_themes_widgets_type_about\"}}</option>\n\t\t\t<option value=\"simple\"{{if eq .Type \"simple\"}}selected{{end}}>{{lang \"panel_themes_widgets_type_simple\"}}</option>\n\t\t\t<option value=\"wol\"{{if eq .Type \"wol\"}}selected{{end}}>{{lang \"panel_themes_widgets_type_wol\"}}</option>\n\t\t\t<option value=\"wol_context\"{{if eq .Type \"wol_context\"}}selected{{end}}>{{lang \"panel_themes_widgets_type_wol_context\"}}</option>\n\t\t\t<option value=\"search_and_filter\"{{if eq .Type \"search_and_filter\"}}selected{{end}}>{{lang \"panel_themes_widgets_type_search_and_filter\"}}</option>\n\t\t</select>\n\t</div>\n</div>\n<div class=\"formrow\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_enabled\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<select name=\"wenabled\">\n\t\t\t<option{{if .Enabled}} selected{{end}} value=1>{{lang \"option_yes\"}}</option>\n\t\t\t<option{{if not .Enabled}} selected{{end}} value=0>{{lang \"option_no\"}}</option>\n\t\t</select>\n\t</div>\n</div>\n<div class=\"formrow\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_location\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<input name=\"wlocation\"value=\"{{.Location}}\">\n\t</div>\n</div>\n<div class=\"wtypes wtype_{{.Type}}\">\n<div class=\"formrow w_simple w_about\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_name\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<input name=\"wname\"value=\"{{index .Data \"Name\"}}\">\n\t</div>\n</div>\n<div class=\"formrow w_simple w_about\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_body\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<textarea name=\"wtext\"class=\"wtext\">{{index .Data \"Text\"}}</textarea>\n\t</div>\n</div>\n<div class=\"formrow w_default\">\n\t<div class=\"formitem formlabel\"><a>{{lang \"panel_themes_widgets_raw_body\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<textarea name=\"wbody\"class=\"rwtext\">{{.RawBody}}</textarea>\n\t</div>\n</div>\n</div>\n<div class=\"formrow form_button_row\">\n\t<div class=\"formitem\">\n\t\t<button name=\"panel-button\"class=\"formbutton widget_save\">{{lang \"panel_themes_widgets_save\"}}</button>\n\t\t<a href=\"/panel/themes/widgets/delete/submit/{{.ID}}\">\n\t\t\t<button name=\"panel-button\"class=\"formbutton widget_delete\">{{lang \"panel_themes_widgets_delete\"}}</button>\n\t\t</a>\n\t</div>\n</div>"
  },
  {
    "path": "templates/panel_user_edit.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_user_head\"}}</h1></div>\n</div>\n<div id=\"panel_user\" class=\"colstack_item the_form\">\n\t<form id=\"user_form\"action=\"/panel/users/edit/submit/{{.User.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t<form id=\"avatar_form\"enctype=\"multipart/form-data\"action=\"/panel/users/avatar/submit/{{.User.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t<form id=\"remove_avatar_form\"action=\"/panel/users/avatar/remove/submit/{{.User.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_user_avatar\"}}</a></div>\n\t\t<div class=\"formitem avataritem\">\n\t\t\t{{if .User.RawAvatar}}<img src=\"{{.User.Avatar}}\"height=56 width=56>{{end}}\n\t\t\t<div class=\"avatarbuttons\">\n\t\t\t\t<input form=\"avatar_form\"id=\"select_avatar\"name=\"avatar_file\"type=\"file\"required class=\"auto_hide\">\n\t\t\t\t<label for=\"select_avatar\"class=\"formbutton\">{{lang \"panel_user_avatar_select\"}}</label>\n\t\t\t\t<button form=\"avatar_form\"name=\"avatar_action\"value=0>{{lang \"panel_user_avatar_upload\"}}</button>\n\t\t\t\t{{if .User.RawAvatar}}<button form=\"remove_avatar_form\"name=\"avatar_action\"value=1>{{lang \"panel_user_avatar_remove\"}}</button>{{end}}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_user_name\"}}</a></div>\n\t\t<div class=\"formitem\"><input form=\"user_form\"name=\"name\"type=\"text\"value=\"{{.User.Name}}\"placeholder=\"{{lang \"panel_user_name_placeholder\"}}\"autocomplete=\"off\"></div>\n\t</div>\n\t{{if .CurrentUser.Perms.EditUserPassword}}<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_user_password\"}}</a></div>\n\t\t<div class=\"formitem\"><input form=\"user_form\"name=\"password\"type=\"password\"placeholder=\"*****\"autocomplete=\"off\"></div>\n\t</div>{{end}}\n\t{{if .CurrentUser.Perms.EditUserEmail}}<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_user_email\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t{{if .ShowEmail}}<input form=\"user_form\"name=\"show-email\"value=1 type=\"hidden\">\n\t\t\t<input form=\"user_form\"name=\"email\"type=\"email\"value=\"{{.User.Email}}\"placeholder=\"example@localhost\">{{else}}<input form=\"user_form\"name=\"email\"value=\"-1\"type=\"hidden\"><a href=\"/panel/users/edit/{{.User.ID}}?show-email=1\"><button>{{lang \"panel_user_show_email\"}}</button></a>{{end}}\n\t\t</div>\n\t</div>{{end}}\n\t{{if .CurrentUser.Perms.EditUserGroup}}\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_user_group\"}}</a></div>\n\t\t<div class=\"formitem\">\n\t\t\t<select form=\"user_form\"name=\"group\">\n\t\t\t{{range .Groups}}<option{{if eq .ID $.User.Group}} selected{{end}} value={{.ID}}>{{.Name}}</option>{{end}}\n\t\t\t</select>\n\t\t</div>\n\t</div>{{end}}\n\t<div class=\"formrow\">\n\t\t<div class=\"formitem\">\n\t\t\t<button form=\"user_form\"name=\"panel-button\" class=\"formbutton\">{{lang \"panel_user_update_button\"}}</button>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/panel_users.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{if .Search.Any}}{{lang \"panel_users_search_title\"}}{{else}}{{lang \"panel_users_head\"}}{{end}}</h1></div>\n</div>\n<div id=\"panel_users\"class=\"colstack_item rowlist bgavatars\">\n\t{{range .ItemList}}\n\t<div class=\"rowitem\"style=\"background-image:url('{{.Avatar}}');\">\n\t\t<a class=\"rowAvatar\"{{if $.CurrentUser.Perms.EditUser}}href=\"/panel/users/edit/{{.ID}}\"{{end}}>\n\t\t\t<img class=\"bgsub\"src=\"{{.Avatar}}\"alt=\"Avatar\"aria-hidden=\"true\">\n\t\t</a>\n\t\t<a class=\"rowTitle\"{{if $.CurrentUser.Perms.EditUser}}href=\"/panel/users/edit/{{.ID}}\"{{end}}>{{.Name}}</a>\n\t\t<span class=\"panel_floater\">\n\t\t\t<a href=\"{{.Link}}\"class=\"tag-mini profile_url\">{{lang \"panel_users_profile\"}}</a>\n\t\t\t{{if (.Tag) and (.IsSuperMod)}}<span class=\"panel_tag\">{{.Tag}}</span></span>{{end}}\n\t\t\t{{if .IsBanned}}<a href=\"/users/unban/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button ban_button\">{{lang \"panel_users_unban\"}}</a>{{else if not .IsSuperMod}}<a href=\"/user/{{.ID}}#ban_user\"class=\"panel_tag panel_right_button ban_button\">{{lang \"panel_users_ban\"}}</a>{{end}}\n\t\t\t{{if not .Active}}<a href=\"/users/activate/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button\">{{lang \"panel_users_activate\"}}</a>{{end}}\n\t\t</span>\n\t</div>\n\t{{end}}\n</div>\n{{template \"paginator_mod.html\" . }}\n\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_users_search_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/users/\"method=\"get\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_users_search_name\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"s-name\"type=\"text\"{{if .Search.Name}}value=\"{{.Search.Name}}\"{{end}}placeholder=\"{{lang \"panel_users_search_name_placeholder\"}}\"></div>\n\t\t</div>\n\t\t{{if .CurrentUser.Perms.EditUserEmail}}<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_users_search_email\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"s-email\"type=\"email\"{{if .Search.Email}}value=\"{{.Search.Email}}\"{{end}}placeholder=\"{{lang \"panel_users_search_email_placeholder\"}}\"></div>\n\t\t</div>{{end}}\n\t\t{{if .CurrentUser.Perms.EditUserGroup}}<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_users_search_group\"}}</a></div>\n\t\t\t<div class=\"formitem\"><select name=\"s-group\">\n\t\t\t\t<option value=\"0\"{{if eq $.Search.Group 0}}selected{{end}}>{{lang \"panel_users_search_group_none\"}}</option>\n\t\t\t{{range .Groups}}{{if ne .ID 0}}\n\t\t\t\t<option value=\"{{.ID}}\"{{if eq $.Search.Group .ID}}selected{{end}}>{{.Name}}</option>\n\t\t\t{{end}}{{end}}</select></div>\n\t\t</div>{{end}}\n\t\t<div class=\"formrow form_button_row\">\n\t\t\t<div class=\"formitem\"><button class=\"formbutton\">{{lang \"panel_users_search_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/panel_word_filters.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_word_filters_head\"}}</h1></div>\n</div>\n<div id=\"panel_word_filters\"class=\"colstack_item rowlist micro_grid\">\n\t{{range .Something}}\n\t<div class=\"rowitem panel_compactrow editable_parent\">\n\t\t<a data-field=\"find\"data-type=\"text\"href=\"/panel/settings/word-filters/edit/{{.ID}}\"class=\"editable_block panel_upshift edit_fields filter_find\">{{.Find}}</a>\n\t\t<span class=\"itemSeparator\"></span>\n\t\t<a data-field=\"replace\"data-type=\"text\"class=\"editable_block panel_compacttext filter_replace\">{{.Replace}}</a>\n\t\t<span class=\"panel_buttons\">\n\t\t\t<a class=\"panel_tag edit_fields hide_on_edit panel_right_button edit_button\"aria-label=\"{{lang \"panel_word_filters_edit_button_aria\"}}\"></a>\n\t\t\t<a class=\"panel_right_button show_on_edit\"href=\"/panel/settings/word-filters/edit/submit/{{.ID}}\"><button class='panel_tag submit_edit'type='submit'>{{lang \"panel_word_filters_update_button\"}}</button></a>\n\t\t\t<a href=\"/panel/settings/word-filters/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"panel_tag panel_right_button hide_on_edit delete_button\"aria-label=\"{{lang \"panel_word_filters_delete_button_aria\"}}\"></a>\n\t\t</span>\n\t</div>\n\t{{else}}\n\t<div class=\"rowitem rowmsg\">\n\t\t<a>{{lang \"panel_word_filters_no_filters\"}}</a>\n\t</div>\n\t{{end}}\n</div>\n\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><h1>{{lang \"panel_word_filters_create_head\"}}</h1></div>\n</div>\n<div class=\"colstack_item the_form\">\n\t<form action=\"/panel/settings/word-filters/create/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_word_filters_create_find\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"find\"type=\"text\"placeholder=\"{{lang \"panel_word_filters_create_find_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"panel_word_filters_create_replacement\"}}</a></div>\n\t\t\t<div class=\"formitem\"><input name=\"replace\"type=\"text\"placeholder=\"{{lang \"panel_word_filters_create_replacement_placeholder\"}}\"></div>\n\t\t</div>\n\t\t<div class=\"formrow\">\n\t\t\t<div class=\"formitem\"><button name=\"panel-button\" class=\"formbutton form_middle_button\">{{lang \"panel_word_filters_create_button\"}}</button></div>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/password_reset.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"password_reset_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"password_reset_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/password-reset/submit/\"method=\"post\">\n\t\t\t<div class=\"formrow login_name_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_name_label\">{{lang \"password_reset_username\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"username\"type=\"text\"aria-labelledby=\"login_name_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\"class=\"formbutton\">{{lang \"password_reset_button\"}}</button></div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/password_reset_token.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"password_reset_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"password_reset_token_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/password-reset/token/submit/\"method=\"post\">\n\t\t\t<input name=\"uid\"value=\"{{.UID}}\"type=\"hidden\">\n\t\t\t<input name=\"token\"value=\"{{.Token}}\"type=\"hidden\">\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"password_label\">{{lang \"password_reset_token_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\"type=\"password\"autocomplete=\"new-password\"placeholder=\"*****\"aria-labelledby=\"password_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"confirm_password_label\">{{lang \"password_reset_token_confirm_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"confirm_password\"type=\"password\"placeholder=\"*****\"autocomplete=\"new-password\"aria-labelledby=\"confirm_password_label\"required></div>\n\t\t\t</div>\n\t\t\t{{if .MFA}}\n\t\t\t<div class=\"formrow mfa_token_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"mfa_token_label\">{{lang \"password_reset_mfa_token\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"mfa_token\"type=\"text\"autocomplete=\"off\"placeholder=\"*****\"aria-labelledby=\"mfa_token_label\"required></div>\n\t\t\t</div>\n\t\t\t{{end}}\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"token-button\"class=\"formbutton\">{{lang \"password_reset_token_button\"}}</button></div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/profile.html",
    "content": "{{template \"header.html\" . }}\n<div id=\"profile_container\"class=\"colstack\">\n\n<div id=\"profile_left_lane\"class=\"colstack_left\">\n\t<div id=\"profile_left_pane\"class=\"rowmenu\">\n\t\t<div class=\"topBlock\">\n\t\t\t<div class=\"rowitem avatarRow\">\n\t\t\t\t<a href=\"{{.ProfileOwner.Avatar}}\"><img src=\"{{.ProfileOwner.Avatar}}\"class=\"avatar\"alt=\"Avatar\"title=\"{{.ProfileOwner.Name}}'s Avatar\"aria-hidden=\"true\"></a>\n\t\t\t</div>\n\t\t\t<div class=\"rowitem nameRow\">\n\t\t\t\t<span class=\"profileName\"title=\"{{.ProfileOwner.Name}}\">{{.ProfileOwner.Name}}</span>{{if .ProfileOwner.Tag}}<span class=\"username\"title=\"{{.ProfileOwner.Tag}}\">{{.ProfileOwner.Tag}}</span>{{end}}\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"levelBlock\">\n\t\t\t<div class=\"rowitem passive\">\n\t\t\t\t<div class=\"profile_menu_item level_inprogress{{if eq .CurrentScore 0}} level_zero{{end}}\">\n\t\t\t\t\t<div class=\"levelBit\"{{if ne .CurrentScore 0}}style=\"width:{{.Percentage}}%\"{{end}}>\n\t\t\t\t\t\t<a>{{level .ProfileOwner.Level}}</a>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"progressWrap\"{{/**{{if ne .CurrentScore 0}}style=\"width:40%\"{{end}}**/}}>\n\t\t\t\t\t\t<div>{{.CurrentScore}} / {{.NextScore}}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"passiveBlock\">\n\t\t\t{{if not .CurrentUser.Loggedin}}<div class=\"rowitem passive\">\n\t\t\t\t<a class=\"profile_menu_item\">{{lang \"profile.login_for_options\"}}</a>\n\t\t\t</div>{{else}}\n\t\t\t{{if .CanMessage}}<div class=\"rowitem passive\">\n\t\t\t\t<a href=\"/user/convos/create/?with={{.ProfileOwner.ID}}\"class=\"profile_menu_item\">{{lang \"profile.send_message\"}}</a>\n\t\t\t</div>{{end}}\n\t\t\t<!--<div class=\"rowitem passive\">\n\t\t\t\t<a class=\"profile_menu_item\">{{lang \"profile.add_friend\"}}</a>\n\t\t\t</div>-->\n\n\t\t\t{{if (.CurrentUser.IsSuperMod) and not (.ProfileOwner.IsSuperMod)}}<div class=\"rowitem passive\">\n\t\t\t\t{{if .ProfileOwner.IsBanned}}<a href=\"/users/unban/{{.ProfileOwner.ID}}?s={{.CurrentUser.Session}}\"class=\"profile_menu_item\">{{lang \"profile.unban\"}}</a>\n\t\t\t{{else}}<a href=\"#ban_user\"class=\"profile_menu_item\">{{lang \"profile.ban\"}}</a>{{end}}\n\t\t\t</div>\n\t\t\t<div class=\"rowitem passive\">\n\t\t\t\t<a href=\"#delete_posts\"class=\"profile_menu_item\">{{lang \"profile.delete_posts\"}}</a>\n\t\t\t</div>\n\t\t\t{{end}}\n\n\t\t\t<div class=\"rowitem passive\">\n\t\t\t\t{{if .Blocked}}<a href=\"/user/block/remove/{{.ProfileOwner.ID}}\"class=\"profile_menu_item\">{{lang \"profile.unblock\"}}</a>{{else}}<a href=\"/user/block/create/{{.ProfileOwner.ID}}\"class=\"profile_menu_item\">{{lang \"profile.block\"}}</a>{{end}}\n\t\t\t</div>\n\t\t\t<div class=\"rowitem passive\">\n\t\t\t\t<a href=\"/report/submit/{{.ProfileOwner.ID}}?s={{.CurrentUser.Session}}&type=user\"class=\"profile_menu_item report_item\"aria-label=\"{{lang \"profile.report_user_aria\"}}\"title=\"{{lang \"profile.report_user_tooltip\"}}\"></a>\n\t\t\t</div>\n\t\t\t{{end}}\n\t\t</div>\n\t</div>\n</div>\n\n<div id=\"profile_right_lane\"class=\"colstack_right\">\n\t{{if .CurrentUser.Loggedin}}\n\t{{if .CurrentUser.Perms.BanUsers}}\n\t<!-- TODO: Inline the display:none; CSS -->\n\t<div id=\"ban_user_head\"class=\"colstack_item colstack_head hash_hide ban_user_hash\"style=\"display:none;\">\n\t\t<div class=\"rowitem\"><h1><a>{{lang \"profile.ban_user_head\"}}</a></h1></div>\n\t</div>\n\t<form id=\"ban_user_form\"class=\"hash_hide ban_user_hash\"action=\"/users/ban/submit/{{.ProfileOwner.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"style=\"display:none;\">\n\t<div class=\"the_form\">\n\t\t{{/** TODO: Put a JS duration calculator here instead of this text? **/}}\n\t\t<div class=\"colline\">{{lang \"profile.ban_user_notice\"}}</div>\n\t\t<div class=\"colstack_item\">\n\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"profile.ban_user_days\"}}</a></div>\n\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t<input name=\"dur-days\"type=\"number\"value=0 min=0>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"profile.ban_user_weeks\"}}</a></div>\n\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t<input name=\"dur-weeks\"type=\"number\"value=0 min=0>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"profile.ban_user_months\"}}</a></div>\n\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t<input name=\"dur-months\"type=\"number\"value=0 min=0>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"profile.ban_delete_posts\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><select name=\"delete-posts\">\n\t\t\t\t\t<option value=1>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t<option selected value=0>{{lang \"option_no\"}}</option>\n\t\t\t\t</select></div>\n\t\t\t</div>\n\t\t\t{{/**<!--<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a>{{lang \"profile.ban_user_reason\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><textarea name=\"ban-reason\" placeholder=\"A really horrible person\"required></textarea></div>\n\t\t\t</div>-->**/}}\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem\"><button name=\"ban-button\"class=\"formbutton form_middle_button\">{{lang \"profile.ban_user_button\"}}</button></div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t</form>\n\n\t<div id=\"delete_posts_head\"class=\"colstack_item colstack_head hash_hide delete_posts_hash\"style=\"display:none;\">\n\t\t<div class=\"rowitem\"><h1><a>{{lang \"profile.delete_posts_head\"}}</a></h1></div>\n\t</div>\n\t<form id=\"delete_posts_form\"class=\"hash_hide delete_posts_hash\"action=\"/users/delete-posts/submit/{{.ProfileOwner.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"style=\"display:none;\">\n\t<div class=\"the_form\">\n\t\t<div class=\"colline\">{{langf \"profile.delete_posts_notice\" .ProfileOwner.Posts}}</div>\n\t\t<div class=\"colstack_item\">\n\t\t\t<div class=\"formrow real_first_child\">\n\t\t\t\t<div class=\"formitem\"><button name=\"delete-posts-button\"class=\"formbutton form_middle_button\">{{lang \"profile.delete_posts_button\"}}</button></div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t</form>\n\t{{end}}\n\t{{end}}\n\n\t<div id=\"profile_comments_head\"class=\"colstack_item colstack_head hash_hide\">\n\t\t<div class=\"rowitem\"><h1><a>{{lang \"profile.comments_head\"}}</a></h1></div>\n\t</div>{{if .ShowComments}}\n\t<div id=\"profile_comments\"class=\"colstack_item hash_hide\">{{template \"profile_comments_row.html\" . }}</div>{{end}}\n\n{{if .CurrentUser.Loggedin}}\n{{if .CanComment}}\n\t<form id=\"profile_comments_form\"class=\"hash_hide\"action=\"/profile/reply/create/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t<input name=\"uid\"value='{{.ProfileOwner.ID}}'type=\"hidden\">\n\t\t<div class=\"colstack_item topic_reply_form\"style=\"border-top:none;\">\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem\"><textarea class=\"input_content\"name=\"content\"placeholder=\"{{lang \"profile.comments_form_content\"}}\"></textarea></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow quick_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"reply-button\"class=\"formbutton\">{{lang \"profile.comments_form_button\"}}</button></div>\n\t\t\t</div>\n\t\t</div>\n\t</form>\n{{end}}\n{{else}}\n\t<div class=\"colstack_item\"style=\"border-top:none;\">\n\t\t<div class=\"rowitem passive\">{{lang \"profile.comments_form_guest\"}}</div>\n\t</div>\n{{end}}\n</div>\n\n</div>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/profile_comments_row.html",
    "content": "{{/** TODO: Temporary hack until we find a more granular way of doing this. Perhaps, a custom include function? **/}}\n{{if .Header.Theme.BgAvatars}}\n{{range .ItemList}}\n\t<div id=\"post-{{.ID}}\"class=\"rowitem passive deletable_block editable_parent simple {{.ClassName}}\"style=\"background-image:url({{.Avatar}}),url(/s/post-avatar-bg.jpg);background-position:0px {{if le .ContentLines 5}}-1{{end}}0px;\">\n\t\t<span class=\"editable_block user_content simple\">{{.ContentHtml}}</span>\n\t\t<span class=\"controls\">\n\t\t\t<a href=\"{{.UserLink}}\"class=\"real_username username\">{{.CreatedByName}}</a>&nbsp;&nbsp;\n\n\t\t\t{{if $.CurrentUser.IsMod}}<a href=\"/profile/reply/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_edit_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_edit_aria\"}}\"><button class=\"username edit_item edit_label\"></button></a>\n\n\t\t\t<a href=\"/profile/reply/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_delete_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_delete_aria\"}}\"><button class=\"username delete_item delete_label\"></button></a>{{end}}\n\n\t\t\t<a class=\"mod_button\"href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&type=user-reply\"><button class=\"username report_item flag_label\"title=\"{{lang \"profile.comments_report_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_report_aria\"}}\"></button></a>\n\n\t\t\t{{if .Tag}}<a class=\"username hide_on_mobile user_tag\"style=\"float:right;\">{{.Tag}}</a>{{end}}\n\t\t</span>\n\t</div>\n{{end}}\n{{else}}\n{{template \"profile_comments_row_alt.html\" . }}\n{{end}}"
  },
  {
    "path": "templates/profile_comments_row_alt.html",
    "content": "{{range .ItemList}}\n<div id=\"post-{{.ID}}\"class=\"rowitem passive deletable_block editable_parent comment {{.ClassName}}\">\n\t<div class=\"topRow\">\n\t\t<div class=\"userbit\">\n\t\t\t<a href=\"{{.UserLink}}\"><img src=\"{{.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.CreatedByName}}'s Avatar\"aria-hidden=\"true\"></a>\n\t\t\t<span class=\"nameAndTitle\">\n\t\t\t\t<a href=\"{{.UserLink}}\"class=\"real_username username\">{{.CreatedByName}}</a>\n\t\t\t\t{{if .Tag}}<a class=\"username hide_on_mobile user_tag\"style=\"float:right;\">{{.Tag}}</a>{{end}}\n\t\t\t</span>\n\t\t</div>\n\t\t<span class=\"controls\">\n\t\t\t{{if $.CurrentUser.IsMod}}\n\t\t\t\t<a href=\"/profile/reply/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_edit_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_edit_aria\"}}\"><button class=\"username edit_item edit_label\"></button></a>\n\t\t\t\t<a href=\"/profile/reply/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"mod_button\"title=\"{{lang \"profile.comments_delete_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_delete_aria\"}}\"><button class=\"username delete_item delete_label\"></button></a>\n\t\t\t{{end}}\n\t\t\t<a class=\"mod_button\"href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&type=user-reply\"><button class=\"username report_item flag_label\"title=\"{{lang \"profile.comments_report_tooltip\"}}\"aria-label=\"{{lang \"profile.comments_report_aria\"}}\"></button></a>\n\t\t</span>\n\t</div>\n\t<div class=\"content_column\">\n\t\t<span class=\"editable_block user_content\">{{.ContentHtml}}</span>\n\t</div>\n</div>\n<div class=\"after_comment\"></div>\n{{end}}"
  },
  {
    "path": "templates/register.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"register_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"register_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/create/submit/\"method=\"post\">\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"name_label\">{{lang \"register_account_name\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"name\"type=\"text\"placeholder=\"{{lang \"register_account_name\"}}\"aria-labelledby=\"name_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"email_label\">{{if not .RequireEmail}}{{lang \"register_account_email\"}}{{else}}{{lang \"register_account_email_optional\"}}{{end}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"email\"type=\"email\"placeholder=\"joe.doe@example.com\"aria-labelledby=\"email_label\"{{if not .RequireEmail}}required{{end}}></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"password_label\">{{lang \"register_account_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\"type=\"password\"autocomplete=\"new-password\"placeholder=\"*****\"aria-labelledby=\"password_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"confirm_password_label\">{{lang \"register_account_confirm_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"confirm_password\"type=\"password\"placeholder=\"*****\"autocomplete=\"new-password\"aria-labelledby=\"confirm_password_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow\">{{/** This is not a TOS, that text is there to fool the spambots **/}}\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"accept_tos_label\">{{lang \"register_account_anti_spam\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><select name=\"tos\"aria-labelledby=\"accept_tos_label\"required>\n\t\t\t\t\t<option value=\"1\"selected>{{lang \"option_yes\"}}</option>\n\t\t\t\t\t<option value=\"0\">{{lang \"option_no\"}}</option>\n\t\t\t\t</select></div>\n\t\t\t</div>\n\t\t\t{{range .Verify}}{{template \"register_verify.html\" .}}{{end}}\n\t\t\t<div class=\"formrow register_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"register-button\"class=\"formbutton\">{{lang \"register_submit_button\"}}</button></div>\n\t\t\t</div>\n\t\t\t{{if eq .Token \"\"}}<input id=\"golden-watch\"name=\"golden-watch\"value=\"$500\"type=\"hidden\">{{else}}<input id=\"areg\"name=\"areg\"value=\"{{.Token}}\"type=\"hidden\">{{end}}\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/register_verify.html",
    "content": "{{if .NoScript}}<noscript>{{end}}{{if .Image}}<div class=\"formrow\">\n\t<div class=\"formitem formlabel\"><a id=\"verify_image_label\">{{lang \"register_account_verify_image\"}}</a></div>\n\t<div class=\"formitem\">\n\t\t<div class=\"vimage_box\">\n\t\t\t<div class=\"vimage_question\">{{.Image.Question}}</div>\n\t\t\t<div class=\"vimage_grid\">\n\t\t\t\t{{range .Image.Items}}<div class=\"vimage_image\"><img src=\"{{.Src}}\"></div>{{end}}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>{{end}}{{if .NoScript}}</noscript>{{end}}"
  },
  {
    "path": "templates/topic.html",
    "content": "{{template \"header.html\" . }}\n{{template \"topic_inner.html\" . }}\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/topic_alt.html",
    "content": "{{template \"header.html\" . }}\n{{template \"topic_alt_inner.html\" . }}\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/topic_alt_inner.html",
    "content": "<main id=\"topicPage\">\n\n{{if gt .Page 1}}<link rel=\"prev\"href=\"{{.Topic.Link}}?page={{subtract .Page 1}}\">{{end}}\n{{if ne .LastPage .Page}}<link rel=\"prerender next\"href=\"{{.Topic.Link}}?page={{add .Page 1}}\">{{end}}\n{{if not .CurrentUser.Loggedin}}<link rel=\"canonical\"href=\"//{{.Site.URL}}{{.Topic.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}\">{{end}}\n\n<div {{scope \"topic_title_block\"}} class=\"rowblock rowhead topic_block\"aria-label=\"{{lang \"topic.topic_info_aria\"}}\">\n\t<div class=\"rowitem topic_item{{if .Topic.Sticky}} topic_sticky_head{{else if .Topic.IsClosed}} topic_closed_head{{end}}\">\n\t\t<h1 class='topic_name hide_on_edit'title='{{.Topic.Title}}'>{{.Topic.Title}}</h1>\n\t\t<span class=\"topic_name_forum_sep hide_on_edit\"> - </span>\n\t\t<a href=\"{{.Forum.Link}}\"class=\"topic_forum hide_on_edit\">{{.Forum.Name}}</a>\n\t\t{{/** TODO: Does this need to be guarded by a permission? It's only visible in edit mode anyway, which can't be triggered, if they don't have the permission **/}}\n\t\t{{if .CurrentUser.Loggedin}}\n\t\t{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n\t\t{{if .CurrentUser.Perms.EditTopic}}\n\t\t<form id=\"edit_topic_form\"action='/topic/edit/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'method=\"post\"></form>\n\t\t<input form=\"edit_topic_form\"class='show_on_edit topic_name_input'name=\"topic_name\"value='{{.Topic.Title}}'type=\"text\"aria-label=\"{{lang \"topic.title_input_aria\"}}\">\n\t\t<button form=\"edit_topic_form\"name=\"topic-button\"class=\"formbutton show_on_edit submit_edit\">{{lang \"topic.update_button\"}}</button>\n\t\t{{end}}\n\t\t{{end}}\n\t\t{{end}}\n\t\t<span class=\"topic_view_count hide_on_edit\">{{.Topic.ViewCount}}</span>\n\t\t{{/** TODO: Inline this CSS **/}}\n\t\t{{if .Topic.IsClosed}}<span class='username hide_on_micro topic_status_e topic_status_closed hide_on_edit'title='{{lang \"status.closed_tooltip\"}}'aria-label='{{lang \"topic.status_closed_aria\"}}'>&#x1F512;&#xFE0E</span>{{end}}\n\t</div>\n</div>\n\n<div class=\"rowblock post_container\">\n\t{{if .Poll}}{{template \"topic_alt_poll.html\" . }}{{end}}\n\t<article {{scope \"opening_post\"}} itemscope itemtype=\"http://schema.org/CreativeWork\"class=\"rowitem passive deletable_block editable_parent post_item top_post{{if .Topic.Attachments}} has_attachs{{end}}\"aria-label=\"{{lang \"topic.opening_post_aria\"}}\">\n\t\t{{template \"topic_alt_userinfo.html\" .Topic }}\n\t\t<div class=\"content_container\">\n\t\t\t<div class=\"hide_on_edit topic_content user_content\"itemprop=\"text\">{{.Topic.ContentHTML}}</div>\n\t\t\t{{if .CurrentUser.Loggedin}}<textarea name=\"topic_content\"class=\"show_on_edit topic_content_input edit_source\">{{.Topic.Content}}</textarea>\n\n\t\t\t{{if .CurrentUser.Perms.EditTopic}}\n\t\t\t<div class=\"show_on_edit attach_edit_bay\"type=\"topic\"id=\"{{.Topic.ID}}\">\n\t\t\t\t{{range .Topic.Attachments}}\n\t\t\t\t<div class=\"attach_item attach_item_item{{if .Image}} attach_image_holder{{end}}\">\n\t\t\t\t\t{{if .Image}}<img src=\"//{{$.Header.Site.URL}}/attachs/{{.Path}}?sid={{.SectionID}}&amp;stype=forums\"height=24 width=24>{{end}}\n\t\t\t\t\t<span class=\"attach_item_path\"aid=\"{{.ID}}\"fullPath=\"//{{$.Header.Site.URL}}/attachs/{{.Path}}\">{{.Path}}</span>\n\t\t\t\t\t<button class=\"attach_item_select\">{{lang \"topic.select_button_text\"}}</button>\n\t\t\t\t\t<button class=\"attach_item_copy\">{{lang \"topic.copy_button_text\"}}</button>\n\t\t\t\t</div>\n\t\t\t\t{{end}}\n\t\t\t\t<div class=\"attach_item attach_item_buttons\">\n\t\t\t\t\t{{if .CurrentUser.Perms.UploadFiles}}\n\t\t\t\t\t<input name=\"upload_files\"id=\"upload_files_op\"multiple type=\"file\"class=\"auto_hide\">\n\t\t\t\t\t<label for=\"upload_files_op\"class=\"formbutton add_file_button\">{{lang \"topic.upload_button_text\"}}</label>{{end}}\n\t\t\t\t\t<button class=\"attach_item_delete formbutton\">{{lang \"topic.delete_button_text\"}}</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{{end}}{{end}}\n\t\t\t<div class=\"controls button_container{{if .Topic.LikeCount}} has_likes{{end}}\">\n\t\t\t\t<div class=\"action_button_left\">\n\t\t\t\t{{if .CurrentUser.Loggedin}}\n\t\t\t\t\t{{if .CurrentUser.Perms.LikeItem}}{{if ne .CurrentUser.ID .Topic.CreatedBy}}\n\t\t\t\t\t{{if .Topic.Liked}}<a href=\"/topic/unlike/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}\"class=\"action_button like_item remove_like\"aria-label=\"{{lang \"topic.unlike_aria\"}}\"data-action=\"unlike\"></a>{{else}}<a href=\"/topic/like/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}\"class=\"action_button like_item add_like\"aria-label=\"{{lang \"topic.like_aria\"}}\"data-action=\"like\"></a>{{end}}\n\t\t\t\t\t{{end}}{{end}}\n\t\t\t\t\t<a href=\"\"class=\"action_button quote_item\"aria-label=\"{{lang \"topic.quote_aria\"}}\"data-action=\"quote\"></a>\n\t\t\t\t\t{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n\t\t\t\t\t{{if .CurrentUser.Perms.EditTopic}}<a href=\"/topic/edit/{{.Topic.ID}}\"class=\"action_button open_edit\"aria-label=\"{{lang \"topic.edit_aria\"}}\"data-action=\"edit\"></a>{{end}}\n\t\t\t\t\t{{end}}\n\t\t\t\t\t{{if .Topic.Deletable}}<a href=\"/topic/delete/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}\"class=\"action_button delete_item\"aria-label=\"{{lang \"topic.delete_aria\"}}\"data-action=\"delete\"></a>{{end}}\n\t\t\t\t\t{{if .CurrentUser.Perms.CloseTopic}}\n\t\t\t\t\t{{if .Topic.IsClosed}}<a href='/topic/unlock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class=\"action_button unlock_item\"data-action=\"unlock\"aria-label=\"{{lang \"topic.unlock_aria\"}}\"></a>{{else}}<a href='/topic/lock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class=\"action_button lock_item\"data-action=\"lock\"aria-label=\"{{lang \"topic.lock_aria\"}}\"></a>{{end}}{{end}}\n\t\t\t\t\t{{if .CurrentUser.Perms.PinTopic}}\n\t\t\t\t\t{{if .Topic.Sticky}}<a href='/topic/unstick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class=\"action_button unpin_item\"data-action=\"unpin\"aria-label=\"{{lang \"topic.unpin_aria\"}}\"></a>{{else}}<a href='/topic/stick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'class=\"action_button pin_item\"data-action=\"pin\"aria-label=\"{{lang \"topic.pin_aria\"}}\"></a>{{end}}{{end}}\n\t\t\t\t\t{{if .CurrentUser.Perms.ViewIPs}}<a href=\"/users/ips/?ip={{.Topic.IP}}\"title=\"{{lang \"topic.ip_full_tooltip\"}}\" class=\"action_button ip_item_button hide_on_big\"aria-label=\"{{lang \"topic.ip_full_aria\"}}\"data-action=\"ip\"></a>{{end}}\n\t\t\t\t\t<a href=\"/report/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}&type=topic\"class=\"action_button report_item\"aria-label=\"{{lang \"topic.report_aria\"}}\"data-action=\"report\"></a>\n\t\t\t\t\t<a href=\"#\"class=\"action_button button_menu\"></a>\n\t\t\t\t{{end}}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"action_button_right\">\n\t\t\t\t\t<a class=\"action_button like_count hide_on_micro\"aria-label=\"{{lang \"topic.like_count_aria\"}}\">{{.Topic.LikeCount}}</a>\n\t\t\t\t\t<a class=\"action_button created_at hide_on_mobile\"title=\"{{abstime .Topic.CreatedAt}}\">{{reltime .Topic.CreatedAt}}</a>\n\t\t\t\t\t{{if .CurrentUser.Perms.ViewIPs}}<a href=\"/users/ips/?ip={{.Topic.IP}}\"title=\"{{lang \"topic.ip_full_tooltip\"}}\"class=\"action_button ip_item hide_on_mobile\"aria-hidden=\"true\">{{.Topic.IP}}</a>{{end}}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div><div style=\"clear:both;\"></div>\n\t</article>\n\t{{template \"topic_alt_posts.html\" . }}\n</div>\n{{template \"paginator.html\" . }}\n\n{{if .CurrentUser.Loggedin}}\n{{if .CurrentUser.Perms.CreateReply}}\n{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n{{template \"topic_alt_quick_reply.html\" . }}\n{{end}}\n{{end}}\n{{end}}\n\n</main>"
  },
  {
    "path": "templates/topic_alt_mini.html",
    "content": "<div id=\"back\"class=\"zone_{{.Header.Zone}}{{if hasWidgets \"rightSidebar\" .Header }} shrink_main{{end}}\">\n<div id=\"main\">\n<div class=\"alertbox initial_alertbox\">{{range .Header.NoticeList}}{{template \"notice.html\" . }}{{end}}</div>\n{{template \"topic_alt_inner.html\" . }}\n</div>\n<aside class=\"midRight sidebar\">{{dock \"rightSidebar\" .Header}}</aside>\n</div>"
  },
  {
    "path": "templates/topic_alt_poll.html",
    "content": "<form id=\"poll_{{.Poll.ID}}_form\" action=\"/poll/vote/{{.Poll.ID}}?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t<article class=\"rowitem passive deletable_block editable_parent post_item poll_item top_post hide_on_edit\">\n\t\t{{/**{{template \"topic_alt_userinfo.html\" .Topic }}**/}}\n\t\t<div id=\"poll_voter_{{.Poll.ID}}\" class=\"content_container poll_voter\">\n\t\t\t<div class=\"topic_content user_content\">\n\t\t\t\t{{range .Poll.QuickOptions}}\n\t\t\t\t<div class=\"poll_option\">\n\t\t\t\t\t<input form=\"poll_{{$.Poll.ID}}_form\" id=\"poll_option_{{.ID}}\" name=\"poll_option_input\" type=\"checkbox\" value=\"{{.ID}}\">\n\t\t\t\t\t<label class=\"poll_option_label\"for=\"poll_option_{{.ID}}\"><div class=\"sel\"></div></label>\n\t\t\t\t\t<span id=\"poll_option_text_{{.ID}}\"class=\"poll_option_text\">{{.Value}}</span>\n\t\t\t\t</div>\n\t\t\t\t{{end}}\n\t\t\t\t<div class=\"poll_buttons\">\n\t\t\t\t\t<button form=\"poll_{{.Poll.ID}}_form\" class=\"poll_vote_button\">{{lang \"topic.poll_vote\"}}</button>\n\t\t\t\t\t<button class=\"poll_results_button\"data-poll-id=\"{{.Poll.ID}}\">{{lang \"topic.poll_results\"}}</button>\n\t\t\t\t\t<a href=\"#\"><button class=\"poll_cancel_button\">{{lang \"topic.poll_cancel\"}}</button></a>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<div id=\"poll_results_{{.Poll.ID}}\" class=\"content_container poll_results auto_hide\">\n\t\t\t<div class=\"topic_content user_content\">\n\t\t\t\t<div class=\"auto_hide poll_no_results\">{{lang \"topic.poll_no_results\"}}</div>\n\t\t\t</div>\n\t\t</div>\n\t</article>"
  },
  {
    "path": "templates/topic_alt_posts.html",
    "content": "{{range .ItemList}}<article {{scope \"post\"}} id=\"post-{{.ID}}\"itemscope itemtype=\"http://schema.org/CreativeWork\"class=\"rowitem passive deletable_block editable_parent post_item{{if .ActionType}} action_item{{end}}{{if .Attachments}} has_attachs{{end}}\">\n\t{{if js}}js{{/**{{ptmpl \"topic_alt_userinfo\" .}}**/}}{{else}}{{template \"topic_alt_userinfo.html\" . }}{{end}}\n\t<div class=\"content_container\">\n\t\t{{if .ActionType}}\n\t\t\t<span class=\"action_icon\"aria-hidden=\"true\">{{.ActionIcon}}</span><span itemprop=\"text\">{{.ActionType}}</span>\n\t\t{{else}}\n\t\t<div class=\"editable_block user_content\"itemprop=\"text\">{{.ContentHtml}}</div>\n\t\t{{if $.CurrentUser.Loggedin}}\n\t\t<div class=\"edit_source auto_hide\">{{.Content}}</div>\n\n\t\t{{if $.CurrentUser.Perms.EditReply}}\n\t\t<div class=\"show_on_block_edit attach_edit_bay\"type=\"reply\"id=\"{{.ID}}\">\n\t\t\t{{range .Attachments}}\n\t\t\t<div class=\"attach_item attach_item_item{{if .Image}} attach_image_holder{{end}}\">\n\t\t\t\t{{if .Image}}<img src=\"//{{$.Header.Site.URL}}/attachs/{{.Path}}?sid={{.SectionID}}&amp;stype=forums\"height=24 width=24>{{end}}\n\t\t\t\t<span class=\"attach_item_path\"aid={{.ID}} fullPath=\"//{{$.Header.Site.URL}}/attachs/{{.Path}}\">{{.Path}}</span>\n\t\t\t\t<button class=\"attach_item_select\">{{lang \"topic.select_button_text\"}}</button>\n\t\t\t\t<button class=\"attach_item_copy\">{{lang \"topic.copy_button_text\"}}</button>\n\t\t\t</div>\n\t\t\t{{end}}\n\t\t\t<div class=\"attach_item attach_item_buttons\">\n\t\t\t\t{{if $.CurrentUser.Perms.UploadFiles}}\n\t\t\t\t<input name=\"upload_files\"class=\"upload_files_post auto_hide\"id=\"upload_files_post_{{.ID}}\"multiple type=\"file\">\n\t\t\t\t<label for=\"upload_files_post_{{.ID}}\"class=\"formbutton add_file_button\">{{lang \"topic.upload_button_text\"}}</label>{{end}}\n\t\t\t\t<button class=\"attach_item_delete formbutton\">{{lang \"topic.delete_button_text\"}}</button>\n\t\t\t</div>\n\t\t</div>\n\t\t{{end}}{{end}}\n\n\t\t<div class=\"controls button_container{{if .LikeCount}} has_likes{{end}}\">\n\t\t\t<div class=\"action_button_left\">\n\t\t\t{{if $.CurrentUser.Loggedin}}\n\t\t\t\t{{if $.CurrentUser.Perms.LikeItem}}{{if ne $.CurrentUser.ID .CreatedBy}}\n\t\t\t\t{{if .Liked}}<a href=\"/reply/unlike/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"action_button like_item remove_like\"aria-label=\"{{lang \"topic.post_unlike_aria\"}}\"data-action=\"unlike\"></a>{{else}}\n\t\t\t\t<a href=\"/reply/like/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"action_button like_item add_like\"aria-label=\"{{lang \"topic.post_like_aria\"}}\"data-action=\"like\"></a>{{end}}\n\t\t\t\t{{end}}{{end}}\n\t\t\t\t<a href=\"\"class=\"action_button quote_item\"aria-label=\"{{lang \"topic.quote_aria\"}}\"data-action=\"quote\"></a>\n\t\t\t\t{{if not $.Topic.IsClosed or $.CurrentUser.Perms.CloseTopic}}\n\t\t\t\t{{if $.CurrentUser.Perms.EditReply}}<a href=\"/reply/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"action_button edit_item\"aria-label=\"{{lang \"topic.post_edit_aria\"}}\"data-action=\"edit\"></a>{{end}}\n\t\t\t\t{{end}}\n\t\t\t\t{{if .Deletable}}<a href=\"/reply/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\"class=\"action_button delete_item\"aria-label=\"{{lang \"topic.post_delete_aria\"}}\"data-action=\"delete\"></a>{{end}}\n\t\t\t\t{{if $.CurrentUser.Perms.ViewIPs}}<a href=\"/users/ips/?ip={{.IP}}\"title=\"{{lang \"topic.ip_full_tooltip\"}}\"class=\"action_button ip_item_button hide_on_big\"aria-label=\"{{lang \"topic.ip_full_aria\"}}\"data-action=\"ip\"></a>{{end}}\n\t\t\t\t<a href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&amp;type=reply\"class=\"action_button report_item\"aria-label=\"{{lang \"topic.report_aria\"}}\"data-action=\"report\"></a>\n\t\t\t\t<a href=\"#\"class=\"action_button button_menu\"></a>\n\t\t\t{{end}}\n\t\t\t</div>\n\t\t\t<div class=\"action_button_right\">\n\t\t\t\t<a class=\"action_button like_count hide_on_micro\"aria-label=\"{{lang \"topic.post_like_count_tooltip\"}}\">{{.LikeCount}}</a>\n\t\t\t\t<a class=\"action_button created_at hide_on_mobile\"title=\"{{abstime .CreatedAt}}\">{{reltime .CreatedAt}}</a>\n\t\t\t\t{{if $.CurrentUser.Loggedin}}{{if $.CurrentUser.Perms.ViewIPs}}<a href=\"/users/ips/?ip={{.IP}}\"title=\"IP Address\"class=\"action_button ip_item hide_on_mobile\"aria-hidden=\"true\">{{.IP}}</a>{{end}}{{end}}\n\t\t\t</div>\n\t\t</div>\n\t\t{{end}}\n\t</div><div style=\"clear:both;\"></div>\n</article>{{end}}"
  },
  {
    "path": "templates/topic_alt_quick_reply.html",
    "content": "<div class=\"rowblock topic_reply_container\">\n\t<div class=\"userinfo\"aria-label=\"{{lang \"topic.your_information\"}}\">\n\t\t<img class=\"aitem\"src=\"{{.CurrentUser.Avatar}}\"alt=\"Avatar\">\n\t\t<div class=\"user_meta\">\n\t\t\t<a href=\"{{.CurrentUser.Link}}\"class=\"the_name\"rel=\"author\">{{.CurrentUser.Name}}</a>\n\t\t\t<div class=\"tag_block\">\n\t\t\t\t<div class=\"tag_pre\"></div>\n\t\t\t\t{{if .CurrentUser.Tag}}<div class=\"post_tag\">{{.CurrentUser.Tag}}{{else}}<div class=\"post_tag post_level\">{{level .CurrentUser.Level}}{{end}}</div>\n\t\t\t\t<div class=\"tag_post\"></div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t<div class=\"rowblock topic_reply_form quick_create_form\"aria-label=\"{{lang \"topic.reply_aria\"}}\">\n\t\t<form id=\"quick_post_form\"enctype=\"multipart/form-data\"action=\"/reply/create/?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t\t<input form=\"quick_post_form\"name=\"tid\"value='{{.Topic.ID}}'type=\"hidden\">\n\t\t<input form=\"quick_post_form\"id=\"has_poll_input\"name=\"has_poll\"type=\"hidden\"value=0>\n\t\t<div class=\"formrow real_first_child\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<textarea id=\"input_content\"form=\"quick_post_form\"name=\"content\"placeholder=\"{{lang \"topic.reply_content_alt\"}}\"required></textarea>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow poll_content_row auto_hide\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<div class=\"pollinput\"data-pollinput=0>\n\t\t\t\t\t<input type=\"checkbox\"disabled>\n\t\t\t\t\t<label class=\"pollinputlabel\"></label>\n\t\t\t\t\t<input form=\"quick_post_form\"name=\"pollinputitem[0]\"class=\"pollinputinput\"type=\"text\"placeholder=\"{{lang \"topic.reply_add_poll_option_first\"}}\">\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t\t<div class=\"formrow quick_button_row\">\n\t\t\t<div class=\"formitem\">\n\t\t\t\t<button form=\"quick_post_form\"name=\"reply-button\"class=\"formbutton\">{{lang \"topic.reply_button\"}}</button>\n\t\t\t\t<button form=\"quick_post_form\"class=\"formbutton\"id=\"add_poll_button\">{{lang \"topic.reply_add_poll_button\"}}</button>\n\t\t\t\t{{if .CurrentUser.Perms.UploadFiles}}\n\t\t\t\t<input name=\"upload_files\"form=\"quick_post_form\"id=\"upload_files\"multiple type=\"file\"class=\"auto_hide\">\n\t\t\t\t<label for=\"upload_files\"class=\"formbutton add_file_button\">{{lang \"topic.reply_add_file_button\"}}</label>\n\t\t\t\t<div id=\"upload_file_dock\"></div>{{end}}\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/topic_alt_userinfo.html",
    "content": "<div class=\"userinfo\"aria-label=\"{{lang \"topic.userinfo_aria\"}}\">\n\t<img class=\"aitem\"src=\"{{.Avatar}}\"alt=\"Avatar\">\n\t<div class=\"user_meta\">\n\t\t<a href=\"{{.UserLink}}\"class=\"the_name\"rel=\"author\">{{.CreatedByName}}</a>\n\t\t<div class=\"tag_block\">\n\t\t\t<div class=\"tag_pre\"></div>\n\t\t\t{{if .Tag}}<div class=\"post_tag\">{{.Tag}}{{else}}<div class=\"post_tag post_level\">{{level .Level}}{{end}}</div>\n\t\t\t<div class=\"tag_post\"></div>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/topic_c_attach_item.html",
    "content": "{{if .ImgSrc}}<img src='{{.ImgSrc}}'height=24 width=24>{{end}}\n<span class='attach_item_path'aid='{{.ID}}'fullpath='{{.FullPath}}'>{{.Path}}</span>\n<button class='attach_item_select'>{{lang \"topic.select_button_text\"}}</button>\n<button class='attach_item_copy'>{{lang \"topic.copy_button_text\"}}</button>"
  },
  {
    "path": "templates/topic_c_edit_post.html",
    "content": "<textarea style='width:99%;'name='edit_item'>{{.Source}}</textarea><br>\n<div class=\"update_buttons\">\n\t<a href='{{.Ref}}'><button class='submit_edit'type='submit'>{{lang \"topic.update_button\"}}</button></a>\n\t<label for=\"upload_files_post_{{.ID}}\"class=\"formbutton add_file_button\">{{lang \"topic.reply_add_file_button\"}}</label>\n</div>"
  },
  {
    "path": "templates/topic_c_poll_input.html",
    "content": "<div class='pollinput'data-pollinput={{.Index}}>\n\t<input type='checkbox'disabled>\n\t<label class='pollinputlabel'></label>\n\t<input form='quick_post_form'name='pollinputitem[{{.Index}}]'class='pollinputinput'type='text'placeholder='{{.Place}}'>\n</div>"
  },
  {
    "path": "templates/topic_inner.html",
    "content": "<main id=\"topicPage\">\n\n{{if gt .Page 1}}<link rel=\"prev\" href=\"{{.Topic.Link}}?page={{subtract .Page 1}}\"/>\n<div id=\"prevFloat\" class=\"prev_button\"><a class=\"prev_link\" aria-label=\"{{lang \"paginator.prev_page_aria\"}}\" rel=\"prev\"href=\"{{.Topic.Link}}?page={{subtract .Page 1}}\">{{lang \"paginator.less_than\"}}</a></div>{{end}}\n\n{{if ne .LastPage .Page}}<link rel=\"prerender next\" href=\"{{.Topic.Link}}?page={{add .Page 1}}\"/>\n<div id=\"nextFloat\" class=\"next_button\">\n\t<a class=\"next_link\" aria-label=\"{{lang \"paginator.next_page_aria\"}}\" rel=\"next\"href=\"{{.Topic.Link}}?page={{add .Page 1}}\">{{lang \"paginator.greater_than\"}}</a>\n</div>{{end}}\n{{if not .CurrentUser.Loggedin}}<link rel=\"canonical\" href=\"//{{.Site.URL}}{{.Topic.Link}}{{if gt .Page 1}}?page={{.Page}}{{end}}\"/>{{end}}\n\n<div {{scope \"topic_title_block\"}} class=\"rowblock rowhead topic_block\" aria-label=\"{{lang \"topic.topic_info_aria\"}}\">\n\t<div class=\"rowitem topic_item{{if .Topic.Sticky}} topic_sticky_head{{else if .Topic.IsClosed}} topic_closed_head{{end}}\">\n\t\t<h1 class='topic_name hide_on_edit' title='{{.Topic.Title}}'>{{.Topic.Title}}</h1>\n\t\t{{if .Topic.IsClosed}}<span class='username hide_on_micro topic_status_e topic_status_closed hide_on_edit' title='{{lang \"status.closed_tooltip\"}}' aria-label='{{lang \"topic.status_closed_aria\"}}'>&#x1F512;&#xFE0E</span>{{end}}\n\t\t{{/** TODO: Does this need to be guarded by a permission? It's only visible in edit mode anyway, which can't be triggered, if they don't have the permission **/}}\n\t\t{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n\t\t{{if .CurrentUser.Perms.EditTopic}}\n\t\t<form id=\"edit_topic_form\" action='/topic/edit/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' method=\"post\"></form>\n\t\t<input form='edit_topic_form' class='show_on_edit topic_name_input' name=\"topic_name\" value='{{.Topic.Title}}' type=\"text\" aria-label=\"{{lang \"topic.title_input_aria\"}}\">\n\t\t<button form='edit_topic_form' name=\"topic-button\" class=\"formbutton show_on_edit submit_edit\">{{lang \"topic.update_button\"}}</button>\n\t\t{{end}}\n\t\t{{end}}\n\t</div>\n</div>\n{{if .Poll}}{{template \"topic_poll.html\" . }}{{end}}\n\n<article {{scope \"opening_post\"}} itemscope itemtype=\"http://schema.org/CreativeWork\" class=\"rowblock post_container top_post\" aria-label=\"{{lang \"topic.opening_post_aria\"}}\">\n\t<div class=\"rowitem passive editable_parent post_item {{.Topic.ClassName}}\" style=\"background-image:url({{.Topic.Avatar}}),url(/s/{{.Header.Theme.Name}}/post-avatar-bg.jpg);background-position:0px {{if le .Topic.ContentLines 5}}-1{{end}}0px;background-repeat:no-repeat,repeat-y;\">\n\t\t<div class=\"hide_on_edit topic_content user_content\"itemprop=\"text\">{{.Topic.ContentHTML}}</div>\n\t\t{{if .CurrentUser.Loggedin}}<textarea name=\"topic_content\" class=\"show_on_edit topic_content_input edit_source\">{{.Topic.Content}}</textarea>{{end}}\n\n\t\t<span class=\"controls{{if .Topic.LikeCount}} has_likes{{end}}\" aria-label=\"{{lang \"topic.post_controls_aria\"}}\">\n\n\t\t<a href=\"{{.Topic.UserLink}}\"class=\"username real_username\"rel=\"author\">{{.Topic.CreatedByName}}</a>&nbsp;&nbsp;\n\n\t\t{{if .CurrentUser.Loggedin}}\n\t\t{{if .CurrentUser.Perms.LikeItem}}{{if ne .CurrentUser.ID .Topic.CreatedBy}}\n\n\t\t{{if .Topic.Liked}}\n\t\t<a href=\"/topic/unlike/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}\" class=\"mod_button\"title=\"{{lang \"topic.unlike_tooltip\"}}\"aria-label=\"{{lang \"topic.unlike_aria\"}}\">\n\t\t<button class=\"username like_label remove_like\"></button></a>{{else}}\n\t\t<a href=\"/topic/like/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}\" class=\"mod_button\"title=\"{{lang \"topic.like_tooltip\"}}\"aria-label=\"{{lang \"topic.like_aria\"}}\">\n\t\t<button class=\"username like_label add_like\"></button></a>{{end}}\n\t\t\n\t\t{{end}}{{end}}\n\n\t\t<a href=\"\"class=\"mod_button quote_item\"title=\"{{lang \"topic.quote_tooltip\"}}\" aria-label=\"{{lang \"topic.quote_aria\"}}\"><button class=\"username quote_label\"></button></a>\n\n\t\t{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n\t\t{{if .CurrentUser.Perms.EditTopic}}<a href='/topic/edit/{{.Topic.ID}}' class=\"mod_button open_edit\"title=\"{{lang \"topic.edit_tooltip\"}}\" aria-label=\"{{lang \"topic.edit_aria\"}}\"><button class=\"username edit_label\"></button></a>{{end}}\n\t\t{{end}}\n\n\t\t{{if .Topic.Deletable}}<a href='/topic/delete/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class=\"mod_button\"title=\"{{lang \"topic.delete_tooltip\"}}\" aria-label=\"{{lang \"topic.delete_aria\"}}\"><button class=\"username delete_label\"></button></a>{{end}}\n\n\t\t{{if .CurrentUser.Perms.CloseTopic}}{{if .Topic.IsClosed}}<a class=\"mod_button\" href='/topic/unlock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'title=\"{{lang \"topic.unlock_tooltip\"}}\" aria-label=\"{{lang \"topic.unlock_aria\"}}\"><button class=\"username unlock_label\"></button></a>{{else}}<a href='/topic/lock/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class=\"mod_button\"title=\"{{lang \"topic.lock_tooltip\"}}\" aria-label=\"{{lang \"topic.lock_aria\"}}\"><button class=\"username lock_label\"></button></a>{{end}}{{end}}\n\n\t\t{{if .CurrentUser.Perms.PinTopic}}{{if .Topic.Sticky}}<a class=\"mod_button\" href='/topic/unstick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}'title=\"{{lang \"topic.unpin_tooltip\"}}\" aria-label=\"{{lang \"topic.unpin_aria\"}}\"><button class=\"username unpin_label\"></button></a>{{else}}<a href='/topic/stick/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}' class=\"mod_button\"title=\"{{lang \"topic.pin_tooltip\"}}\" aria-label=\"{{lang \"topic.pin_aria\"}}\"><button class=\"username pin_label\"></button></a>{{end}}{{end}}\n\t\t{{if .CurrentUser.Perms.ViewIPs}}<a class=\"mod_button\"href='/users/ips/?ip={{.Topic.IP}}'title=\"{{lang \"topic.ip_tooltip\"}}\" aria-label=\"The poster's IP is {{.Topic.IP}}\"><button class=\"username ip_label\"></button></a>{{end}}\n\t\t{{end}}\n\n\t\t<a href=\"/report/submit/{{.Topic.ID}}?s={{.CurrentUser.Session}}&type=topic\" class=\"mod_button report_item\"title=\"{{lang \"topic.flag_tooltip\"}}\" aria-label=\"{{lang \"topic.flag_aria\"}}\" rel=\"nofollow\"><button class=\"username flag_label\"></button></a>\n\n\t\t<a class=\"username hide_on_micro like_count\" aria-label=\"{{lang \"topic.like_count_aria\"}}\">{{.Topic.LikeCount}}</a><a class=\"username hide_on_micro like_count_label\" title=\"{{lang \"topic.like_count_tooltip\"}}\"></a>\n\n\t\t{{if .Topic.Tag}}<a class=\"username hide_on_micro user_tag\">{{.Topic.Tag}}</a>{{else}}<a class=\"username hide_on_micro level\" aria-label=\"{{lang \"topic.level_aria\"}}\" title=\"{{lang \"topic.level_tooltip\"}}\">{{level .Topic.Level}}</a><a class=\"username hide_on_micro level_label\" title=\"{{lang \"topic.level_tooltip\"}}\"></a>{{end}}\n\n\t\t</span>\n\t</div>\n</article>\n\n{{template \"topic_posts.html\" . }}\n\n{{if .CurrentUser.Perms.CreateReply}}\n{{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}\n<div class=\"rowblock topic_reply_form quick_create_form\" aria-label=\"{{lang \"topic.reply_aria\"}}\">\n\t<form id=\"quick_post_form\" enctype=\"multipart/form-data\" action=\"/reply/create/?s={{.CurrentUser.Session}}\" method=\"post\"></form>\n\t<input form=\"quick_post_form\" name=\"tid\" value='{{.Topic.ID}}' type=\"hidden\">\n\t<input form=\"quick_post_form\" id=\"has_poll_input\" name=\"has_poll\" value=0 type=\"hidden\">\n\t<div class=\"formrow real_first_child\">\n\t\t<div class=\"formitem\">\n\t\t\t<textarea id=\"input_content\" form=\"quick_post_form\" name=\"content\" placeholder=\"{{lang \"topic.reply_content\"}}\" required></textarea>\n\t\t</div>\n\t</div>\n\t<div class=\"formrow poll_content_row auto_hide\">\n\t\t<div class=\"formitem\">\n\t\t\t<div class=\"pollinput\" data-pollinput=0>\n\t\t\t\t<input type=\"checkbox\" disabled>\n\t\t\t\t<label class=\"pollinputlabel\"></label>\n\t\t\t\t<input form=\"quick_post_form\" name=\"pollinputitem[0]\" class=\"pollinputinput\" type=\"text\" placeholder=\"{{lang \"topic.reply_add_poll_option_first\"}}\">\n\t\t\t</div>\n\t\t</div>\n\t</div>\n\t<div class=\"formrow quick_button_row\">\n\t\t<div class=\"formitem\">\n\t\t\t<button form=\"quick_post_form\" name=\"reply-button\" class=\"formbutton\">{{lang \"topic.reply_button\"}}</button>\n\t\t\t<button form=\"quick_post_form\" class=\"formbutton\" id=\"add_poll_button\">{{lang \"topic.reply_add_poll_button\"}}</button>\n\t\t\t{{if .CurrentUser.Perms.UploadFiles}}\n\t\t\t<input name=\"upload_files\" form=\"quick_post_form\" id=\"upload_files\" multiple type=\"file\" class=\"auto_hide\">\n\t\t\t<label for=\"upload_files\" class=\"formbutton add_file_button\">{{lang \"topic.reply_add_file_button\"}}</label>\n\t\t\t<div id=\"upload_file_dock\"></div>{{end}}\n\t\t</div>\n\t</div>\n</div>\n{{end}}\n{{end}}\n\n</main>"
  },
  {
    "path": "templates/topic_mini.html",
    "content": "<div id=\"back\"class=\"zone_{{.Header.Zone}}{{if hasWidgets \"rightSidebar\" .Header }} shrink_main{{end}}\">\n<div id=\"main\">\n<div class=\"alertbox initial_alertbox\">{{range .Header.NoticeList}}{{template \"notice.html\" . }}{{end}}</div>\n{{template \"topic_inner.html\" . }}\n</div>\n<aside class=\"midRight sidebar\">{{dock \"rightSidebar\" .Header}}</aside>\n</div>"
  },
  {
    "path": "templates/topic_poll.html",
    "content": "<article class=\"rowblock post_container poll\"aria-level=\"{{lang \"topic.poll_aria\"}}\">\n\t<div class=\"rowitem passive editable_parent post_item poll_item {{.Topic.ClassName}}\"style=\"background-image:url({{.Topic.Avatar}}),url(/s/{{.Header.Theme.Name}}/post-avatar-bg.jpg);background-position:0px {{if le .Topic.ContentLines 5}}-1{{end}}0px;background-repeat:no-repeat,repeat-y;\">\n\t\t<div class=\"topic_content user_content\">\n\t\t\t{{range .Poll.QuickOptions}}\n\t\t\t<div class=\"poll_option\">\n\t\t\t\t<input form=\"poll_{{$.Poll.ID}}_form\"id=\"poll_option_{{.ID}}\"name=\"poll_option_input\"type=\"checkbox\"value=\"{{.ID}}\">\n\t\t\t\t<label class=\"poll_option_label\"for=\"poll_option_{{.ID}}\">\n\t\t\t\t\t<div class=\"sel\"></div>\n\t\t\t\t</label>\n\t\t\t\t<span id=\"poll_option_text_{{.ID}}\"class=\"poll_option_text\">{{.Value}}</span>\n\t\t\t</div>\n\t\t\t{{end}}\n\t\t\t<div class=\"poll_buttons\">\n\t\t\t\t<button form=\"poll_{{.Poll.ID}}_form\" class=\"poll_vote_button\">{{lang \"topic.poll_vote\"}}</button>\n\t\t\t\t<button class=\"poll_results_button\"data-poll-id=\"{{.Poll.ID}}\">{{lang \"topic.poll_results\"}}</button>\n\t\t\t\t<a href=\"#\"><button class=\"poll_cancel_button\">{{lang \"topic.poll_cancel\"}}</button></a>\n\t\t\t</div>\n\t\t</div>\n\t\t<div id=\"poll_results_{{.Poll.ID}}\"class=\"poll_results auto_hide\">\n\t\t\t<div class=\"topic_content user_content\">\n\t\t\t\t<div class=\"auto_hide poll_no_results\">{{lang \"topic.poll_no_results\"}}</div>\n\t\t\t</div>\n\t\t</div>\n\t</div>\n</article>"
  },
  {
    "path": "templates/topic_posts.html",
    "content": "<div class=\"rowblock post_container\" aria-label=\"{{lang \"topic.current_page_aria\"}}\" style=\"overflow:hidden;\">{{range .ItemList}}\n{{if .ActionType}}\n\t<article {{scope \"post_action\"}} id=\"post-{{.ID}}\" itemscope itemtype=\"http://schema.org/CreativeWork\" class=\"rowitem passive deletable_block editable_parent post_item action_item\">\n\t\t<span class=\"action_icon\">{{.ActionIcon}}</span>\n\t\t<span itemprop=\"text\">{{.ActionType}}</span>\n\t</article>\n{{else}}\n\t<article {{scope \"post\"}} id=\"post-{{.ID}}\" itemscope itemtype=\"http://schema.org/CreativeWork\" class=\"rowitem passive deletable_block editable_parent post_item {{.ClassName}}\" style=\"background-image:url({{.Avatar}}),url(/s/{{$.Header.Theme.Name}}/post-avatar-bg.jpg);background-position:0px {{if le .ContentLines 5}}-1{{end}}0px;background-repeat:no-repeat,repeat-y;\">\n\t\t{{/** TODO: We might end up with <br>s in the inline editor, fix this **/}}\n\t\t<div class=\"editable_block user_content\" itemprop=\"text\">{{.ContentHtml}}</div>\n\t\t{{if $.CurrentUser.Loggedin}}<div class=\"auto_hide edit_source\">{{.Content}}</div>{{end}}\n\n\t\t<span class=\"controls{{if .LikeCount}} has_likes{{end}}\">\n\n\t\t<a href=\"{{.UserLink}}\" class=\"username real_username\" rel=\"author\">{{.CreatedByName}}</a>&nbsp;&nbsp;\n\t\t{{if $.CurrentUser.Perms.LikeItem}}{{if ne $.CurrentUser.ID .CreatedBy}}{{if .Liked}}<a href=\"/reply/unlike/submit/{{.ID}}?s={{$.CurrentUser.Session}}\" class=\"mod_button\" title=\"{{lang \"topic.post_unlike_tooltip\"}}\" aria-label=\"{{lang \"topic.post_unlike_aria\"}}\"><button class=\"username like_label remove_like\"></button></a>{{else}}<a href=\"/reply/like/submit/{{.ID}}?s={{$.CurrentUser.Session}}\" class=\"mod_button\" title=\"{{lang \"topic.post_like_tooltip\"}}\" aria-label=\"{{lang \"topic.post_like_aria\"}}\"><button class=\"username like_label add_like\"></button></a>{{end}}{{end}}{{end}}\n\n\t\t<a href=\"\" class=\"mod_button quote_item\" title=\"{{lang \"topic.quote_tooltip\"}}\" aria-label=\"{{lang \"topic.quote_aria\"}}\"><button class=\"username quote_label\"></button></a>\n\n\t\t{{if not $.Topic.IsClosed or $.CurrentUser.Perms.CloseTopic}}\n\t\t{{if $.CurrentUser.Perms.EditReply}}<a href=\"/reply/edit/submit/{{.ID}}?s={{$.CurrentUser.Session}}\" class=\"mod_button\" title=\"{{lang \"topic.post_edit_tooltip\"}}\" aria-label=\"{{lang \"topic.post_edit_aria\"}}\"><button class=\"username edit_item edit_label\"></button></a>{{end}}\n\t\t{{end}}\n\n\t\t{{if .Deletable}}<a href=\"/reply/delete/submit/{{.ID}}?s={{$.CurrentUser.Session}}\" class=\"mod_button\" title=\"{{lang \"topic.post_delete_tooltip\"}}\" aria-label=\"{{lang \"topic.post_delete_aria\"}}\"><button class=\"username delete_item delete_label\"></button></a>{{end}}\n\t\t{{if $.CurrentUser.Perms.ViewIPs}}<a class=\"mod_button\" href='/users/ips/?ip={{.IP}}' title=\"{{lang \"topic.post_ip_tooltip\"}}\" aria-label=\"The poster's IP is {{.IP}}\"><button class=\"username ip_label\"></button></a>{{end}}\n\t\t<a href=\"/report/submit/{{.ID}}?s={{$.CurrentUser.Session}}&amp;type=reply\" class=\"mod_button report_item\" title=\"{{lang \"topic.post_flag_tooltip\"}}\" aria-label=\"{{lang \"topic.post_flag_aria\"}}\" rel=\"nofollow\"><button class=\"username report_item flag_label\"></button></a>\n\n\t\t<a class=\"username hide_on_micro like_count\">{{.LikeCount}}</a><a class=\"username hide_on_micro like_count_label\" title=\"{{lang \"topic.post_like_count_tooltip\"}}\"></a>\n\n\t\t{{if .Tag}}<a class=\"username hide_on_micro user_tag\">{{.Tag}}</a>{{else}}<a class=\"username hide_on_micro level\" aria-label=\"{{lang \"topic.post_level_aria\"}}\" title=\"{{lang \"topic.post_level_tooltip\"}}\">{{.Level}}</a><a class=\"username hide_on_micro level_label\" title=\"{{lang \"topic.post_level_tooltip\"}}\"></a>{{end}}\n\n\t\t</span>\n\t</article>\n{{end}}\n{{end}}</div>"
  },
  {
    "path": "templates/topics.html",
    "content": "{{template \"header.html\" . }}\n{{template \"topics_inner.html\" . }}\n{{template \"footer.html\" . }}"
  },
  {
    "path": "templates/topics_inner.html",
    "content": "<main id=\"topicsItemList\"itemscope itemtype=\"http://schema.org/ItemList\">\n{{if not .CurrentUser.Loggedin}}<link rel=\"canonical\"href=\"//{{.Site.URL}}/topics/{{if eq .Sort.SortBy \"mostviewed\"}}most-viewed/{{end}}{{if gt .Page 1}}?page={{.Page}}{{end}}\">{{end}}\n\t\n\t<div class=\"rowblock rowhead topic_list_title_block{{if .CurrentUser.Loggedin}} has_opt{{end}}\">\n\t\t<div class=\"rowitem topic_list_title\"><h1 itemprop=\"name\">{{.Title}}</h1></div>\n\t\t{{if .CurrentUser.Loggedin}}\n\t\t\t<div class=\"optbox\">\n\t\t\t{{if .ForumList}}\n\t\t\t\t<div class=\"opt filter_opt\">\n\t\t\t\t\t<a class=\"filter_opt_sep\"> - </a>\n\t\t\t\t\t<a href=\"#\"class=\"filter_opt_label link_label\"data-for=\"topic_list_filter_select\">{{if eq .Sort.SortBy \"mostviewed\"}}{{lang \"topic_list.most_viewed_filter\"}}{{else if eq .Sort.SortBy \"weekviews\"}}{{lang \"topic_list.week_views_filter\"}}{{else}}{{lang \"topic_list.most_recent_filter\"}}{{end}} <span class=\"filter_opt_pointy\">▾</span></a>\n\t\t\t\t\t<div id=\"topic_list_filter_select\"class=\"link_select\">\n\t\t\t\t\t\t<div class=\"link_option link_selected\">\n\t\t\t\t\t\t\t<a class=\"link_recent\"href=\"/topics/{{if .SelectedFids}}?fids={{range .SelectedFids}}{{.}}{{end}}{{end}}\">{{lang \"topic_list.most_recent_filter\"}}</a>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"link_option\">\n\t\t\t\t\t\t\t<a class=\"link_most_viewed\"href=\"/topics/most-viewed/{{if .SelectedFids}}?fids={{range .SelectedFids}}{{.}}{{end}}{{end}}\">{{lang \"topic_list.most_viewed_filter\"}}</a>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"link_option\">\n\t\t\t\t\t\t\t<a class=\"link_week_views\"href=\"/topics/week-views/{{if .SelectedFids}}?fids={{range .SelectedFids}}{{.}}{{end}}{{end}}\">{{lang \"topic_list.week_views_filter\"}}</a>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"pre_opt auto_hide\"></div>\n\t\t\t\t<div class=\"opt create_topic_opt\"title=\"{{lang \"topic_list.create_topic_tooltip\"}}\"aria-label=\"{{lang \"topic_list.create_topic_aria\"}}\"><a class=\"create_topic_link\"href=\"/topics/create/\"></a></div>\n\t\t\t\t{{/** TODO: Add a permissions check for this **/}}\n\t\t\t\t<div class=\"opt mod_opt\"title=\"{{lang \"topic_list.moderate_tooltip\"}}\">\n\t\t\t\t\t<a class=\"moderate_link\"href=\"#\"aria-label=\"{{lang \"topic_list.moderate_aria\"}}\"></a>\n\t\t\t\t</div>\n\t\t\t{{else}}<div class=\"opt locked_opt\"title=\"{{lang \"topics_locked_tooltip\"}}\"aria-label=\"{{lang \"topics_locked_aria\"}}\"><a></a></div>{{end}}\n\t\t\t</div><div style=\"clear:both;\"></div>\n\t\t{{end}}\n\t</div>\n\t\n\t{{if .CurrentUser.Loggedin}}\n\t{{template \"topics_mod_floater.html\" . }}\n\t\n\t{{if .ForumList}}\n\t{{/** TODO: Have a seperate forum list for moving topics? Maybe an AJAX forum search compatible with plugin_guilds? **/}}\n\t{{/** TODO: Add ARIA attributes for this **/}}\n\t<div id=\"mod_topic_mover\"class=\"modal_pane auto_hide\">\n\t\t<form action=\"/topic/move/submit/?s={{.CurrentUser.Session}}\"method=\"post\">\n\t\t\t<input id=\"mover_fid\"name=\"fid\"value=0 type=\"hidden\">\n\t\t\t<div class=\"pane_header\">\n\t\t\t\t<h3>{{lang \"topic_list.move_head\"}}</h3>\n\t\t\t</div>\n\t\t\t<div class=\"pane_body\">\n\t\t\t\t<div class=\"pane_table\">\n\t\t\t\t\t{{range .ForumList}}<div id=\"mover_fid_{{.ID}}\"data-fid=\"{{.ID}}\"class=\"pane_row\">{{.Name}}</div>{{end}}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div class=\"pane_buttons\">\n\t\t\t\t<button id=\"mover_submit\">{{lang \"topic_list.move_button\"}}</button>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n\t<div class=\"rowblock topic_create_form quick_create_form auto_hide\"aria-label=\"{{lang \"quick_topic.aria\"}}\">\n\t\t<form name=\"topic_create_form_form\"id=\"quick_post_form\"enctype=\"multipart/form-data\"action=\"/topic/create/submit/?s={{.CurrentUser.Session}}\"method=\"post\"></form>\n\t\t<img class=\"little_row_avatar\"src=\"{{.CurrentUser.MicroAvatar}}\"height=64 alt=\"{{lang \"quick_topic.avatar_alt\"}}\"title=\"{{lang \"quick_topic.avatar_tooltip\"}}\">\n\t\t<div class=\"main_form\">\n\t\t\t<div class=\"topic_meta\">\n\t\t\t\t<div class=\"formrow topic_board_row real_first_child\">\n\t\t\t\t\t<div class=\"formitem\"><select form=\"quick_post_form\"id=\"topic_board_input\"name=\"board\">\n\t\t\t\t\t\t{{range .ForumList}}<option value=\"{{.ID}}\"{{if eq .ID $.DefaultForum}}selected{{end}}>{{.Name}}</option>{{end}}\n\t\t\t\t\t</select></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"formrow topic_name_row\">\n\t\t\t\t\t<div class=\"formitem\">\n\t\t\t\t\t\t<input form=\"quick_post_form\"name=\"name\"placeholder=\"{{lang \"quick_topic.whatsup\"}}\"required>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{{template \"topics_quick_topic.html\" . }}\n\t\t</div>\n\t</div>\n\t\t{{end}}\n\t{{end}}\n\t<div class=\"rowblock more_topic_block more_topic_block_initial\">\n\t\t<div class=\"rowitem rowmsg\"><a href=\"\"class=\"more_topics\"></a></div>\n\t</div>\n\t<div id=\"topic_list\"class=\"rowblock topic_list topic_list_{{.Sort.SortBy}}\"aria-label=\"{{lang \"topics_list_aria\"}}\">\n\t\t{{range .TopicList}}{{template \"topics_topic.html\" . }}{{else}}<div class=\"rowitem passive rowmsg\">{{lang \"topics_no_topics\"}}{{if .CurrentUser.Loggedin}}{{if .CurrentUser.Perms.CreateTopic}}&nbsp;<a href=\"/topics/create/\">{{lang \"topics_start_one\"}}</a>{{end}}{{end}}</div>{{end}}\n\t</div>\n\t\n\t{{template \"paginator.html\" . }}\n\t</main>"
  },
  {
    "path": "templates/topics_mini.html",
    "content": "<div id=\"back\"class=\"zone_{{.Header.Zone}}{{if hasWidgets \"rightSidebar\" .Header }} shrink_main{{end}}\">\n<div id=\"main\">\n<div class=\"alertbox initial_alertbox\">{{range .Header.NoticeList}}{{template \"notice.html\" . }}{{end}}</div>\n{{template \"topics_inner.html\" . }}\n</div>\n<aside class=\"midRight sidebar\">{{dock \"rightSidebar\" .Header}}</aside>\n</div>"
  },
  {
    "path": "templates/topics_mod_floater.html",
    "content": "{{/** TODO: Hide these from unauthorised users? **/}}\n<div class=\"mod_floater auto_hide\">\n\t<form method=\"post\">\n\t\t<div class=\"mod_floater_head\">\n\t\t\t<span></span>\n\t\t</div>\n\t\t<div class=\"mod_floater_body\">\n\t\t\t<select class=\"mod_floater_options\">\n\t\t\t\t<option class=\"val_delete\" value=\"delete\">{{lang \"topic_list.moderate_delete\"}}</option>\n\t\t\t\t<option class=\"val_lock{{if not .CanLock}} auto_hide{{end}}\" value=\"lock\">{{lang \"topic_list.moderate_lock\"}}</option>\n\t\t\t\t<option class=\"val_move{{if not .CanMove}} auto_hide{{end}}\" value=\"move\">{{lang \"topic_list.moderate_move\"}}</option>\n\t\t\t</select>\n\t\t\t<button class=\"mod_floater_submit\">{{lang \"topic_list.moderate_run\"}}</button>\n\t\t</div>\n\t</form>\n</div>"
  },
  {
    "path": "templates/topics_quick_topic.html",
    "content": "<input form=\"quick_post_form\"id=\"has_poll_input\"name=\"has_poll\"value=0 type=\"hidden\">\n<div class=\"formrow topic_content_row\">\n\t<div class=\"formitem\">\n\t\t<textarea form=\"quick_post_form\"id=\"input_content\"name=\"content\"placeholder=\"{{lang \"quick_topic.content_placeholder\"}}\"required></textarea>\n\t</div>\n</div>\n<div class=\"formrow poll_content_row auto_hide\">\n\t<div class=\"formitem\">\n\t\t<div class=\"pollinput\"data-pollinput=0>\n\t\t\t<input type=\"checkbox\"disabled>\n\t\t\t<label class=\"pollinputlabel\"></label>\n\t\t\t<input form=\"quick_post_form\"name=\"pollinputitem[0]\"class=\"pollinputinput\"type=\"text\"placeholder=\"{{lang \"quick_topic.add_poll_option_first\"}}\">\n\t\t</div>\n\t</div>\n</div>\n<div class=\"formrow quick_button_row\">\n\t<div class=\"formitem\">\n\t\t<button form=\"quick_post_form\"class=\"formbutton\">{{lang \"quick_topic.create_button\"}}</button>\n\t\t<button form=\"quick_post_form\"class=\"formbutton\"id=\"add_poll_button\">{{lang \"quick_topic.add_poll_button\"}}</button>\n\t\t{{if .CurrentUser.Perms.UploadFiles}}\n\t\t<input name=\"upload_files\"form=\"quick_post_form\"id=\"upload_files\"multiple type=\"file\"class=\"auto_hide\">\n\t\t<label for=\"upload_files\"class=\"formbutton add_file_button\">{{lang \"quick_topic.add_file_button\"}}</label>\n\t\t<div id=\"upload_file_dock\"></div>{{end}}\n\t\t<button class=\"formbutton close_form\">{{lang \"quick_topic.cancel_button\"}}</button>\n\t</div>\n</div>"
  },
  {
    "path": "templates/topics_topic.html",
    "content": "<div class=\"topic_row{{if .Sticky}} topic_sticky{{else if .IsClosed}} topic_closed{{end}}{{if .CanMod}} can_mod{{end}}\"data-tid={{.ID}}>\n\t<div class=\"rowitem topic_left passive datarow\">\n\t\t<span class=\"selector\"></span>\n\t\t<a href=\"{{.Creator.Link}}\"><img src=\"{{.Creator.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.Creator.Name}}'s Avatar\"aria-hidden=\"true\"height=64></a>\n\t\t<span class=\"topic_inner_left\">\n\t\t\t<a class=\"rowtopic\"href=\"{{.Link}}\"itemprop=\"itemListElement\"title=\"{{.Title}}\"><span>{{.Title}}</span></a> {{if .ForumName}}<a class=\"rowsmall parent_forum\"href=\"{{.ForumLink}}\"title=\"{{.ForumName}}\">{{.ForumName}}</a>{{end}}\n\t\t\t<br><a class=\"rowsmall starter\"href=\"{{.Creator.Link}}\"title=\"{{.Creator.Name}}\">{{.Creator.Name}}</a>\n\t\t\t{{/** TODO: Avoid the double '|' when both .IsClosed and .Sticky are set to true. We could probably do this with CSS **/}}\n\t\t\t{{if .IsClosed}}<span class=\"rowsmall topic_status_e topic_status_closed\"title=\"{{lang \"status.closed_tooltip\"}}\"> | &#x1F512;&#xFE0E</span>{{end}}\n\t\t\t{{if .Sticky}}<span class=\"rowsmall topic_status_e topic_status_sticky\"title=\"{{lang \"status.pinned_tooltip\"}}\"> | &#x1F4CD;&#xFE0E</span>{{end}}\n\t\t</span>\n\t\t{{/** TODO: Phase this out of Cosora and remove it **/}}\n\t\t<div class=\"topic_inner_right rowsmall\">\n\t\t\t<span class=\"replyCount\">{{.PostCount}}</span><br>\n\t\t\t<span class=\"likeCount\">{{.LikeCount}}</span>\n\t\t</div>\n\t</div>\n\t<div class=\"topic_middle\">\n\t\t<div class=\"topic_middle_inside rowsmall\">\n\t\t\t<span class=\"replyCount\">{{.PostCount}}&nbsp;{{lang \"topic_list.replies_suffix\"}}</span>\n\t\t\t<span class=\"likeCount\">{{.LikeCount}}&nbsp;{{lang \"topic_list.likes_suffix\"}}</span>\n\t\t\t<span class=\"viewCount\">{{.ViewCount}}&nbsp;{{lang \"topic_list.views_suffix\"}}</span>\n\t\t</div>\n\t</div>\n\t<div class=\"rowitem topic_right passive datarow\">\n\t\t<div class=\"topic_right_inside\">\n\t\t\t<a href=\"{{.LastUser.Link}}\"><img src=\"{{.LastUser.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.LastUser.Name}}'s Avatar\"aria-hidden=\"true\"height=64></a>\n\t\t\t<span>\n\t\t\t\t<a href=\"{{.LastUser.Link}}\"class=\"lastName\"title=\"{{.LastUser.Name}}\">{{.LastUser.Name}}</a><br>\n\t\t\t\t<a href=\"{{.Link}}?page={{.LastPage}}{{if .LastReplyID}}#post-{{.LastReplyID}}{{end}}\"class=\"rowsmall lastReplyAt\"title=\"{{abstime .LastReplyAt}}\">{{reltime .LastReplyAt}}</a>\n\t\t\t</span>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "templates/widget_about.html",
    "content": "<div class=\"about widget\">\n\t<a id=\"aboutTitle\">{{.Name}}</a>\n\t<span id=\"aboutDesc\">{{.Text}}</span>\n</div>"
  },
  {
    "path": "templates/widget_menu.html",
    "content": "<div class=\"rowblock rowhead\">\n\t<div class=\"rowitem\"><h1>{{.Name}}</h1></div>\n</div>\n<nav class=\"rowblock\">{{range .MenuList}}\n\t<div class=\"rowitem{{if .Compact}} datarow{{end}}\"><a href=\"{{.Location}}\">{{.Text}}</a></div>\n{{end}}</nav>"
  },
  {
    "path": "templates/widget_online.html",
    "content": "<div class=\"rowblock rowhead widget_online\">\n\t<div class=\"rowitem\"><h1>{{.Name}}</h1></div>\n</div>\n<div class=\"rowblock rowlist bgavatars not_grid widget_online\">\n\t{{if lt .UserCount 30}}\n\t{{range .Users}}<div class=\"rowitem\"style=\"background-image:url('{{.Avatar}}');\">\n\t\t<img src=\"{{.Avatar}}\"class=\"bgsub\"alt=\"Avatar\"aria-hidden=\"true\">\n\t\t<a class=\"rowTitle\"href=\"{{.Link}}\">{{.Name}}</a>\n\t</div>\n\t{{else}}<div class=\"rowitem rowmsg\">{{lang \"widget.online_none_online\"}}</div>{{end}}\n\t{{else}}<div class=\"rowitem rowmsg\">{{langf \"widget.online_some_online\" .UserCount}}</div>{{end}}\n</div>"
  },
  {
    "path": "templates/widget_search_and_filter.html",
    "content": "<div class=\"search widget_search\">\n\t<input class=\"widget_search_input\"name=\"widget_search\"placeholder=\"Search\"type=\"search\">\n</div>\n<div class=\"rowblock filter_list widget_filter\">\n{{range .Forums}}\t<div class=\"rowitem filter_item{{if .Selected}} filter_selected{{end}}\"data-fid={{.ID}}><a href=\"/topics/?fids={{.ID}}\"rel=\"nofollow\">{{.Name}} ({{.TopicCount}})</a></div>\n{{end}}\n</div>"
  },
  {
    "path": "templates/widget_simple.html",
    "content": "<div class=\"rowblock rowhead widget_simple\">\n\t<div class=\"rowitem\"><h1>{{.Name}}</h1></div>\n</div>\n<div class=\"rowblock widget_simple\">\n\t<div class=\"rowitem\">{{.Text}}</div>\n</div>\n"
  },
  {
    "path": "themes/cosora/public/account.css",
    "content": ".sidebar, .footer .widget {\n\tdisplay: none;\n}\n#account_dashboard .colstack_right .coldyn_block {\n\tdisplay: flex;\n}\n#account_dashboard .coldyn_item {\n\tmargin-left: 16px;\n}\n#dash_left {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n\tpadding: 18px;\n\theight: 184px;\n\tposition: relative;\n}\n.dash_security, .account_soon {\n\ttext-transform: uppercase;\n\tfont-size: 11px;\n\tcolor: maroon;\n}\n#dash_username {\n\tdisplay: flex;\n\tfont-size: 18px;\n\ttext-align: center;\n\tmargin-bottom: 6px;\n}\n#dash_username input {\n\tfont-size: 16px;\n\twidth: 130px;\n\twidth: 80px;\n\tpadding-left: 8px;\n\tmargin-top: -4px;\n\tmargin-bottom: 6px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tcolor: hsl(0,0%,45%); /* TODO: Use this colour elsewhere? */\n\ttext-align: center;\n}\n#dash_username button {\n\tdisplay: none;\n\tmargin-left: 4px;\n\tpadding: 6px;\n\tmargin-top: 0px;\n\tmargin-bottom: 6px;\n\tpadding-top: 4px;\n\tpadding-bottom: 4px;\n}\n#dash_left img {\n\tdisplay: block;\n\tborder-radius: 48px;\n\theight: 72px;\n\twidth: 72px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n#dash_left label {\n\tdisplay: inline-block;\n\tmargin-right: 8px;\n}\n#dash_avatar_buttons {\n\tdisplay: flex;\n\tmargin-bottom: 3px;\n}\n#dash_right {\n\twidth: 100%;\n\tbackground: none !important;\n\tborder: none !important;\n}\n#dash_right .rowitem {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n\tpadding: 16px;\n}\n#dash_right .rowitem:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n\n.validated_email {\n\tcolor: green;\n}\n.invalid_email {\n\tcolor: crimson;\n}"
  },
  {
    "path": "themes/cosora/public/convo.css",
    "content": ".rowhead .rowitem, .convos_list .rowitem {\n\tdisplay: flex;\n}\n.convos_list .to_left {\n\tdisplay: flex;\n}\n.convos_list .rowitem img {\n\twidth: 26px;\n\theight: 26px;\n\tmargin-right: 8px;\n\tborder-radius: 24px;\n}\n.convos_list .rowitem a {\n\t/*margin-top: auto;\n\tmargin-bottom: auto;*/\n\tmargin-top: 4px;\n}\n.convos_list .to_right {\n\tmargin-top: auto;\n\tmargin-bottom: auto;\n}\n.convos_item_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.parti {\n\tmargin-bottom: 8px;\n\tpadding: 12px;\n}\n.parti .rowitem {\n\tdisplay: flex;\n}\n.parti_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.convo_row_box .topRow {\n\tdisplay: flex;\n}\n.convo_row_box .topRow .controls {\n\tpadding-top: 16px;\n\tpadding-right: 16px;\n}\n.convo_row_box .content_column {\n\tmargin-bottom: 16px;\n}\n.convo_row_box button {\n\tbackground: inherit;\n\tcolor: var(--lighter-text-color);\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n\tcursor: pointer;\n}\n.convo_row_box button:hover {\n\tcolor: var(--light-text-color);\n}\n.convo_row_box button.edit_item:after,\n.convo_row_box button.delete_item:after,\n.convo_row_box button.report_item:after {\n\tfont: normal normal normal 14px/1 FontAwesome;\n}\n.convo_row_box button.edit_item:after {\n\tcontent: \"\\f040\";\n}\n.convo_row_box button.delete_item:after {\n\tcontent: \"\\f1f8\";\n}\n.convo_row_box button.report_item:after {\n\tcontent: \"\\f024\";\n}\n.convo_row_box {\n\tmargin-bottom: 12px;\n}\n.convo_row_box:empty {\n\tdisplay: none !important;\n}\n.convo_row_box .rowitem {\n\tbackground-image: none !important;\n}\n.convo_row_box .comment:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n.convo_row_box .comment .userbit {\n\tdisplay: flex;\n\tmargin-left: 14px;\n\tmargin-top: 14px;\n\tmargin-bottom: 8px;\n}\n.convo_row_box .comment img {\n\twidth: 40px;\n\theight: 40px;\n\tborder-radius: 62px;\n\tmargin-right: 8px;\n}\n.convo_row_box .comment .nameAndTitle {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-top: 2px;\n}\n.convo_row_box .comment .nameAndTitle .user_tag {\n\tfont-size: 15px;\n}\n.convo_row_box .comment .content_column {\n\tpadding-left: 14px;\n\tpadding-right: 14px;\n\tdisplay: flex;\n\twidth: 100%\n}\n.convo_row_box .comment .controls {\n\tmargin-left: auto;\n}\n/*#profile_comments_form .topic_reply_form {\n\tborder-top: 1px solid var(--element-border-color) !important;\n}*/"
  },
  {
    "path": "themes/cosora/public/main.css",
    "content": ":root {\n\t--header-border-color: hsl(0,0%,80%);\n\t--element-border-color: hsl(0,0%,85%);\n\t--element-background-color: white;\n\t--replies-lang-string: \"{{lang \"topics_replies_suffix\" . }}\";\n\t--topics-lang-string: \"{{lang \"forums.topics_suffix\" . }}\";\n\t--likes-lang-string: \"{{lang \"topics_gap_likes_suffix\" . }}\";\n\t--primary-link-color: hsl(0,0%,40%);\n\t--primary-text-color: hsl(0,0%,20%);\n\t--lightened-primary-text-color: hsl(0,0%,30%);\n\t--extra-lightened-primary-text-color: hsl(0,0%,40%);\n\t--inverse-primary-text-color: white;\n\t--light-text-color: hsl(0,0%,55%);\n\t--lighter-text-color: hsl(0,0%,65%);\n\n\t--tinted-background-color: hsl(0,0%,98%);\n}\n\n* {\n\tbox-sizing: border-box;\n\t-moz-box-sizing: border-box;\n\t-webkit-box-sizing: border-box;\n}\n\n@font-face {\n  font-family: 'FontAwesome';\n  src: url('../font-awesome-4.7.0/fonts/fontawesome-webfont.eot?v=4.7.0');\n  src: url('../font-awesome-4.7.0/fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');\n  font-weight: normal;\n  font-style: normal;\n}\n\nbody {\n\tfont-size: 16px;\n\tfont-family: arial;\n\tmargin: 0px;\n\tcolor: var(--lightened-primary-text-color);\n}\na {\n\ttext-decoration: none;\n\tcolor: var(--primary-link-color);\n}\n\nbody, #main {\n\tbackground-color: var(--tinted-background-color);\n}\n#back {\n\tpadding: 8px;\n\tpadding-top: 0px;\n\tdisplay: flex;\n\tpadding-left: 0px;\n\tpadding-right: 0px;\n\tpadding-bottom: 0px;\n}\n.footBlock {\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n}\n#container {\n\tbackground-color: var(--element-background-color);\n}\n\n#main {\n\twidth: 100%;\n\tpadding-top: 14px;\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n\tpadding-bottom: 14px;\n}\n.sidebar {\n\twidth: 200px;\n\tdisplay: none;\n}\n.nav {\n\tpadding-top: 16px;\n\tborder-bottom: 1.5px solid var(--header-border-color);\n}\n\nli {\n\tmargin-right: 12px;\n}\n.menu_left:not(:last-child):after, .menu_alerts:after {\n\tcontent: \"\";\n\tmargin-left: 11px;\n\twidth: 1px;\n\tdisplay: inline-block;\n\theight: 15px;\n\tposition: relative;\n\ttop: 2px;\n\tborder-right: 1px solid var(--header-border-color);\n}\n\n#menu_overview {\n\tfont-size: 22px;\n\tmargin-right: 12px;\n\tletter-spacing: 1px;\n}\n#menu_overview:after {\n\tmargin-right: 5px !important;\n\theight: 20px !important;\n}\n\n#menu_forums a:before, .menu_topics a:before, .alert_bell:before, .menu_account a:before, .menu_profile a:before, .menu_panel a:before, .menu_logout a:before {\n\tfont: normal normal normal 14px/1 FontAwesome;\n}\n\n#menu_forums a:before {\n\tcontent: \"\\f03a\";\n\tmargin-right: 6px;\n}\n.menu_topics a:before {\n\tmargin-right: 4px;\n\tcontent: \"\\f27b\";\n\tposition: relative;\n\ttop: -2px;\n}\n.menu_alerts {\n\tcolor: var(--primary-link-color);\n\tdisplay: flex;\n}\n.alert_bell:before {\n\tcontent: \"\\f01c\";\n}\n.menu_alerts:not(.has_alerts) .alert_counter {\n\tdisplay: none;\n}\n.alert_counter {\n\twidth: 4px;\n\theight: 4px;\n\toverflow: hidden;\n\tbackground-color: red;\n\topacity: 0.7;\n\tborder-radius: 30px;\n\tposition: relative;\n\ttop: 2px;\n\tleft: -1px;\n}\n.alert_aftercounter:before {\n\tcontent: \"{{lang \"menu_alerts\" . }}\";\n\tmargin-left: 4px;\n}\n\n.menu_account a:before {\n\tcontent: \"\\f2c3\";\n\tmargin-right: 6px;\n}\n.menu_profile a:before {\n\tcontent: \"\\f2c0\";\n\tmargin-right: 5px;\n\tposition: relative;\n\ttop: -1px;\n\tfont-size: 14px;\n}\n.menu_panel a:before {\n\tmargin-right: 6px;\n\tcontent: \"\\f108\";\n}\n.menu_logout a:before {\n\tcontent: \"\\f08b\";\n\tmargin-right: 3px;\n}\n\n#main_menu {\n\tdisplay: flex;\n\tlist-style-type: none;\n\tpadding: 0px;\n\tmargin-left: 14px;\n\tmargin-bottom: 12px;\n\tmargin-top: 0px;\n}\n.menu_alerts:not(.selectedAlert) .alertList {\n\tdisplay: none;\n}\n.alertList {\n\tposition: fixed;\n\ttop: 54px;\n\tleft: 0px;\n\tz-index: 50;\n\tbackground: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n.alertList .alertItem {\n\tpadding: 12px;\n}\n.alertItem.withAvatar {\n\tbackground-image: none !important;\n\tpadding-left: 12px;\n\tfont-size: 15px;\n\tdisplay: flex;\n}\n.alertItem.withAvatar .text {\n\tmargin-top: 8px;\n}\n.alertItem.withAvatar:not(:last-child) .text {\n\tborder-bottom: 1px solid var(--element-border-color);\n\tpadding-bottom: 16px;\n}\n.alertItem .bgsub {\n\twidth: 32px;\n\theight: 32px;\n\tborder-radius: 30px;\n\tmargin-right: 12px;\n}\n.alertItem.withAvatar:not(:first-child) {\n\tpadding-top: 0px;\n}\n\n.rowblock, .rowitem, .colstack_head, .colstack_item {\n\tposition: sticky;\n}\n.rowblock, .colstack_head {\n\tmargin-bottom: 12px;\n\tborder: 1px solid var(--header-border-color);\n\tborder-bottom: 2px solid var(--header-border-color);\n\tmargin-left: 12px;\n}\n/* TODO: Reduce the number of nots */\n/* TODO: Apply the property to the rowitem on the colstack_head rather than the container itself */\n.rowblock:not(.topic_list):not(.forum_list):not(.post_container):not(.topic_reply_container), .colstack_head, .topic_row .rowitem, .forum_list .rowitem {\n\tbackground-color: var(--element-background-color);\n}\n.rowblock {\n\tmargin-right: 12px;\n}\n.colstack_right {\n\tpadding-right: 12px;\n}\n\n.rowhead, .opthead, .colstack_head {\n\tpadding: 13px;\n\tpadding-top: 14px;\n\tpadding-bottom: 14px;\n}\n.rowhead:not(:first-child), .opthead:not(:first-child), .colstack_head:not(:first-child) {\n\tmargin-top: 8px;\n}\n.rowhead h1, .opthead h1, .colstack_head h1,\n.rowhead h2, .opthead h2, .colstack_head h2 {\n\tfont-size: 19px;\n\tfont-weight: normal;\n\tcolor: var(--lightened-primary-text-color);\n\tdisplay: inline-block;\n}\n.rowhead h2, .opthead h2, .colstack_head h2 {\n\tfont-size: 17px;\n}\n.colstack_head a h1 {\n\tcolor: var(--primary-link-color);\n}\n.colstack_head.menuhead a h1 {\n\tfont-size: 16px;\n}\n.colstack_head h1 {\n\tfont-size: 18px;\n}\nh1, h2, h3, h4, h5 {\n\t-webkit-margin-before: 0;\n\t-webkit-margin-after: 0;\n\tmargin-block-start: 0;\n\tmargin-block-end: 0;\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n\n.rowmsg.rowitem {\n\tpadding: 12px;\n}\n.topic_list .rowmsg.rowitem,\n.forum_list .rowmsg.rowitem {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n}\n\n.colstack {\n\tdisplay: flex;\n}\n.colstack:not(#profile_container) .colstack_left {\n\twidth: 300px;\n}\n.colstack:not(#profile_container) .colstack_right {\n\twidth: 100%;\n}\n\n.extra_little_row_avatar {\n\theight: 38px;\n\twidth: 38px;\n\tmargin-right: 8px;\n}\n.little_row_avatar {\n\theight: 48px;\n\twidth: 48px;\n}\n.extra_little_row_avatar, .little_row_avatar {\n\tborder-radius: 30px;\n}\n\n.mod_floater {\n\tposition: fixed;\n\tbottom: 15px;\n\tright: 15px;\n\twidth: 200px;\n\theight: 115px;\n\tbackground-color: var(--inverse-primary-text-color);\n\tborder: 1px solid var(--header-border-color);\n\tborder-bottom: 2px solid var(--header-border-color);\n\tz-index: 9999;\n\tanimation: fadein 0.8s;\n}\n.mod_floater_head {\n\tdisplay: flex;\n\tborder-bottom: 1px solid var(--element-border-color);\n\tmargin-left: 16px;\n\tmargin-right: 16px;\n\tmargin-bottom: 10px;\n}\n.mod_floater_head span {\n\tcolor: hsl(0,0%,55%);\n\tfont-size: 14px;\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n}\n.mod_floater_body {\n\tdisplay: flex;\n}\n.mod_floater_body select {\n\tmargin-left: auto;\n\tborder-bottom: 1px solid var(--header-border-color);\n\toutline: none;\n}\n.mod_floater_body button {\n\tmargin-left: 10px;\n\tmargin-right: auto;\n\toutline: none;\n\tpadding-left: 10px;\n\tbackground: hsl(9, 97%, 56%);\n\tborder-radius: 2px;\n\tpadding-right: 10px;\n\tpadding-top: 6px;\n\tpadding-bottom: 6px;\n\tcolor: var(--inverse-primary-text-color);\n\tfont-size: 13px;\n\tfont-weight: bold;\n\tmargin-top: -2px;\n}\n\n.modal_pane {\n\tposition: fixed;\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n\tbackground-color: var(--inverse-primary-text-color);\n\tborder: 1px solid var(--header-border-color);\n\tborder-bottom: 2px solid var(--header-border-color);\n\t/*padding: 8px;*/\n\tpadding-left: 24px;\n\tpadding-right: 24px;\n\tz-index: 9999;\n\tanimation: fadein 0.8s;\n}\n.pane_header {\n\tcolor: hsl(0,0%,55%);\n\tpadding-top: 16px;\n\tpadding-bottom: 12px;\n\tborder-bottom: 1px solid var(--element-border-color);\n\tmargin-bottom: 2px;\n}\n.pane_header h3 {\n\tfont-size: 14px;\n\tfont-weight: normal;\n}\n\n.pane_row {\n\tcolor: var(--light-text-color);\n\tborder-bottom: 1px solid var(--element-border-color);\n\tfont-size: 13px;\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\tmargin-bottom: 3px;\n\tcursor: pointer;\n}\n.pane_selected {\n\tfont-weight: bold;\n}\n\n.pane_buttons {\n\tpadding-top: 12px;\n\tpadding-bottom: 16px;\n}\n\n@keyframes fadein {\n\tfrom { opacity: 0; }\n\tto { opacity: 1; }\n}\n\n.topic_list_title_block {\n\tdisplay: flex;\n}\n.topic_list_title_block .pre_opt {\n\tborder-left: 1px solid var(--element-border-color);\n\tpadding-left: 11px;\n\theight: 20px;\n\tcolor: var(--light-text-color);\n\tmargin-right: 9px;\n}\n.topic_list_title_block .pre_opt:before {\n\tcontent: \"{{lang \"topics_click_topics_to_select\" . }}\";\n\tfont-size: 14px;\n}\n.topic_list_title, .forum_title {\n\tmargin-right: auto;\n}\n\n.mod_opt .moderate_link {\n\tborder-left: 1px solid var(--element-border-color);\n\tpadding-left: 12px;\n\theight: 20px;\n\tcolor: hsl(0,0%,65%);\n}\n.mod_opt .moderate_link:hover {\n\tcolor: var(--light-text-color);\n}\n.mod_opt .moderate_link:before {\n\tcontent: \"\\f0e3\";\n\tfont: normal normal normal 14px/1 FontAwesome;\n\tfont-size: 18px;\n}\n.mod_opt .moderate_open {\n\tdisplay: none;\n}\n.filter_opt {\n\tdisplay: none;\n}\n\n.auto_hide,\n.show_on_edit:not(.edit_opened),\n.hide_on_edit.edit_opened,\n.show_on_block_edit:not(.edit_opened),\n.hide_on_block_edit.edit_opened,\n.link_select:not(.link_opened) {\n\tdisplay: none !important;\n}\n.topic_create_form {\n\tdisplay: flex !important;\n\tpadding-bottom: 12px;\n}\n.topic_create_form .main_form {\n\twidth: 100%;\n\tmargin-right: 25px;\n}\n.topic_create_form.selectedInput .main_form {\n\tmargin-right: 50px;\n\tmargin-left: 18px;\n}\n.topic_create_form .topic_meta {\n\tdisplay: flex;\n}\n\n.topic_create_form img {\n\tdisplay: inline-block;\n\tmargin-top: 12px;\n\tmargin-left: 8px;\n}\n.topic_board_row, .topic_create_form .quick_button_row {\n\tdisplay: none;\n}\n.topic_name_row {\n\tmargin-top: 20px;\n\tmargin-left: 12px;\n\twidth: 100%;\n}\n#forum_topic_create_form.selectedInput .topic_name_row {\n\tmargin-left: 20px;\n}\n.topic_content_row {\n\tdisplay: none;\n\tmargin-left: 12px;\n\twidth: 100%;\n\tmin-width: 0;\n}\n.selectedInput .topic_board_row {\n\tdisplay: inline-block;\n\tmargin-top: 16px;\n\tmargin-left: 12px;\n}\n.selectedInput .topic_name_row {\n\tmargin-top: 16px;\n\tmargin-bottom: 8px;\n\tmargin-left: 8px;\n}\n.selectedInput .topic_content_row {\n\tdisplay: inline-block;\n}\n.topic_create_form.selectedInput .quick_button_row {\n\tdisplay: inline-block;\n\twidth: 100%;\n}\n\n.topic_board_row select {\n\theight: 27px;\n\twidth: 100px;\n\tmargin-left: 10px;\n}\n.topic_name_row input, .ip_search_input {\n\twidth: 100%;\n\tdisplay: inline-block;\n\tpadding-left: 8px;\n}\ninput, select {\n\tborder: none;\n\tborder-bottom: 1px solid var(--header-border-color);\n\toutline: none;\n}\n.topic_content_row textarea {\n\tmin-height: 80px;\n\twidth: 100%;\n}\n\ninput[type=checkbox] {\n\tdisplay: none;\n}\ninput[type=checkbox] + label {\n\tdisplay: inline-block;\n\twidth: 12px;\n\theight: 12px;\n\tmargin-bottom: -2px;\n\tborder: 1px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n}\ninput[type=checkbox]:checked + label .sel {\n\tdisplay: block;\n\twidth: 5px;\n\theight: 5px;\n\tbackground: var(--element-border-color);\n\tmargin-top: -2px;\n}\n.poll_content_row {\n\tpadding-left: 20px;\n\tpadding-top: 4px;\n\tpadding-bottom: 2px;\n}\n.poll_content_row .formitem {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.pollinput:not(:only-child):not(:first-child) {\n\tmargin-bottom: 5px;\n}\n\ninput[type=checkbox] + label.poll_option_label {\n\twidth: 18px;\n\theight: 18px;\n}\ninput[type=checkbox]:checked + label.poll_option_label .sel {\n\tdisplay: block;\n\twidth: 10px;\n\theight: 10px;\n\tmargin-left: 3px;\n\tmargin-top: 3px;\n\tbackground: var(--element-border-color);\n}\n.poll_option {\n\tpadding-bottom: 5px;\n\tdisplay: flex;\n}\n.poll_option_text {\n\tdisplay: block;\n\tmargin-left: 8px;\n\tmargin-top: 1px;\n\tfont-size: 15px;\n\tposition: relative;\n\ttop: -1px;\n\tcolor: var(--light-text-color);\n}\n#poll_option_text_0 {\n\tcolor: #d70206;\n}\n#poll_option_text_1 {\n\tcolor: #f05b4f;\n}\n.poll_buttons {\n\tdisplay: flex;\n\tmargin-top: 8px;\n}\n.poll_buttons button {\n\tmargin-right: 5px;\n}\n.topic_reply_form .pollinput {\n\tmargin-left: 16px;\n\tmargin-top: 4px;\n}\n.poll_results {\n\tmargin-left: 14px;\n}\n\n.formbutton {\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tmargin-top: 12px;\n}\n.quick_button_row .formitem {\n\tdisplay: flex;\n\tmargin-left: 2px;\n}\n.quick_button_row button, .quick_button_row label, .ip_search_search, .formbutton, button {\n\tpadding-left: 10px;\n\tpadding-right: 10px;\n\tpadding-top: 6px;\n\tpadding-bottom: 6px;\n\tcolor: var(--inverse-primary-text-color);\n\tfont-size: 13px;\n\tfont-weight: bold;\n\tborder-width: initial;\n\tborder-style: none;\n\tborder-color: initial;\n\tborder-image: initial;\n\toutline: none;\n\tbackground: hsl(209, 97%, 56%);\n\tborder-radius: 2px;\n}\n.quick_button_row button, .quick_button_row label, .ip_search_search {\n\tmargin-right: 0px;\n}\n.quick_button_row button, .quick_button_row label {\n\tmargin-left: 10px;\n\tmargin-top: 8px;\n}\n.quick_button_row #add_poll_button {\n\tbackground: hsl(209, 47%, 56%);\n}\n.quick_button_row .add_file_button {\n\tbackground: hsl(129, 57%, 56%);\n}\n.quick_button_row .close_form {\n\tbackground: hsl(9, 0%, 56%);\n}\n\n.quick_button_row #upload_file_dock {\n\tdisplay: flex;\n}\nlabel.uploadItem {\n\tbackground-size: 25px 30px;\n\tbackground-repeat: no-repeat;\n\tpadding-left: 33px;\n}\n\nselect, input, textarea {\n\tbackground: var(--element-background-color);\n\tpadding: 5px;\n\tcolor: hsl(0,0%,30%);\n}\ninput, select {\n\tcolor: var(--primary-text-color);\n}\ninput:not(:focus):not([type=\"submit\"]), select:not(:focus) {\n\tcolor: var(--light-text-color);\n}\ntextarea {\n\toutline: none;\n\tborder: 1px solid var(--header-border-color);\n}\n\n.topic_reply_container {\n\tdisplay: flex;\n\tborder: 0;\n}\n.topic_reply_form {\n\tmargin: 0px;\n\twidth: 100%;\n\theight: min-content;\n}\n.topic_reply_form .formrow {\n\tpadding: 0px !important;\n}\n.topic_reply_form .trumbowyg-button-pane:after {\n\tdisplay: none;\n}\n.topic_reply_form .trumbowyg-box {\n\tmin-height: auto;\n}\n.topic_reply_form .trumbowyg-editor {\n\tborder-left: none;\n\tborder-right: none;\n\tmin-height: 103px;\n\tmax-height: 200px;\n\toverflow-y: scroll;\n}\n.topic_reply_form .quick_button_row {\n\tmargin-bottom: 7px;\n}\n\n#prevFloat, #nextFloat {\n\tdisplay: none;\n}\n\n.topic_list {\n\tborder: none;\n}\n.topic_list .topic_row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n}\n.topic_list .topic_row:last-child .rowitem {\n\tmargin-bottom: 0px;\n}\n#forum_topic_list .topic_inner_left .starter {\n\tdisplay: inline-block;\n\twidth: 200px;\n}\n\n.rowlist .rowitem, .topic_left, .topic_right {\n\tmargin-bottom: 8px;\n\tpadding: 4px;\n\tdisplay: flex;\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n.topic_row.new_item .topic_left, .topic_row.new_item .topic_right {\n\tbackground-color: rgb(239, 255, 255);\n\tborder: 1px solid rgb(187, 217, 217);\n\tborder-bottom: 2px solid rgb(187, 217, 217);\n}\n.topic_row.new_item .topic_left {\n\tborder-right: none;\n}\n.topic_row.new_item .topic_right {\n\tborder-left: none;\n}\n.hide_ajax_topic {\n\tdisplay: none !important;\n}\n.topic_middle {\n\tdisplay: none;\n}\n.rowlist .rowitem {\n\tbackground-color: var(--element-background-color);\n\tpadding: 12px;\n}\n.rowlist.bgavatars {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, 1fr);\n\tgrid-template-columns: repeat(auto-fill, 150px);\n\tgrid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n\tgrid-gap: 6px 12px;\n\tborder: none;\n\tbackground: none !important;\n}\n.rowlist .rowitem {\n\tdisplay: flex;\n}\n.bgavatars .rowitem {\n\tbackground-image: none !important;\n}\n.rowlist.bgavatars .rowitem {\n\tflex-direction: column;\n\tpadding-top: 16px;\n\tpadding-bottom: 10px;\n}\n.bgavatars .bgsub {\n\tborder-radius: 30px;\n\theight: 48px;\n\twidth: 48px;\n\tmargin-top: 8px;\n\tmargin-left: 4px;\n}\n.rowlist.bgavatars .bgsub {\n\theight: 80px;\n\twidth: 80px;\n\tborder-radius: 48px;\n\tmargin-top: 4px;\n\tmargin-bottom: 8px;\n}\n.rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowitem > a, .rowlist.bgavatars .rowitem > span {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.rowlist .rowTitle {\n\tfont-size: 20px;\n\tmargin-bottom: 3px;\n}\n.rowlist.bgavatars .rowAvatar {\n\tmargin-bottom: -4px;\n}\n.rowlist .panel_compactrow {\n\tpadding: 16px;\n}\n\n.loglist .to_left small {\n\tmargin-left: 2px;\n\tfont-size: 12px;\n}\n.loglist .to_right span {\n\tfont-size: 14px;\n}\n\n.topic_list .rowtopic {\n\tfont-size: 16px;\n\tmargin-right: 1px;\n\twhite-space: nowrap;\n\tdisplay: inline-block;\n}\n.topic_list .rowtopic span {\n\tmax-width: 162px;\n\toverflow: hidden;\n\tcolor: var(--primary-text-color);\n}\n.topic_list .rowsmall {\n\tfont-size: 15px;\n}\n\n.topic_list .rowsmall.starter:before {\n\tcontent: \"\\f007\";\n\tfont: normal normal normal 14px/1 FontAwesome;\n\tmargin-right: 5px;\n\tfont-size: 15px;\n}\n\n.topic_list .lastReplyAt {\n\tfont-size: 14px;\n}\n.topic_list .topic_status_e {\n\tdisplay: none;\n}\n\n.topic_left {\n\tflex: 1 1 calc(100% - 380px);\n\tborder-right: none;\n}\n.topic_inner_right {\n\tmargin-left: 15%;\n\tmargin-right: auto;\n\tfont-size: 17px;\n}\n\n.rowsmall {\n\tfont-size: 14px;\n}\n.topic_inner_right.rowsmall {\n\tmargin-top: 15px;\n}\n\n/* Experimenting here */\n.topic_inner_right {\n\tmargin-top: 12px;\n}\n.topic_inner_right span {\n\tfont-size: 16px;\n}\n.topic_inner_right span:after {\n\tfont-size: 13.5px;\n}\n/* End Experiment */\n\n.topic_inner_right .replyCount:after {\n\tcontent: var(--replies-lang-string);\n\tcolor: var(--lightened-primary-text-color);\n}\n.topic_inner_right .topicCount:after {\n\tcontent: var(--topics-lang-string);\n\tcolor: var(--lightened-primary-text-color);\n}\n.topic_inner_right .likeCount:after {\n\tcontent: var(--likes-lang-string);\n\tcolor: var(--lightened-primary-text-color);\n}\n.parent_forum {\n\tcolor: var(--lightened-primary-text-color);\n}\n\n.topic_right {\n\tflex: 1 1 0px;\n\tborder-left: none;\n}\n.topic_right_inside {\n\tdisplay: flex;\n}\n\n.topic_left img {\n\tborder-radius: 30px;\n\theight: 48px;\n\twidth: 48px;\n\tmargin-top: 8px;\n\tmargin-left: 4px;\n}\n.topic_right_inside img {\n\tborder-radius: 30px;\n\theight: 42px;\n\twidth: 42px;\n\tmargin-top: 10px;\n}\n\n.topic_left .topic_inner_left {\n\tmargin-top: 12px;\n\tmargin-left: 8px;\n\tmargin-bottom: 14px;\n\twidth: 220px;\n}\n.topic_right_inside > span {\n\tmargin-top: 12px;\n\tmargin-left: 8px;\n}\n.topic_right_inside .lastName {\n\tfont-size: 14px;\n}\n\n.topic_sticky .topic_left, .topic_sticky .topic_right {\n\tborder-bottom: 2px solid hsl(51, 60%, 70%);\n}\n.topics_moderate .topic_row:not(.can_mod) .topic_left,\n.topics_moderate .topic_row:not(.can_mod) .topic_right {\n\tbackground-color: #EEEEEE;\n}\n.topics_moderate .can_mod:hover .topic_left, .topics_moderate .can_mod:hover .topic_right {\n\tbackground-color: hsl(81, 60%, 97%);\n}\n.topic_selected .topic_left, .topic_selected .topic_right {\n\tbackground-color: hsl(81, 60%, 95%);\n}\n\n.level_complete, .level_future, .level_inprogress {\n\tdisplay: flex;\n}\n.progressWrap {\n\tmargin-left: auto;\n\twidth: auto !important;\n}\n\n@element .topic_left .rowtopic and (min-width: 160px) {\n\t$this, $this span, $this + .parent_forum {\n\t\tfloat: left;\n\t}\n\t$this + .parent_forum {\n\t\tmargin: 2px;\n\t\tmargin-left: 3px;\n\t}\n\t$this:after {\n\t\tcontent: \"...\";\n\t\tfloat: left;\n\t}\n}\n\n@element .topic_list and (min-width: 738px) {\n\t.topic_left .topic_inner_left {\n\t\twidth: calc(240px + 1%);\n\t}\n}\n\n@element .topic_list and (min-width: 875px) {\n\t.topic_left .topic_inner_left {\n\t\twidth: calc(240px + 10%);\n\t}\n}\n\n.more_topic_block_initial {\n\tdisplay: none;\n}\n.more_topic_block_active {\n\tdisplay: block;\n}\n\n.forum_list, .post_container {\n\tborder: none;\n}\n.forum_list .rowitem {\n\tdisplay: flex;\n\tmargin-bottom: 8px;\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tpadding: 14px;\n}\n.forum_list .forum_nodesc {\n\tfont-style: italic;\n}\n.forum_right {\n\tdisplay: flex;\n}\n.forum_right span {\n\tmargin-top: 1px;\n}\n.shift_right {\n\tmargin-left: auto;\n\tmargin-right: 8px;\n}\n\n.topic_item {\n\tdisplay: flex;\n}\n.topic_item .topic_name_input {\n\twidth: 100%;\n\tpadding-left: 12px;\n\tmargin-right: 12px;\n}\n.topic_item .formbutton {\n\tmargin-top: 0px;\n}\n\n.topic_block {\n\tpadding-bottom: 10px;\n}\n.topic_name_forum_sep {\n\tmargin-left: 6px;\n\tmargin-right: 6px;\n\tline-height: 26px;\n\tfont-size: 18px;\n}\n.topic_forum {\n\tline-height: 24px;\n}\n.topic_view_count {\n\tmargin-left: 6px;\n\tfont-size: 14px;\n\tmargin-top: 4px;\n}\n.topic_view_count:before {\n\tcontent: \"(\"\n}\n.topic_view_count:after {\n\tcontent: \"{{lang \"topic.view_count_suffix\" . }})\";\n}\n.postImage {\n\twidth: 100%;\n\tmax-width: 240px;\n}\nblockquote {\n\tmargin: 0px;\n\tbackground-color: #EEEEEE;\n\tpadding: 12px;\n\tmargin-top: 12px;\n\tmargin-bottom: -3px;\n}\nblockquote:first-child {\n\tmargin-top: 0px;\n}\n.post_item {\n\tdisplay: flex;\n\tmargin-bottom: 16px;\n}\n.userinfo, .content_container {\n\tbackground-color: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n.userinfo {\n\tmargin-right: 16px;\n\tdisplay: flex;\n\tflex-direction: column;\n\tpadding-top: 30px;\n\tpadding-left: 42px;\n\tpadding-right: 42px;\n\tpadding-bottom: 18px;\n\theight: min-content;\n}\n.user_meta {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.content_container {\n\twidth: 100%;\n\tpadding: 17px;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.avatar_item {\n\tbackground-position: 0px -10px;\n\tbackground-size: 120px;\n}\n.avatar_item, .aitem {\n\tborder-radius: 62px;\n\twidth: 72px;\n\theight: 72px;\n\tmargin-bottom: 8px;\n}\n.the_name, .userinfo .tag_block {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.the_name {\n\tfont-size: 18px;\n\tcolor: var(--lightened-primary-text-color);\n}\n.action_item .userinfo {\n\tdisplay: none;\n}\n.action_item .content_container {\n\tdisplay: flex;\n\tflex-direction: row;\n}\n.action_item .action_icon {\n\tdisplay: none;\n}\n.userinfo .tag_block {\n\tcolor: var(--extra-lightened-primary-text-color);\n}\n.post_item .user_content {\n\tmargin-bottom: 10px;\n}\n.user_content h2, .user_content h3 {\n\tmargin-bottom: 12px;\n\tfont-weight: normal;\n}\n.user_content h4 {\n\tmargin-bottom: 8px;\n\tfont-weight: normal;\n}\n.user_content strong h2, .user_content strong h3, .user_content strong h4 {\n\tfont-weight: bold;\n}\nred {\n\tcolor: red;\n}\n.update_buttons {\n\tmargin-top: -8px;\n\tmargin-bottom: 8px;\n}\n.update_buttons .add_file_button {\n\tmargin-left: 8px;\n}\n.button_container {\n\tmargin-top: auto;\n\tdisplay: flex;\n}\n.action_button {\n\tmargin-right: 5px;\n\tcolor: var(--light-text-color);\n\tfont-size: 14px;\n\tdisplay: inline-block;\n}\n.action_button_left {\n\tdisplay: flex;\n}\n.action_button_right {\n\tdisplay: inline-flex;\n\tmargin-left: auto;\n}\n.like_count {\n\tdisplay: none;\n}\n.has_likes .like_count {\n\tdisplay: block;\n}\n.like_count:after {\n\tcontent: \"{{lang \"topic.like_count_suffix\" . }}\";\n\tmargin-right: 6px;\n}\n\n.post_item .add_like:after, .post_item .remove_like:after,\n.created_at:before,\n.ip_item:before {\n\tborder-left: 1px solid var(--element-border-color);\n\tcontent: \"\";\n\tmargin-top: 1px;\n\tmargin-bottom: 1px;\n}\n.created_at:before, .ip_item:before {\n\tmargin-right: 10px;\n}\n.post_item .add_like:after, .post_item .remove_like:after {\n\tmargin-left: 10px;\n\tmargin-right: 5px;\n}\n/* TODO: Use a less bold bold */\n.post_item .remove_like:before {\n\tfont-weight: bold;\n}\n.created_at {\n\tmargin-right: 10px;\n}\n\n.add_like:before, .remove_like:before {\n\tcontent: \"{{lang \"topic.plus_one\" . }}\";\n}\n.button_container .open_edit:after, .edit_item:after{\n\tcontent: \"{{lang \"topic.edit_button_text\" . }}\";\n}\n.quote_item:after {\n\tcontent: \"{{lang \"topic.quote_button_text\" . }}\";\n}\n.delete_item:after {\n\tcontent: \"{{lang \"topic.delete_button_text\" . }}\";\n}\n.ip_item_button:after {\n\tcontent: \"{{lang \"topic.ip_button_text\" . }}\";\n}\n.lock_item:after {\n\tcontent: \"{{lang \"topic.lock_button_text\" . }}\";\n}\n.unlock_item:after {\n\tcontent: \"{{lang \"topic.unlock_button_text\" . }}\";\n}\n.pin_item:after {\n\tcontent: \"{{lang \"topic.pin_button_text\" . }}\";\n}\n.unpin_item:after {\n\tcontent: \"{{lang \"topic.unpin_button_text\" . }}\";\n}\n.report_item:after {\n\tcontent: \"{{lang \"topic.report_button_text\" .}}\";\n}\n\n.attach_edit_bay {\n\tmargin-top: -4px;\n}\n.top_post .attach_edit_bay {\n\tmargin-top: 8px;\n}\n.attach_item {\n\tdisplay: flex;\n\tmargin-top: 4px;\n\tmargin-bottom: 8px;\n\ttext-overflow: ellipsis;\n\toverflow: hidden;\n\tpadding: 8px;\n\tpadding-left: 0px;\n\twidth: 100%;\n}\n.attach_item img {\n\tmargin-right: 8px;\n\tborder-radius: 4px;\n}\n.attach_item_item {\n\tbackground-color: #EEEEEE;\n\tpadding-left: 8px;\n}\n.attach_item_item span {\n\tmargin-bottom: 4px;\n\tmargin-right: auto;\n\tpadding-top: 4px;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twidth: 350px;\n}\n.attach_image_holder span {\n\twidth: 300px;\n}\n.attach_item_buttons label, .attach_item_select, .attach_item_delete {\n\tmargin-left: 0px;\n\tmargin-right: 8px;\n\tmargin-top: 0px;\n}\n.post_item:not(.has_attachs) .attach_item_buttons,\n.has_attachs .update_buttons .add_file_button {\n\tdisplay: none;\n}\n.update_buttons a button {\n\tmargin-top: 0px;\n}\n.top_post .attach_item_buttons {\n\tmargin-top: -4px;\n}\n.zone_view_topic .pageset {\n\tmargin-bottom: 14px;\n}\n\n.hide_spoil {\n\tbackground-color: lightgrey;\n\tcolor: lightgrey;\n}\n.hide_spoil img {\n\tborder: 0;\n\tclip: rect(0 0 0 0);\n\theight: 1px;\n\tmargin: -1px;\n\toverflow: hidden;\n\tpadding: 50px;\n\twhite-space: nowrap;\n\twidth: 1px;\n\tbackground-color: lightgrey;\n}\n.hide_spoil img {\n\tcontent: \"   \";\n}\n.attach_box {\n\tbackground-color: #5a5555;\n\tbackground-color: #EFEEEE;\n\tborder-radius: 3px;\n\tpadding: 16px;\n\toverflow-wrap: break-word;\n}\n\n#ip_search_container .rowlist:not(.has_items) {\n\tdisplay: block;\n}\n#ip_search_container .rowlist .rowitem {\n\tpadding-top: 16px;\n\tpadding-bottom: 10px;\n}\n#ip_search_container .rowlist .rowmsg {\n\twidth: 100%;\n}\n.ip_search_block .rowitem {\n\tpadding: 8px;\n\tpadding-left: 12px;\n\tpadding-right: 12px;\n}\n.ip_search_input {\n\tmargin-right: 12px;\n}\n\n.ip_search_block .rowitem,\n#profile_left_pane .topBlock {\n\tdisplay: flex;\n}\n#profile_left_lane {\n\tmargin-left: 8px;\n\tmargin-right: 4px;\n}\n#profile_left_pane .topBlock {\n\tflex-direction: column;\n\tpadding-bottom: 12px;\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n}\n#profile_left_pane .avatarRow {\n\tpadding: 28px;\n\tpadding-bottom: 4px;\n\tpadding-top: 22px;\n}\n#profile_left_pane .avatar {\n\tborder-radius: 80px;\n\theight: 100px;\n\twidth: 100px;\n}\n#profile_left_pane .nameRow {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n#profile_left_pane .nameRow .username {\n\ttext-align: center;\n}\n#profile_left_pane .profileName {\n\tfont-size: 19px;\n}\n.rowmenu .passive {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n\tmargin-top: 6px;\n\tpadding: 12px;\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n}\n.colstack:not(#profile_container) .rowmenu {\n\tpadding-left: 12px;\n}\n.colstack:not(#profile_container) .rowmenu .passive {\n\tmargin-top: 0px;\n\tborder-bottom: none;\n}\n.colstack:not(#profile_container) .rowmenu .passive:last-child {\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n#profile_left_pane .passiveBlock .passive {\n\tpadding-left: 12px;\n}\n\n#profile_right_lane {\n\twidth: 100%;\n\tmargin-right: 12px;\n}\n.colstack_right .colstack_item:not(.rowlist):not(#profile_comments),\n#profile_comments .comment, .alert {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n}\n.alert {\n\tpadding: 12px;\n\tmargin-top: -3px;\n\tmargin-bottom: 8px;\n\tmargin-left: 12px;\n\tmargin-right: 12px;\n}\n.colstack_right .alert {\n\tmargin-left: 16px;\n\tmargin-right: 0px;\n}\n.colstack_right .colstack_item, .colstack_right .colstack_grid {\n\tmargin-left: 16px;\n}\n#profile_right_lane .topic_reply_form {\n\twidth: auto;\n}\n#profile_comments .topRow {\n\tdisplay: flex;\n}\n#profile_comments .topRow .controls {\n\tpadding-top: 16px;\n\tpadding-right: 16px;\n}\n#profile_comments .content_column {\n\tmargin-bottom: 16px;\n}\n#profile_comments button {\n\tbackground: inherit;\n\tcolor: var(--lighter-text-color);\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n\tcursor: pointer;\n}\n#profile_comments button:hover {\n\tcolor: var(--light-text-color);\n}\n#profile_comments button.edit_item:after,\n#profile_comments button.delete_item:after,\n#profile_comments button.report_item:after {\n\tfont: normal normal normal 14px/1 FontAwesome;\n}\n#profile_comments button.edit_item:after {\n\tcontent: \"\\f040\";\n}\n#profile_comments button.delete_item:after {\n\tcontent: \"\\f1f8\";\n}\n#profile_comments button.report_item:after {\n\tcontent: \"\\f024\";\n}\n#profile_comments_head {\n\tmargin-top: 6px;\n}\n#profile_comments {\n\tmargin-bottom: 12px;\n}\n#profile_comments:empty {\n\tdisplay: none !important;\n}\n#profile_comments .rowitem {\n\tbackground-image: none !important;\n}\n#profile_comments .comment:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n#profile_comments .comment .userbit {\n\tdisplay: flex;\n\tmargin-left: 14px;\n\tmargin-top: 14px;\n\tmargin-bottom: 8px;\n}\n#profile_comments .comment img {\n\twidth: 40px;\n\theight: 40px;\n\tborder-radius: 62px;\n\tmargin-right: 8px;\n}\n#profile_comments .comment .nameAndTitle {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-top: 2px;\n}\n#profile_comments .comment .nameAndTitle .user_tag {\n\tfont-size: 15px;\n}\n#profile_comments .comment .content_column {\n\tpadding-left: 14px;\n\tpadding-right: 14px;\n\tdisplay: flex;\n\twidth: 100%\n}\n#profile_comments .comment .controls {\n\tmargin-left: auto;\n}\n#profile_comments_form .topic_reply_form {\n\tborder-top: 1px solid var(--element-border-color) !important;\n}\n\n.formitem:only-child {\n\twidth: 100%;\n\tdisplay: flex;\n}\n.the_form .formitem:only-child button {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.quick_reply_form, .topic_reply_form, .the_form {\n\tbackground: var(--element-background-color);\n}\n.formrow {\n\tborder-right: none !important;\n}\n\n.to_right {\n\tfloat: right;\n\tmargin-left: auto;\n}\n\n#account_edit_avatar .avatar_box {\n\tmargin-bottom: 10px;\n}\n\n#create_topic_page .close_form, #create_topic_page .formlabel, #login_page .formlabel {\n\tdisplay: none;\n}\n.formrow:not(:first-child):not(:last-child) {\n\tmargin-top: 4px;\n}\n.formrow:not(:first-child) {\n\tpadding-top: 3px;\n}\n.formrow {\n\tpadding: 16px;\n}\n.formrow:not(:last-child) {\n\tpadding-bottom: 4px;\n}\n#login_page .formrow:not(:last-child) {\n\tpadding-bottom: 0px;\n}\n.formlabel {\n\tdisplay: block;\n\tfont-size: 15px;\n}\n.quick_create_form .formrow {\n\tpadding: 0px;\n}\n#register_page .register_button_row {\n\tpadding: 12px !important;\n\tpadding-top: 0px !important;\n\tmargin-top: -2px !important;\n}\n#register_page .register_button_row .formbutton {\n\tmargin-left: 2px;\n}\n\n/* TODO: Add a generic button_row class and add this to them all? */\n.login_button_row {\n\tdisplay: flex;\n}\n.dont_have_account, .forgot_password {\n\tcolor: var(--primary-link-color);\n\tfont-size: 12px;\n\tmargin-top: 23px;\n}\n.dont_have_account {\n\tmargin-left: auto;\n}\n.dont_have_account:after {\n\tcontent: \"|\";\n\tmargin-left: 5px;\n\tmargin-right: 5px;\n}\n\n/* TODO: Highlight the one we're currently on? */\n.pageset {\n\tdisplay: flex;\n\tmargin-left: 14px;\n}\n.pageitem {\n\tpadding: 8px;\n\tpadding-left: 10px;\n\tpadding-right: 10px;\n\tbackground: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tborder-left: none;\n\tborder-right: none;\n}\n.pageitem:first-child {\n\tborder-left: 1px solid var(--element-border-color);\n}\n.pageitem:last-child {\n\tborder-right: 1px solid var(--element-border-color);\n}\n.pagefirst, .pagenext, .pageprev, .pagelast {\n\tpadding-top: 5px;\n}\n.pagefirst a, .pagenext a, .pageprev a, .pagelast a {\n\tfont-size: 18px;\n}\n\n/* TODO: Make widget_about's CSS less footer centric */\n.footerBit, .footer .widget {\n\tborder-top: 1px solid var(--element-border-color);\n\tpadding: 12px;\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n\tmargin-left: -8px;\n\tmargin-right: -8px;\n\tbackground-color: var(--element-background-color);\n\tdisplay: flex;\n}\n.elapsed {\n\tdisplay: none;\n}\n#poweredByHolder {\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n.about, #poweredBy {\n\tfont-size: 17px;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n#poweredBy {\n\tmargin-right: auto;\n}\n#poweredBy span {\n\tfont-size: 16px;\n}\n#aboutTitle {\n\tfont-size: 17px;\n\tmargin: 8px;\n\tmargin-bottom: 4px;\n}\n#poweredByName {\n\tfont-size: 17px;\n\tmargin: 4px;\n}\n#aboutDesc {\n\tmargin-left: 8px;\n\tmargin-top: 8px;\n\twidth: 60%;\n\tfont-size: 16px;\n}\n#aboutDesc p {\n\t-webkit-margin-before: 12px;\n\t-webkit-margin-after: 12px;\n}\n#aboutDesc p:last-child {\n\t-webkit-margin-after: 8px;\n}\n#aboutDesc p:first-child {\n\t-webkit-margin-before: 0px;\n}\n#poweredByDash, #poweredByMaker {\n\tdisplay: none;\n}\n#themeSelectorSelect {\n\tpadding: 3px;\n\tmargin-top: 0px;\n}\n\n.colstack_grid {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, 1fr);\n\tgrid-gap: 8px;\n}\n.grid_item {\n\tbackground: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tmargin: 0px;\n\tpadding: 16px;\n\tpadding-left: 0px;\n\tdisplay: flex;\n\tpadding-top: 0px;\n\tpadding-bottom: 0px;\n\tpadding-right: 10px;\n}\n.grid_item span, .grid_item a {\n\tmargin-top: 16px;\n\tmargin-bottom: 16px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\ttext-align: center;\n}\n\n@media(min-width: 1000px) {\n\t.footer {\n\t\tmargin-left: -8px;\n\t\tmargin-right: -8px;\n\t}\n\t.footerBit, .footer .widget {\n\t\tborder-top: 1px solid var(--header-border-color);\n\t\tborder-left: 1px solid var(--header-border-color);\n\t\tborder-right: 1px solid var(--header-border-color);\n\t}\n\t#poweredByHolder {\n\t\tborder-bottom: 2px solid var(--header-border-color);\n\t}\n\t#main {\n\t\tmax-width: 1000px;\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t\tpadding-top: 18px;\n\t\tpadding-left: 16px;\n\t\tpadding-right: 16px;\n\t\tborder-left: 1px solid hsl(20,0%,95%);\n\t\tborder-right: 1px solid hsl(20,0%,95%);\n\t}\n\t.footer {\n\t\tmax-width: 1000px;\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t\tpadding-left: 8px;\n\t\tpadding-right: 8px;\n\t}\n\t#back, .footer, .footBlock {\n\t\tbackground-color: hsl(0,0%,95%);\n\t}\n\t#back:not(.zone_panel) .footBlock {\n\t\tdisplay: flex;\n\t}\n}\n\n@media(min-width: 721px) {\n\t.hide_on_big {\n\t\tdisplay: none;\n\t}\n\t.postIframe {\n\t\tmin-width: 400px;\n\t\tmin-height: 200px;\n\t}\n}\n\n@media(max-width: 720px) {\n\t.menu_profile, .ip_item {\n\t\tdisplay: none;\n\t}\n\t.like_count {\n\t\tmargin-right: 1px;\n\t}\n\t.like_count:after, .created_at:before, .ip_item:before {\n\t\tmargin-right: 6px;\n\t}\n}\n\n@media(max-width: 670px) {\n\t.topic_inner_right {\n\t\tdisplay: none;\n\t}\n}\n@media(max-width: 620px) {\n\t.userinfo .avatar_item {\n\t\twidth: 72px;\n\t\theight: 72px;\n\t}\n}\n@media(max-width: 610px) {\n\t.userinfo {\n\t\tpadding-top: 24px;\n\t\tpadding-left: 34px;\n\t\tpadding-right: 34px;\n\t\tpadding-bottom: 14px;\n\t}\n\t.userinfo .avatar_item {\n\t\theight: 64px;\n\t\twidth: 64px;\n\t\t/*background-size: 82px;*/\n\t}\n}\n@media(max-width: 590px) {\n\t#main {\n\t\tpadding-left: 4px;\n\t\tpadding-right: 4px;\n\t}\n\t.post_item {\n\t\tmargin-bottom: 12px;\n\t}\n\t.userinfo {\n\t\tmargin-right: 12px;\n\t\tpadding-top: 20px;\n\t\tpadding-left: 24px;\n\t\tpadding-right: 24px;\n\t\tpadding-bottom: 12px;\n\t}\n\t.userinfo .avatar_item {\n\t\twidth: 52px;\n\t\theight: 52px;\n\t\tmargin-bottom: 10px;\n\t\tbackground-size: 72px;\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t}\n\t.post_tag {\n\t\tfont-size: 15px;\n\t}\n\t.content_container {\n\t\tpadding: 15px;\n\t}\n}\n@media(max-width: 550px) {\n\t.nav {\n\t\tborder-bottom: 1px solid var(--header-border-color);\n\t}\n\t.menu_profile {\n\t\tdisplay: block;\n\t}\n\t#menu_overview {\n\t\tfont-size: 18px;\n\t\tbackground-color: hsl(0,0%,97%);\n\t\tmargin-top: -16px;\n\t\tmargin-bottom: -11px;\n\t\tmargin-left: -14px;\n\t\tmargin-right: 16px;\n\t\tpadding-top: 16px;\n\t\tpadding-left: 14px;\n\t\tpadding-right: 4px;\n\t}\n\t#menu_overview::after {\n\t\theight: 16px !important;\n\t}\n\t.menu_left a:after {\n\t\tcontent: \"\" !important;\n\t}\n\t.menu_left:not(:last-child):after, .menu_alerts:after {\n\t\tmargin-left: 4px;\n\t\tborder-right: none;\n\t}\n\t.menu_alerts {\n\t\tmargin-right: 16px;\n\t}\n\t.alert_bell {\n\t\tposition: relative;\n\t\tbottom: -1px;\n\t}\n\t.alert_bell:before {\n\t\tfont-size: 17px;\n\t}\n\t.alert_aftercounter {\n\t\tdisplay: none;\n\t}\n\n\t#back {\n\t\tpadding-top: 0px;\n\t}\n\t.rowhead h1, .opthead h1, .colstack_head h1 {\n\t\tfont-size: 18px;\n\t}\n\tmain > .rowhead, #main > .rowhead {\n\t\tborder: none;\n\t\tborder-bottom: 2px solid var(--header-border-color);\n\t}\n\t#main {\n\t\tpadding-top: 0px;\n\t}\n\tmain > .rowhead, #main > .rowhead, main > .opthead, #main > .opthead {\n\t\tmargin-left: -3px;\n\t\tmargin-right: -3px;\n\t}\n\n\t.topic_list {\n\t\tdisplay: flex;\n\t\tflex-wrap: wrap;\n\t}\n\t.topic_list .topic_row {\n\t\tdisplay: block;\n\t\twidth: calc(50% - 6px);\n\t\tfloat: left;\n\t}\n\t.topic_list .topic_row:nth-child(odd) {\n\t\tmargin-right: 12px;\n\t}\n\t.topic_left {\n\t\tmargin-bottom: 0px;\n\t\tborder-bottom: none;\n\t\tborder-right: 1px solid var(--element-border-color);\n\t}\n\t.topic_left .parent_forum {\n\t\tdisplay: none;\n\t}\n\t.topic_right.rowitem {\n\t\tborder-top: none;\n\t\tborder-left: 1px solid var(--element-border-color);\n\t\tbackground-color: hsl(0,0%,95%);\n\t}\n\t.topic_right_inside br, .topic_right_inside img {\n\t\tdisplay: none;\n\t}\n\t.topic_sticky .topic_right {\n\t\tborder-bottom: 2px solid var(--element-border-color);\n\t}\n\t.topic_right_inside > span {\n\t\tmargin-top: 6px;\n\t\tmargin-bottom: 6px;\n\t}\n\n\t.topic_name_forum_sep {\n\t\tline-height: 22px;\n\t\tfont-size: 18px;\n\t}\n\t.topic_forum {\n\t\tline-height: 20px;\n\t}\n\n\t.button_container {\n\t\tborder-top: 1px solid var(--element-border-color);\n\t}\n\t.action_button {\n\t\tpadding-bottom: 15px;\n\t\tpadding-left: 10px;\n\t\tpadding-right: 8px;\n\t\tpadding-top: 15px;\n\t\tfont-size: 12px;\n\t}\n\t.action_button:not(.add_like):not(.remove_like) {\n\t\tfont: normal normal normal 14px/1 FontAwesome;\n\t}\n\t.has_likes .action_button_right {\n\t\tmargin-left: 0px;\n\t\twidth: 100%;\n\t}\n\t.like_item {\n\t\tbackground-color: hsl(0,0%,97%);\n\t}\n\t.post_item:not(.top_post) .like_item {\n\t\tborder-bottom: 1px solid var(--element-border-color);\n\t}\n\t.post_item .add_like:after, .post_item .remove_like:after {\n\t\tborder-left: none;\n\t\tmargin: inherit;\n\t}\n\t.content_container {\n\t\tpadding: 0px;\n\t}\n\t.post_item .user_content {\n\t\tpadding: 12px;\n\t\tmargin-bottom: 0px;\n\t}\n\t.button_container .open_edit:after, .edit_item:after{\n\t\tcontent: \"\\f040\";\n\t}\n\t.delete_item:after {\n\t\tcontent: \"\\f014\";\n\t}\n\t.ip_item_button:after {\n\t\tcontent: \"\\f0ac\";\n\t}\n\t.lock_item:after {\n\t\tcontent: \"\\f023\";\n\t}\n\t.unlock_item:after {\n\t\tcontent: \"\\f09c\";\n\t}\n\t.pin_item:after, .unpin_item:after {\n\t\tcontent: \"\\f08d\";\n\t}\n\t.report_item:not(.profile_menu_item):after {\n\t\tcontent: \"\\f024\";\n\t}\n\t.unpin_item, .unlock_item {\n\t\tbackground-color: hsl(80,50%,97%);\n\t}\n\t.like_count, .like_count:before {\n\t\tfont-family: arial;\n\t}\n\t.like_count:after {\n\t\tcontent: \"\";\n\t}\n\t.like_count:before {\n\t\tcontent: \"{{lang \"topic.plus\" . }}\";\n\t\tfont-weight: normal;\n\t}\n\t.created_at {\n\t\tmargin-left: auto;\n\t}\n\t.created_at:before {\n\t\tborder-left: none;\n\t\tmargin: inherit;\n\t}\n\n\t.topic_reply_form .trumbowyg-editor {\n\t\tpadding: 15px;\n\t}\n\t.trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before {\n\t\tfont-size: 15px;\n\t}\n\t.trumbowyg-button-pane .trumbowyg-button-group:first-child {\n\t\tmargin-left: 0px !important;\n\t}\n\t.trumbowyg-button-pane .trumbowyg-button-group:after,\n\t.trumbowyg-button-pane .trumbowyg-button-group:first-child:before {\n\t\tmargin: inherit !important;\n\t\tborder: none !important;\n\t}\n}\n@media(min-width: 521px) {\n\t.button_menu {\n\t\tdisplay: none;\n\t}\n}\n@media(max-width: 520px) {\n\t.edit_item, .button_container .open_edit, .delete_item, .pin_item, .unpin_item, .lock_item, .unlock_item, .ip_item_button, .report_item:not(.profile_menu_item) {\n\t\tdisplay: none;\n\t}\n\t.button_menu:after {\n\t\tcontent: \"\\f013\";\n\t}\n\t.button_menu_pane {\n\t\tdisplay: flex;\n\t\tflex-direction: column;\n\t\tbackground-color: var(--element-background-color);\n\t\tborder: 2px solid var(--element-border-color);\n\t\tposition: fixed;\n\t\tleft: 50%;\n\t\ttop: 110px;\n\t\twidth: 300px;\n\t\ttransform: translateX(-50%);\n\t\tz-index: 200;\n\t}\n\t.button_menu_pane > *:not(:last-child) {\n\t\tborder-bottom: 1px solid var(--element-border-color);\n\t}\n\t.button_menu_pane .userinfo {\n\t\tdisplay: flex;\n\t\tflex-direction: row;\n\t\twidth: 100%;\n\t\tpadding-top: 12px;\n\t}\n\t.button_menu_pane .avatar_item {\n\t\twidth: 42px;\n\t\theight: 42px;\n\t\tbackground-size: 62px;\n\t\tmargin-left: 0px;\n\t\tmargin-right: 10px;\n\t\tmargin-bottom: 0px;\n\t}\n\t.button_menu_pane .userinfo .the_name {\n\t\tmargin-right: 0px;\n\t}\n\t\n\t/* TODO: Make this grid more flexible so that plugins can add new items more easily */\n\t.button_menu_pane .buttonGrid {\n\t\tdisplay: grid;\n\t\tgrid-template-columns: repeat(8, 1fr);\n\t\tborder-left: 1px solid var(--element-border-color);\n\t\tborder-bottom: 1px solid var(--element-border-color);\n\t}\n\t.button_menu_pane .action_button {\n\t\tdisplay: flex;\n\t\tmargin: 0px;\n\t\tpadding-left: 0px;\n\t\tpadding-right: 0px;\n\t\tbackground-color: var(--element-background-color);\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t\twidth: 42px;\n\t\theight: 42px;\n\t\tfont-size: 15px;\n\t\tborder-right: 1px solid var(--element-border-color);\n\t\tborder-bottom: 1px solid var(--element-border-color);\n\t}\n\t.button_menu_pane .action_button:nth-child(8n) {\n\t\tborder-right: none;\n\t}\n\t.button_menu_pane .action_button:nth-last-child(-n+8) {\n\t\tborder-bottom: none;\n\t}\n\t.button_menu_pane .action_button:after, .button_menu_pane .add_like:before, .button_menu_pane .remove_like:before {\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t}\n\t.button_menu_pane .open_edit:after {\n\t\tcontent: \"\\f040\";\n\t}\n\t.button_menu_pane .gridFiller {\n\t\tbackground-color: var(--tinted-background-color);\n\t}\n}\n@media(max-width: 450px) {\n\t.topic_list .topic_row {\n\t\tdisplay: block;\n\t\twidth: 100%;\n\t\tfloat: none;\n\t}\n\t.topic_list .topic_row:nth-child(odd) {\n\t\tmargin-right: 0px;\n\t}\n}\n@media(max-width: 440px) {\n\t#main {\n\t\tpadding-left: 0px;\n\t\tpadding-right: 0px;\n\t}\n\t.userinfo {\n\t\tpadding-left: 18px;\n\t\tpadding-right: 18px;\n\t\tmargin-right: 10px;\n\t}\n\t.the_name {\n\t\tfont-size: 17px;\n\t}\n}"
  },
  {
    "path": "themes/cosora/public/misc.js",
    "content": "\"use strict\";\n\n(() => {\n\tfunction newElement(etype, eclass) {\n\t\tlet element = document.createElement(etype);\n\t\telement.className = eclass;\n\t\treturn element;\n\t}\n\t\n\tfunction moveAlerts() {\n\t\t// Move the alerts under the first header\n\t\tlet colSel = $(\".colstack_right .colstack_head:first\");\n\t\tlet colSelAlt = $(\".colstack_right .colstack_item:first\");\n\t\tlet colSelAltAlt = $(\".colstack_right .coldyn_block:first\");\n\t\tif(colSel.length > 0) $('.alert').insertAfter(colSel);\n\t\telse if (colSelAlt.length > 0) $('.alert').insertBefore(colSelAlt);\n\t\telse if (colSelAltAlt.length > 0) $('.alert').insertBefore(colSelAltAlt);\n\t\telse $('.alert').insertAfter(\".rowhead:first\");\n\t}\n\t\n\taddInitHook(\"end_init\", () => {\n\t\tlet loggedIn = document.head.querySelector(\"[property='x-mem']\")!=null;\n\t\tif(loggedIn) {\n\t\t\tif(navigator.userAgent.indexOf(\"Firefox\")!=-1) $.trumbowyg.svgPath = pre+\"trumbowyg/ui/icons.svg\";\n\t\t\t\n\t\t\t// Is there we way we can append instead? Maybe, an editor plugin?\n\t\t\tattachItemCallback = function(attachItem) {\n\t\t\t\tlet currentContent = $('#input_content').trumbowyg('html');\n\t\t\t\t$('#input_content').trumbowyg('html',currentContent);\n\t\t\t}\n\t\t\tquoteItemCallback = function() {\n\t\t\t\tlet currentContent = $('#input_content').trumbowyg('html');\n\t\t\t\t$('#input_content').trumbowyg('html',currentContent);\n\t\t\t}\n\t\t\t\n\t\t\t$(\".topic_name_row\").click(() => {\n\t\t\t\t$(\".topic_create_form\").addClass(\"selectedInput\");\n\t\t\t});\n\n\t\t\t// TODO: Bind this to the viewport resize event\n\t\t\tvar btnlist = [];\n\t\t\tif(document.documentElement.clientWidth > 550) {\n\t\t\t\tbtnlist = [['viewHTML'],['undo','redo'],['formatting'],['strong','em','del'],['link'],['insertImage'],['unorderedList','orderedList'],['removeformat']];\n\t\t\t} else {\n\t\t\t\tbtnlist = [['viewHTML'],['strong','em','del'],['link'],['insertImage'],['unorderedList','orderedList'],['removeformat']];\n\t\t\t}\n\t\t\t\n\t\t\t$('.topic_create_form #input_content').trumbowyg({\n\t\t\t\tbtns: btnlist,\n\t\t\t});\n\t\t\t$('.topic_reply_form #input_content').trumbowyg({\n\t\t\t\tbtns: btnlist,\n\t\t\t\tautogrow: true,\n\t\t\t});\n\t\t\t$('#profile_comments_form .topic_reply_form .input_content').trumbowyg({\n\t\t\t\tbtns: [['viewHTML'],['strong','em','del'],['link'],['insertImage'],['removeformat']],\n\t\t\t\tautogrow: true,\n\t\t\t});\n\t\t\taddHook(\"edit_item_pre_bind\", () => {\n\t\t\t\t$('.user_content textarea').trumbowyg({\n\t\t\t\t\tbtns: btnlist,\n\t\t\t\t\tautogrow: true,\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\n\t\t// TODO: Refactor this to use `each` less\n\t\t$('.button_menu').click(function(){\n\t\t\tlog(\".button_menu\");\n\t\t\t// The outer container\n\t\t\tlet buttonPane = newElement(\"div\",\"button_menu_pane\");\n\t\t\tlet postItem = $(this).parents('.post_item');\n\n\t\t\t// Create the userinfo row in the pane\n\t\t\tlet userInfo = newElement(\"div\",\"userinfo\");\n\t\t\tpostItem.find('.avatar_item').each(function(){\n\t\t\t\tuserInfo.appendChild(this);\n\t\t\t});\n\n\t\t\tlet userText = newElement(\"div\",\"userText\");\n\t\t\tpostItem.find('.userinfo:not(.avatar_item)').children().each(function(){\n\t\t\t\tuserText.appendChild(this);\n\t\t\t});\n\t\t\tuserInfo.appendChild(userText);\n\t\t\tbuttonPane.appendChild(userInfo);\n\n\t\t\t// Copy a short preview of the post contents into the pane\n\t\t\tpostItem.find('.user_content').each(function(){\n\t\t\t\t// TODO: Truncate an excessive number of lines to 5 or so\n\t\t\t\tlet contents = this.innerHTML;\n\t\t\t\tif(contents.length > 45) this.innerHTML = contents.substring(0,45) + \"...\";\n\t\t\t\tbuttonPane.appendChild(this);\n\t\t\t});\n\n\t\t\t// Copy the buttons from the post to the pane\n\t\t\tlet buttonGrid = newElement(\"div\",\"buttonGrid\");\n\t\t\tlet gridElementCount = 0;\n\t\t\t$(this).parent().children('a:not(.button_menu)').each(function(){\n\t\t\t\tbuttonGrid.appendChild(this);\n\t\t\t\tgridElementCount++;\n\t\t\t});\n\t\t\t\n\n\t\t\t// Fill in the placeholder grid nodes\n\t\t\tlet rowCount = 4;\n\t\t\tlog(\"rowCount\",rowCount);\n\t\t\tlog(\"gridElementCount\",gridElementCount);\n\t\t\tif(gridElementCount%rowCount != 0) {\n\t\t\t\tlet fillerNodes = (rowCount - (gridElementCount%rowCount));\n\t\t\t\tlog(\"fillerNodes\",fillerNodes);\n\t\t\t\tfor(let i = 0; i < fillerNodes;i++ ) {\n\t\t\t\t\tlog(\"added a gridFiller\");\n\t\t\t\t\tbuttonGrid.appendChild(newElement(\"div\",\"gridFiller\"));\n\t\t\t\t}\n\t\t\t}\n\t\t\tbuttonPane.appendChild(buttonGrid);\n\n\t\t\tdocument.getElementById(\"back\").appendChild(buttonPane);\n\t\t});\n\n\t\tmoveAlerts();\n\t});\n\n\taddInitHook(\"after_notice\", moveAlerts);\n})()"
  },
  {
    "path": "themes/cosora/public/panel.css",
    "content": "#main {\n\tmax-width: inherit;\n\tmargin-left: 0px;\n\tmargin-right: 0px;\n\tborder-left: none;\n\tborder-right: none;\n\tpadding-left: 0px;\n\tpadding-bottom: 0px;\n}\n#back {\n\tbackground-color: inherit;\n\tpadding-top: 0px;\n}\n\n.colstack_left {\n\twidth: 250px !important;\n\tbackground-color: white;\n\tmargin-top: -18.5px;\n\tmargin-left: -0.5px;\n\tborder-top: 1px solid var(--element-border-color);\n\tborder-right: 1px solid var(--element-border-color);\n}\n.colstack_left .colstack_head {\n\tmargin-top: 6px;\n\tmargin-bottom: 8px;\n\tmargin-left: 16px;\n\tpadding-bottom: 12px;\n\tpadding-top: 12px;\n\tpadding-left: 8px;\n\tborder-bottom: 1px solid var(--element-border-color);\n\tborder-left: none;\n\tborder-right: none;\n\tborder-top: none;\n\twidth: fit-content;\n}\n.colstack_left .rowmenu {\n\tpadding-left: 18px !important;\n}\n.colstack_left .rowmenu .passive {\n\tborder: none;\n\tfont-size: 15px;\n\tpadding: 8px;\n\tpadding-top: 6px;\n\tpadding-bottom: 8px;\n}\n.colstack_left .rowmenu .passive:first-child {\n    border-top: none;\n}\n.colstack_left .rowmenu .passive:last-child {\n\tborder-bottom: 1px solid var(--element-border-color) !important;\n\twidth: 150px;\n\tpadding-bottom: 16px;\n}\n.submenu {\n\tmargin-left: 12px;\n}\n.submenu_fallback {\n\tdisplay: none;\n}\n.colstack_right {\n\tmargin-right: 14px;\n\t/*margin-top: -4px;*/\n\tmargin-bottom: 14px;\n}\n.colstack_right .colstack_head .rowitem {\n\tdisplay: flex;\n}\n.colstack_right .colstack_head h1 + h2.hguide {\n\tmargin-left: auto;\n\tcolor: var(--extra-lightened-primary-text-color);\n}\n.footer {\n\tmargin-top: 0px;\n}\n.colstack_right .colstack_head:not(:first-child) {\n    margin-top: 14px;\n}\n\n#panel_dashboard_right .colstack_head h1 {\n\tfont-size: 17px;\n\tcolor: hsl(0,0%,40%);\n}\n/* TODO: Move these to panel.css */\n#dash-version:before, #dash-cpu:before, #dash-ram:before, #dash-memused:before, #dash-totonline:before, #dash-gonline:before, #dash-uonline:before, #dash-reqs:before, #dash-postsperday:before, #dash-topicsperday:before {\n\tdisplay: inline-block;\n\tbackground: var(--tinted-background-color);\n\tfont: normal normal normal 14px/1 FontAwesome;\n\tfont-size: 20px;\n\tpadding-left: 17px;\n\tpadding-top: 16px;\n\tpadding-right: 19px;\n\tcolor: hsl(0,0%,20%);\n}\n#dash-version:before {\n\tcontent: \"\\f126\";\n}\n#dash-cpu:before, #dash-memused:before {\n\tcontent: \"\\f2db\";\n}\n#dash-ram:before {\n\tcontent: \"\\f233\";\n}\n#dash-totonline:before, #dash-gonline:before, #dash-uonline:before {\n\tcontent: \"\\f007\";\n}\n#dash-reqs:before {\n\tcontent: \"\\f080\";\n}\n#dash-postsperday:before, #dash-topicsperday:before {\n\tcontent: \"\\f27b\";\n}\n.grid2 {\n\tmargin-top: 16px;\n}\n#panel_debug .grid_stat:not(.grid_stat_head) {\n\tmargin-bottom: 8px;\n}\n.debug_page.colstack_right .colstack_sub_head {\n\tmargin-top: 6px;\n}\n\n.complex_rowlist {\n\tbackground-color: inherit !important;\n\tborder: none !important;\n}\n.complex_rowlist .rowitem {\n\tdisplay: flex;\n}\n\n.panel_buttons, .panel_floater {\n\tmargin-left: auto;\n\tdisplay: flex;\n}\n.panel_buttons:before,\n.panel_floater:before,\n.edit_button:after,\n.delete_button:after {\n\tcolor: hsl(0,0%,65%);\n}\n.panel_buttons:before,\n.panel_floater:before {\n\tcontent: \"\";\n\tborder-left: 1px solid var(--element-border-color);\n\theight: 15px;\n\tmargin-top: 2px;\n\tmargin-bottom: 0px;\n\tmargin-right: 0px;\n}\n#panel_users .panel_floater:before {\n\tdisplay: none;\n}\n#panel_users .panel_floater {\n\ttext-align: center;\n\tflex-direction: column;\n}\n.edit_button:after, .delete_button:after {\n\tfont: normal normal normal 14px/1 FontAwesome;\n    padding-left: 12px;\n    height: 20px;\n}\n.edit_button:after {\n\tcontent: \"\\f040\";\n}\n.delete_button:after {\n\tcontent: \"\\f014\";\n}\n\n#panel_users .panel_floater:before,\n#panel_forums .panel_floater:before,\n#forum_quick_perms .panel_floater:before,\n.panel_themes .panel_floater:before,\n#panel_backups .panel_floater:before {\n\tborder-left: none;\n}\n#panel_forums_name_box {\n\torder: 0;\n}\n\n#panel_users .panel_tag:not(.panel_right_button) {\n\tdisplay: none;\n}\n.panel_right_button + .panel_right_button {\n\tmargin-left: 3px;\n}\n\n.panel_group_perms .formitem a {\n\tmargin-top: 5px;\n}\n\n#panel_word_filters .itemSeparator:before {\n\tcontent: \"|\";\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\tcolor: var(--primary-link-color);\n}\n\n/* TODO: Should we be using .formrows in #forum_quick_perms? Can we normalize it? Would this break the other themes? */\n.formlist:not(#forum_quick_perms),\n.panel_themes .rowitem,\n#panel_plugins .rowitem,\n#forum_quick_perms .formitem {\n\tpadding: 12px;\n\tmargin-bottom: 8px;\n\tbackground-color: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n}\n.formlist .formrow {\n\tpadding: 0px !important;\n\tmargin: 0px;\n}\n.formlist .formitem {\n\tpadding: 8px;\n}\n.panel_theme_mobile, .panel_theme_tag {\n\tdisplay: none;\n}\n#panel_plugins .rowitem {\n\tdisplay: block;\n}\n\n#panel_users .rowitem .rowTitle {\n\tborder-bottom: 1px solid var(--lighter-text-color);\n\tpadding-bottom: 4px;\n\tmargin-bottom: 6px;\n}\n#panel_setting textarea, #panel_page_list textarea, #panel_page_edit textarea {\n\twidth: 100%;\n\theight: 80px;\n}\n#panel_setting .formlabel {\n\tdisplay: none;\n}\n#panel_settings .panel_upshift {\n\tborder-bottom: 1px solid var(--element-border-color);\n\tpadding-bottom: 12px;\n}\n#panel_settings.rowlist.bgavatars .rowitem > a, #panel_settings.rowlist.bgavatars .rowitem > span {\n\tmargin-left: 0px;\n\tmargin-right: 0px;\n}\n#panel_settings .to_right {\n\tfloat: none;\n\tmargin-top: auto;\n\tpadding-top: 14px;\n\tword-break: break-all;\n}\n#panel_page_list textarea, #panel_page_edit textarea {\n\tmargin-top: 8px;\n}\n\n#forum_quick_perms .formitem {\n\tdisplay: flex;\n}\n#forum_quick_perms .formitem .edit_fields {\n\tmargin-left: 3px;\n\tmargin-top: 1px;\n}\n#forum_quick_perms .perm_preset {\n\tmargin-right: 6px;\n}\n.perm_preset_no_access:before {\n\tcontent: \"{{lang \"panel_perms_no_access\" . }}\";\n\tcolor: hsl(0,100%,20%);\n}\n.perm_preset_read_only:before, .perm_preset_can_post:before {\n\tcolor: hsl(120,100%,20%);\n}\n.perm_preset_read_only:before {\n\tcontent: \"{{lang \"panel_perms_read_only\" . }}\";\n}\n.perm_preset_can_post:before {\n\tcontent: \"{{lang \"panel_perms_can_post\" . }}\";\n}\n.perm_preset_can_moderate:before {\n\tcontent: \"{{lang \"panel_perms_can_moderate\" . }}\";\n\tcolor: hsl(240,100%,20%);\n}\n.perm_preset_quasi_mod:before {\n\tcontent: \"{{lang \"panel_perms_quasi_mod\" . }}\";\n}\n.perm_preset_custom:before {\n\tcontent: \"{{lang \"panel_perms_custom\" . }}\";\n\tcolor: hsl(0,0%,20%);\n}\n.perm_preset_default:before {\n\tcontent: \"{{lang \"panel_perms_default\" . }}\";\n}\n\n.panel_submitrow .rowitem {\n\tdisplay: flex;\n}\n.panel_submitrow .rowitem *:first-child {\n\tmargin-left: auto;\n}\n.panel_submitrow .rowitem *:last-child {\n\tmargin-right: auto;\n}\n.panel_submitrow .rowitem button {\n\tmargin-top: 0px;\n}\n\n.colstack_graph_holder {\n\tbackground-color: var(--element-background-color);\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tmargin-left: 16px;\n\tpadding-top: 16px;\n}\n.colstack_graph_holder.scrolly {\n\toverflow-x: scroll;\n\twidth: 800px;\n}\n.colstack_graph_holder.scrolly .ct_chart {\n\twidth: 1000px;\n}\n.ct-label {\n\tfill: rgba(0,0,0,.6) !important;\n\tcolor: rgba(0,0,0,.6) !important;\n}\n.ct-grid {\n\tstroke: rgba(0,0,0,.3) !important;\n}\n.ct-series-a .ct-bar, .ct-series-a .ct-line, .ct-series-a .ct-point, .ct-series-a .ct-slice-donut {\n    stroke: hsl(359,98%,53%) !important;\n}\n.ct-series-a.ct-point {\n\tstroke: hsl(359,98%,23%) !important;\n}\n.ct-series-a.ct-point:hover {\n\tstroke: hsl(359,98%,30%) !important;\n}\n.ct-legend .ct-series-7:before {\n\tbackground-color: #6b0392 !important;\n\tborder-color: #6b0392 !important;\n}\n/*.ct-chart-line {\n\theight: 300px !important;\n}*/\n/*.ct-label.ct-horizontal {\n    position: fixed;\n    justify-content: flex-end;\n    text-align: right;\n    transform-origin: 100% 0;\n    transform: translate(-100%) rotate(-90deg);\n    white-space: nowrap;\n\t/*padding-right: 48px;/\n\ttop: 8px;\n}*/\n/*.ct-label.ct-horizontal {\n    white-space: nowrap;\n}*/\n/*.ct-label.ct-horizontal {\n    position: fixed;\n    justify-content: flex-end;\n    text-align: right;\n    transform-origin: 100% 0;\n    transform: translate(-100%) rotate(-45deg);\n\twhite-space: nowrap;\n}*/\n.ct-legend {\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n.analytics .colstack_head select {\n\tmargin-top: -5px;\n}\n.colstack_graph_holder + .rowlist {\n\tmargin-top: 8px;\n}\n.analytics .colstack_head h1 {\n\tmargin-top: 2px;\n}\nselect + .timeRangeSelector {\n\tmargin-left: 8px;\n}\n\n/* Experimental header tweaks */\n.colstack_head a {\n    font-size: 17px;\n}\n/*\n#panel_analytics_views_table .rowlist .panel_compactrow {\n    padding: 14px;\n    font-size: 16px;\n}\n*/\n\n.widget_normal {\n\tdisplay: flex;\n\twidth: 100%;\n}\n#widgetTmpl, .widget_disabled {\n\tdisplay: none;\n}\n.bg_red .widget_disabled {\n\tdisplay: inline;\n}\n.wtypes .formrow {\n\tdisplay: none;\n}\n.wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default {\n\tdisplay: block;\n}\n.panel_widgets {\n\tmargin-bottom: 18px;\n}\n\n#panel_reglogs .panel_compactrow {\n\tflex-direction: column;\n}\n.logdetail {\n\tdisplay: flex;\n\twidth: 100%;\n\tmargin-top: 4px;\n}\n#panel_reglogs .logdetail small, #panel_reglogs .logdetails span {\n\tfont-size: 14px;\n}\n\n.pageset {\n\tmargin-left: 16px;\n}\n\n@media(max-width: 999px) {\n\t.colstack_left {\n\t\tmargin-top: -14.5px;\n\t}\n}\n\n@media(min-width: 1000px) {\n\t.footBlock {\n\t\tpadding-left: 0px;\n\t\tpadding-right: 0px;\n\t}\n\t.footer {\n\t\tmax-width: none;\n\t\twidth: 100%;\n\t\tmargin-left: 0px;\n\t\tmargin-right: 0px;\n\t}\n}"
  },
  {
    "path": "themes/cosora/public/profile.css",
    "content": ".colline {\n\tborder: 1px solid var(--element-border-color);\n\tborder-bottom: 2px solid var(--element-border-color);\n\tbackground-color: var(--element-background-color);\n\tpadding: 8px;\n\tmargin-left: 16px;\n\tmargin-bottom: 12px;\n}\n.userbit > a {\n\theight: 40px;\n}"
  },
  {
    "path": "themes/cosora/theme.json",
    "content": "{\n\t\"Name\": \"cosora\",\n\t\"FriendlyName\": \"Cosora\",\n\t\"Version\": \"0.1.0\",\n\t\"Creator\": \"Azareal\",\n\t\"URL\": \"github.com/Azareal/Gosora\",\n\t\"Tag\": \"WIP\",\n\t\"Docks\":[\"topMenu\",\"rightSidebar\",\"footer\"],\n\t\"Templates\": [\n\t\t{\n\t\t\t\"Name\": \"topic\",\n\t\t\t\"Source\": \"topic_alt\"\n\t\t},\n\t\t{\n\t\t\t\"Name\": \"topic_mini\",\n\t\t\t\"Source\": \"topic_alt_mini\"\n\t\t}\n\t],\n\t\"Resources\": [\n\t\t{\n\t\t\t\"Name\":\"EQCSS.js\",\n\t\t\t\"Location\":\"global\"\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"trumbowyg/trumbowyg.min.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Loggedin\":true\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"trumbowyg/ui/trumbowyg.custom.css\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Loggedin\":true\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"cosora/misc.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Async\":true\n\t\t}\n\t]\n}"
  },
  {
    "path": "themes/nox/overrides/login.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"login_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"login_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/login/submit/\" method=\"post\">\n\t\t\t<div class=\"formrow login_name_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_name_label\">{{lang \"login_account_name\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"username\"type=\"text\"placeholder=\"{{lang \"login_account_name\"}}\" aria-labelledby=\"login_name_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_password_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_password_label\">{{lang \"login_account_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\"type=\"password\"autocomplete=\"current-password\"placeholder=\"*****\"aria-labelledby=\"login_password_label\"required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\" class=\"formbutton\">{{lang \"login_submit_button\"}}</button></div>\n\t\t\t\t<div class=\"fall_opts\">\n\t\t\t\t\t<div class=\"formitem dont_have_account\">\n\t\t\t\t\t\t<a href=\"/accounts/create/\">{{lang \"login_no_account\"}}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"formitem forgot_password\">\n\t\t\t\t\t\t<a href=\"/accounts/password-reset/\">{{lang \"login_forgot_password\"}}</a>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "themes/nox/overrides/panel_before_head.html",
    "content": "<div class=\"above_right\">\n    <div class=\"left_bit\"><a href=\"/\">{{lang \"panel_back_to_site\"}}</a></div>\n    <div class=\"right_bit\">\n\t\t<img src=\"{{.CurrentUser.MicroAvatar}}\"height=32 width=32>\n\t\t<span>{{lang \"panel_welcome\"}}{{.CurrentUser.Name}}</span></div>\n</div>"
  },
  {
    "path": "themes/nox/overrides/panel_group_menu.html",
    "content": "<nav class=\"colstack_left\"aria-label=\"{{lang \"panel_menu_aria\"}}\">\n\t<!--<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem back_to_site\"><a href=\"/\">Back to site</a></div>\n\t</div>-->\n\t<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem\"><a href=\"/panel/groups/edit/{{.ID}}\">{{lang \"panel_group_menu_head\"}}</a></div>\n\t</div>\n\t<div class=\"colstack_item rowmenu\">\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/{{.ID}}\">{{lang \"panel_group_menu_general\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/promotions/{{.ID}}\">{{lang \"panel_group_menu_promotions\"}}</a></div>\n\t\t<div class=\"rowitem passive\"><a href=\"/panel/groups/edit/perms/{{.ID}}\">{{lang \"panel_group_menu_permissions\"}}</a></div>\n\t</div>\n{{template \"panel_inner_menu.html\" . }}\n</nav>"
  },
  {
    "path": "themes/nox/overrides/panel_inner_menu.html",
    "content": "<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"/panel/\">{{lang \"panel_menu_head\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/users/\">{{lang \"panel_menu_users\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Users}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/groups/\">{{lang \"panel_menu_groups\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Groups}})</a>\n\t</div>\n\t{{if .CurrentUser.Perms.ManageForums}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/forums/\">{{lang \"panel_menu_forums\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Forums}})</a>\n\t</div>{{end}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/pages/\">{{lang \"panel_menu_pages\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Pages}})</a>\n\t</div>\n\t{{if .CurrentUser.Perms.EditSettings}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/settings/\">{{lang \"panel_menu_settings\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Settings}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/settings/word-filters/\">{{lang \"panel_menu_word_filters\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.WordFilters}})</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.Perms.ManageThemes}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/themes/\">{{lang \"panel_menu_themes\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Themes}})</a>\n\t</div>\n\t{{if eq .Zone \"themes\"}}\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/themes/menus/\">{{lang \"panel_menu_menus\"}}</a></div>\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/themes/widgets/\">{{lang \"panel_menu_widgets\"}}</a></div>\n\t{{end}}\n\t{{end}}\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"#\">{{lang \"panel_menu_events\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/analytics/views/\">{{lang \"panel_menu_stats\"}}</a>\n\t</div>\n\t{{if eq .Zone \"analytics\"}}\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/posts/\">{{lang \"panel_menu_stats_posts\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/topics/\">{{lang \"panel_menu_stats_topics\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/forums/\">{{lang \"panel_menu_stats_forums\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/routes/\">{{lang \"panel_menu_stats_routes\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/routes-perf/\">{{lang \"panel_menu_stats_routes_perf\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/agents/\">{{lang \"panel_menu_stats_agents\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/systems/\">{{lang \"panel_menu_stats_systems\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/langs/\">{{lang \"panel_menu_stats_languages\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/referrers/\">{{lang \"panel_menu_stats_referrers\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/memory/\">{{lang \"panel_menu_stats_memory\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/active-memory/\">{{lang \"panel_menu_stats_active_memory\"}}</a>\n\t\t</div>\n\t\t<div class=\"rowitem passive submenu\">\n\t\t\t<a href=\"/panel/analytics/perf/\">{{lang \"panel_menu_stats_perf\"}}</a>\n\t\t</div>\n\t{{end}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/forum/{{.ReportForumID}}\">{{lang \"panel_menu_reports\"}}</a> <a class=\"menu_stats\" href=\"#\">({{.Stats.Reports}})</a>\n\t</div>\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/logs/mod/\">{{lang \"panel_menu_logs\"}}</a>\n\t</div>\n\t{{if eq .Zone \"logs\"}}\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/regs/\">{{lang \"panel_menu_logs_registrations\"}}</a></div>\n\t\t<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/mod/\">{{lang \"panel_menu_logs_moderators\"}}</a></div>\n\t\t{{if .CurrentUser.Perms.ViewAdminLogs}}<div class=\"rowitem passive submenu\"><a href=\"/panel/logs/admin/\">{{lang \"panel_menu_logs_administrators\"}}</a></div>{{end}}\n\t{{end}}\n</div>\n<div class=\"colstack_item colstack_head\">\n\t<div class=\"rowitem\"><a href=\"#\">{{lang \"panel_menu_system\"}}</a></div>\n</div>\n<div class=\"colstack_item rowmenu\">\n\t{{if .CurrentUser.Perms.ManagePlugins}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/plugins/\">{{lang \"panel_menu_plugins\"}}</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.IsSuperAdmin}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/backups/\">{{lang \"panel_menu_backups\"}}</a>\n\t</div>{{end}}\n\t{{if .CurrentUser.IsAdmin}}\n\t<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/debug/\">{{lang \"panel_menu_debug\"}}</a>\n\t</div>\n\t{{if .DebugAdmin}}<div class=\"rowitem passive\">\n\t\t<a href=\"/panel/debug/tasks/\">{{lang \"panel_menu_debug\"}}</a>\n\t</div>{{end}}\n\t{{end}}\n</div>"
  },
  {
    "path": "themes/nox/overrides/panel_menu.html",
    "content": "<nav class=\"colstack_left\" aria-label=\"{{lang \"panel_menu_aria\"}}\">\n\t<!--<div class=\"colstack_item colstack_head\">\n\t\t<div class=\"rowitem back_to_site\"><a href=\"/\">Back to site</a></div>\n\t</div>-->\n\t{{template \"panel_inner_menu.html\" . }}</nav>\n"
  },
  {
    "path": "themes/nox/overrides/profile_comments_row.html",
    "content": "{{template \"profile_comments_row_alt.html\" . }}"
  },
  {
    "path": "themes/nox/overrides/topics_topic.html",
    "content": "<div class=\"topic_row{{if .Sticky}} topic_sticky{{else if .IsClosed}} topic_closed{{end}}{{if .CanMod}} can_mod{{end}}\"data-tid={{.ID}}>\n\t<div class=\"rowitem topic_left passive datarow\">\n\t\t<a href=\"{{.Creator.Link}}\"><img src=\"{{.Creator.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.Creator.Name}}'s Avatar\"aria-hidden=\"true\"height=64></a>\n\t\t<span class=\"topic_inner_left\">\n\t\t\t<span class=\"rowtopic\"itemprop=\"itemListElement\"title=\"{{.Title}}\"><a href=\"{{.Link}}\">{{.Title}}{{if .ForumName}}</a><a class=\"parent_forum_sep\">-</a><a href=\"{{.ForumLink}}\"title=\"{{.ForumName}}\"class=\"rowsmall parent_forum\">{{.ForumName}}{{end}}</a></span>\n\t\t\t<br><a class=\"rowsmall starter\"href=\"{{.Creator.Link}}\"title=\"{{.Creator.Name}}\">{{.Creator.Name}}</a>\n\t\t</span>\n\t</div>\n\t<div class=\"topic_middle\">\n\t\t<div class=\"topic_middle_inside rowsmall\">\n\t\t\t<span class=\"replyCount\">{{.PostCount}}&nbsp;{{lang \"topic_list.replies_suffix\"}}</span>\n\t\t\t<span class=\"likeCount\">{{.LikeCount}}&nbsp;{{lang \"topic_list.likes_suffix\"}}</span>\n\t\t\t<span class=\"viewCount\">{{.ViewCount}}&nbsp;{{lang \"topic_list.views_suffix\"}}</span>\n\t\t\t<span class=\"weekViewCount\">{{.WeekViews}}&nbsp;{{lang \"topic_list.views_suffix\"}}</span>\n\t\t</div>\n\t</div>\n\t<div class=\"rowitem topic_right passive datarow\">\n\t\t<div class=\"topic_right_inside\">\n\t\t\t<a href=\"{{.LastUser.Link}}\"><img src=\"{{.LastUser.MicroAvatar}}\"alt=\"Avatar\"title=\"{{.LastUser.Name}}'s Avatar\"aria-hidden=\"true\"height=64></a>\n\t\t\t<span>\n\t\t\t\t<a href=\"{{.LastUser.Link}}\"class=\"lastName\"title=\"{{.LastUser.Name}}\">{{.LastUser.Name}}</a><br>\n\t\t\t\t<a href=\"{{.Link}}?page={{.LastPage}}{{if .LastReplyID}}#post-{{.LastReplyID}}{{end}}\"class=\"rowsmall lastReplyAt\"title=\"{{abstime .LastReplyAt}}\">{{reltime .LastReplyAt}}</a>\n\t\t\t</span>\n\t\t</div>\n\t</div>\n</div>"
  },
  {
    "path": "themes/nox/public/acc_panel_common.css",
    "content": "#main {\n\tmax-width: none !important;\n}\n.colstack_left {\n\twidth: 200px;\n\tpadding-bottom: 6px;\n\tbackground-color: rgb(62, 62, 62);\n\t/*border-left: 4px solid rgb(82, 82, 82);*/\n}\n.colstack_left .colstack_head {\n\t/*font-size: 19px;*/\n\tfont-size: 18px;\n\tmargin-bottom: 8px;\n\tbackground-color: rgb(72, 72, 72);\n\t/*padding-top: 10px;*/\n\tpadding-top: 9px;\n\tpadding-left: 18px;\n\tpadding-right: 24px;\n\t/*padding-bottom: 10px;*/\n\tpadding-bottom: 9px;\n\tmargin-left: 0px;\n}\n.colstack_left .colstack_head:not(:first-child) {\n\tmargin-top: 14px;\n\tfont-size: 18px;\n\tpadding-top: 9px;\n\tpadding-bottom: 9px;\n}\n.colstack_left .colstack_head a {\n\tcolor: rgb(210, 210, 210);\n}\n\n.rowmenu {\n\tmargin-left: 18px;\n\t/*margin-bottom: 2px;*/\n\tmargin-bottom: 3px;\n\tfont-size: 17px;\n}\n.rowmenu a {\n\tcolor: rgb(180, 180, 180);\n}\n.rowmenu .rowitem {\n\t/*margin-bottom: 4px;*/\n\tmargin-bottom: 6px;\n}\n\n.to_right {\n\tmargin-left: auto;\n}\n.bg_red {\n\tbackground-color: rgb(88,68,68) !important;\n}\n\n@media (max-width: 420px) {\n\t.colstack {\n\t\tdisplay: block;\n\t}\n\t.colstack_left, .colstack_right {\n\t\twidth: auto !important;\n\t}\n}"
  },
  {
    "path": "themes/nox/public/account.css",
    "content": ".sidebar, .footer .widget {\n\tdisplay: none;\n}\n\n/* start panel css copy, try to de-dupe this */\n#back {\n\tpadding: 0px;\n}\n{{template \"acc_panel_common.css\" }}\n\n.colstack_right {\n\tbackground-color: #333333;\n\twidth: 75%;\n\tpadding-right: 24px;\n\tpadding-bottom: 24px;\n\tpadding-left: 24px;\n}\n.colstack_right .colstack_head {\n\tmargin-bottom: 6px;\n}\n.colstack_right .colstack_head h1 {\n\tfont-size: 21px;\n}\n.footer .widget, #poweredByHolder {\n\tbackground-color: #393939;\n}\n\n/* end panel css copy */\n\n.colstack_right {\n\tpadding-top: 16px;\n}\n\n.account_soon, .dash_security {\n\tfont-size: 14px;\n\tcolor: rgb(270, 170, 170);\n}\n\n#account_dashboard .colstack_right .coldyn_block {\n\tdisplay: flex;\n\tmargin-top: 2px;\n}\n#dash_left {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 12px;\n\theight: 180px;\n\twidth: 240px;\n\tposition: relative;\n}\n#dash_username {\n\tdisplay: flex;\n}\n#dash_username input {\n\tdisplay: block;\n    margin-left: auto;\n    margin-right: auto;\n\tmargin-bottom: 8px;\n    width: 100px;\n\tdisplay: relative;\n\tpadding-left: 16px;\n\tbackground-position: right 8px bottom 8px;\n}\n#dash_username button {\n\tmargin-bottom: 8px;\n    padding-top: 2px;\n    padding-bottom: 2px;\n}\n\n#dash_left img {\n\tdisplay: block;\n\tborder-radius: 48px;\n\theight: 72px;\n\twidth: 72px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tmargin-bottom: 12px;\n}\n#dash_avatar_buttons {\n\tdisplay: flex;\n}\n#dash_avatar_buttons label {\n\tmargin-left: auto;\n\tmargin-right: 8px;\n}\n#dash_avatar_buttons button {\n\tmargin-right: auto;\n}\n#revoke_avatars {\n\tmargin-left: auto;\n}\n#dash_right {\n\twidth: 100%;\n\tmargin-left: 12px;\n}\n#dash_right .rowitem {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 16px;\n}\n#dash_right .rowitem:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n\n.rowlist .rowitem {\n\tdisplay: flex;\n}\n.validated_email {\n\tcolor: rgb(0, 170, 0);\n}\n.invalid_email {\n\tcolor: crimson;\n}"
  },
  {
    "path": "themes/nox/public/convo.css",
    "content": ".rowhead .rowitem,\n.convos_list .rowitem {\n\tdisplay: flex;\n}\n.convo_create_form {\n\tmargin-bottom: 8px;\n}\n.close_form {\n\tmargin-left: 8px;\n}\n.convos_list .to_left {\n\tdisplay: flex;\n}\n.convos_list .rowitem img {\n\twidth: 24px;\n\theight: 24px;\n\tmargin-right: 8px;\n\tborder-radius: 24px;\n}\n.convos_list .rowitem a {\n\t/*margin-top: auto;\n\tmargin-bottom: auto;*/\n}\n.convos_list .to_right {\n\tmargin-top: auto;\n\tmargin-bottom: auto;\n}\n.convos_item_user:not(:last-child):after {\n\tcontent: \",\";\n}\n.to_right {\n\tmargin-left: auto;\n}\n\n.parti {\n\tmargin-bottom: 8px;\n}\n.parti .rowitem {\n\tdisplay: flex;\n}\n.parti_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.rowitem .topRow {\n\tdisplay: flex;\n\twidth: 100%;\n}\n.rowitem .userbit {\n\tdisplay: flex;\n}\n.rowitem .topRow .nameAndTitle {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-left: 8px;\n}\n.nameAndTitle .real_username {\n\tfont-size: 17px;\n\tline-height: 16px;\n}\n.userbit img {\n\twidth: 40px;\n\theight: 40px;\n\tborder-radius: 24px;\n}\n.controls {\n\tmargin-left: auto;\n}\n.controls a {\n\tmargin-right: 8px;\n}\n.content_column {\n\tmargin-top: 5px;\n}\n\n.topic_reply_form {\n\tmargin-top: 8px;\n\tpadding: 12px;\n}\n.input_content {\n\twidth: 100%;\n\theight: 100px;\n\tresize: vertical;\n}"
  },
  {
    "path": "themes/nox/public/fa-svg/LICENSE.txt",
    "content": "Font Awesome Free License\n-------------------------\n\nFont Awesome Free is free, open source, and GPL friendly. You can use it for\ncommercial projects, open source projects, or really almost whatever you want.\nFull Font Awesome Free license: https://fontawesome.com/license.\n\n# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)\nIn the Font Awesome Free download, the CC BY 4.0 license applies to all icons\npackaged as SVG and JS file types.\n\n# Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL)\nIn the Font Awesome Free download, the SIL OLF license applies to all icons\npackaged as web and desktop font files.\n\n# Code: MIT License (https://opensource.org/licenses/MIT)\nIn the Font Awesome Free download, the MIT license applies to all non-font and\nnon-icon files.\n\n# Attribution\nAttribution is required by MIT, SIL OLF, and CC BY licenses. Downloaded Font\nAwesome Free files already contain embedded comments with sufficient\nattribution, so you shouldn't need to do anything additional when using these\nfiles normally.\n\nWe've kept attribution comments terse, so we ask that you do not actively work\nto remove them from files, especially code. They're a great way for folks to \nlearn about Font Awesome.\n\n# Brand Icons\nAll brand icons are trademarks of their respective owners. The use of these\ntrademarks does not indicate endorsement of the trademark holder by Font\nAwesome, nor vice versa. **Please do not use brand logos for any purpose except\nto represent the company, product, or service to which they refer.**\n"
  },
  {
    "path": "themes/nox/public/fa-svg/README.md",
    "content": "# Font Awesome 5.0.13\n\nThanks for downloading Font Awesome! We're so excited you're here.\n\nOur documentation is available online. Just head here:\n\nhttps://fontawesome.com\n"
  },
  {
    "path": "themes/nox/public/main.css",
    "content": "{{$darkest_bg := \"#222222\"}}\n{{$second_dark_bg := \"#292929\"}}\n{{$third_dark_bg := \"#333333\"}}\n* {\n\tbox-sizing: border-box;\n}\nbody {\n\tmargin: 0px;\n\tpadding: 0px;\n\tcolor: #AAAAAA;\n\tbackground-color: {{$darkest_bg}};\n\tfont-family: \"Segoe UI\";\n}\na {\n\tcolor: #eeeeee;\n\ttext-decoration: none;\n}\na:hover {\n\tcolor: #cccccc;\n}\n::selection {\n\tcolor: #111111;\n\tbackground-color: #bbbbbb;\n}\n\nnav.nav {\n\tbackground: {{$darkest_bg}};\n\twidth: calc(100% - 200px);\n\tfloat: left;\n}\nul {\n\tlist-style-type: none;\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n\tclear: both;\n}\nli {\n\tfloat: left;\n\tmargin-right: 12px;\n}\nli a {\n\tpadding-top: 35px;\n\tpadding-bottom: 22px;\n\tfont-size: 18px;\n\tdisplay: inline-block;\n\tcolor: #aaaaaa;\n}\n#menu_overview {\n\tmargin-right: 24px;\n}\n#menu_overview a {\n\tfont-size: 22px;\n\tpadding-bottom: 21px;\n\tcolor: rgb(221,221,221);\n\tpadding-top: 31px;\n}\n.menu_left.menu_active a {\n\tborder-bottom: 2px solid #777777;\n\tpadding-bottom: 21px;\n\tcolor: #dddddd;\n}\n.menu_alerts .alert_bell,\n.menu_alerts .alert_counter,\n.menu_alerts:not(.selectedAlert) .alertList {\n\tdisplay: none;\n}\n.alertList {\n\tdisplay: flex;\n\tflex-direction: column;\n\tbackground-color: #444444;\n\tposition: absolute;\n\tborder: 1px solid #333333;\n\ttop: 82px;\n\tborder-top: none;\n\tright: 0px;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.alertItem {\n\tpadding: 10px;\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n}\n.alertItem:not(.withAvatar) {\n\tpadding-top: 6px;\n\tpadding-bottom: 6px;\n}\n.alertItem:not(.withAvatar) a {\n\tpadding-top: 14px;\n\tpadding-bottom: 18px;\n\tfont-size: 17px;\n}\n.alertItem.withAvatar {\n\tbackground: none !important;\n\theight: 66px;\n\tpadding-top: 4px;\n\tdisplay: flex;\n\tpadding: 16px;\n\tpadding-left: 0px;\n\tpadding-right: 0px;\n}\n.alertItem.withAvatar:not(:last-child) {\n\tborder-bottom: 1px solid #555555;\n}\n.alertItem.withAvatar .bgsub {\n\theight: 36px;\n\twidth: 36px;\n\tborder-radius: 32px;\n}\n.alertItem.withAvatar .text {\n\tmargin-left: 12px;\n\tpadding-top: 5px;\n\tfont-size: 16px;\n}\n.menu_hamburger > a:after {\n\tcontent: \"{{lang \"menu_more\" . }}\";\n}\n.menu_hamburger, .more_menu, .menu_hide {\n\tdisplay: none;\n}\n.more_menu {\n\tposition: absolute;\n\tbackground-color: #444444;\n\tborder: 1px solid #333333;\n\tflex-direction: column;\n\tlist-style-type: none;\n\tpadding: 16px;\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\ttop: 70px;\n}\n.more_menu_selected {\n\tdisplay: flex !important;\n}\n.more_menu li a {\n\tfont-size: 17px;\n\tpadding-top: 0px !important;\n\tpadding-bottom: 0px !important;\n}\n.more_menu li a:not(:last-child) {\n\tpadding-bottom: 8px !important;\n}\n.more_menu .menu_active a {\n\tborder-bottom: none;\n}\n\n.right_of_nav {\n\tfloat: left;\n\twidth: 200px;\n\tbackground-color: {{$darkest_bg}};\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\tpadding-right: 12px;\n}\n.user_box {\n\tdisplay: flex;\n\tflex-direction: row;\n\tborder-radius: 3px;\n\tbackground-color: {{$third_dark_bg}};\n\tpadding-top: 11px;\n\tpadding-bottom: 11px;\n\tpadding-left: 12px;\n}\n.user_box.has_alerts {\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n\tborder: 1px solid #444444;\n}\na img:hover {\n\tfilter: brightness(92%);\n}\n.user_box img {\n\tdisplay: block;\n\twidth: 36px;\n\theight: 36px;\n\tborder-radius: 32px;\n\tmargin-right: 8px;\n}\n.user_box .username {\n\tdisplay: block;\n\tfont-size: 16px;\n\tpadding-top: 4px;\n\tline-height: 10px;\n}\n.user_box .alerts {\n\tfont-size: 12px;\n\tline-height: 12px;\n}\n#container {\n\tclear: both;\n}\n#back {\n\tbackground: {{$third_dark_bg}};\n\tpadding: 24px;\n\tpadding-top: 12px;\n\tclear: both;\n\tdisplay: flex;\n}\n#main, #main .rowblock {\n\twidth: 100%;\n}\n\n.alert {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 12px;\n}\n\n.shrink_main .sidebar {\n\twidth: 320px;\n}\n.widget_simple .rowitem {\n\tline-height: 18px;\n\tpadding-top: 14px !important;\n\tpadding-bottom: 14px !important;\n}\n.widget_simple.rowhead .rowitem {\n\tpadding-bottom: 4px !important;\n}\n.the_form {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 16px;\n}\n.rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem:not(.post_item), .topic_list .rowitem.rowmsg {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tdisplay: flex;\n\tpadding: 12px;\n}\n.sidebar .rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem, .sidebar .search {\n\tmargin-left: 12px;\n}\n.topics_moderate .can_mod {\n\tbackground-color: #4d4d4d;\n}\n.topics_moderate .can_mod:hover {\n\tbackground-color: rgb(78, 78, 98);\n}\n.widget_search:first-child {\n\tmargin-top: 36px;\n}\n.widget_search input {\n\twidth: 100%;\n\theight: 30px;\n\tmargin-left: 0px;\n}\n.filter_list {\n\tmargin-top: 5px;\n}\n.colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 16px;\n}\n.filter_item a {\n\tcolor: #BBBBBB;\n\ttext-overflow: ellipsis;\n\toverflow: hidden;\n\twhite-space: nowrap;\n}\n.colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem:not(:last-child), .rowmsg {\n\tmargin-bottom: 8px;\n}\n.colstack_right .colstack_head:not(:first-child) {\n\tmargin-top: 16px;\n}\n\nh1, h2, h3, h4, h5 {\n\t-webkit-margin-before:0;\n\t-webkit-margin-after:0;\n\tmargin-block-start:0;\n\tmargin-block-end:0;\n\tmargin-top:0;\n\tmargin-bottom:0;\n\tfont-weight:normal;\n\twhite-space:nowrap;\n}\n\n/* new */\n.filter_list {\n\tmargin-top: 5px;\n\tbackground-color: #444444;\n\tmargin-left: 12px;\n\tborder-radius: 3px;\n}\n.filter_item {\n\tmargin-left: 0px !important;\n\tborder-radius: 0px !important;\n}\n.filter_item:hover {\n\tbackground-color: #505050 !important;\n}\n.filter_selected {\n\tbackground-color: #555555 !important;\n}\n.filter_selected a {\n\tcolor: #CCCCCC;\n}\n/* new end */\n\n@keyframes fadein {\n\tfrom { opacity: 0; }\n\tto { opacity: 1; }\n}\n.modal_pane {\n\tposition: fixed;\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n\tbackground: #444444;\n\tborder: 2px solid #333333;\n\tborder-radius: 5px;\n\tpadding: 12px;\n\tpadding-top: 8px;\n\tz-index: 9999;\n\tanimation: fadein 0.8s;\n}\n.pane_header {\n\tmargin-bottom: 2px;\n}\n.pane_row {\n\tmargin-top: 2px;\n\tcursor: pointer;\n}\n.pane_selected {\n\tfont-weight: bold;\n}\n.pane_buttons {\n\tmargin-top: 8px;\n}\n.mod_floater {\n\tposition: absolute;\n\tright: 10px;\n\tbottom: 10px;\n\tbackground: #444444;\n\tborder-radius: 5px;\n\tpadding: 12px;\n\tpadding-top: 8px;\n\twidth: 200px;\n}\n.mod_floater_head span {\n\tmargin-bottom: 6px;\n\tdisplay: block;\n}\n.mod_floater_body {\n\tdisplay: flex;\n}\n.mod_floater_options {\n\twidth: 100%;\n\tmargin-right: 10px;\n\tpadding: 4px;\n\tmargin-bottom: 0px;\n}\n#are_you_sure .rowblock .rowitem.passive {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n.rowhead, .opthead, .colstack_head {\n\tmargin-left: 8px;\n\tmargin-bottom: 8px;\n}\n.rowhead h1, .opthead h1, .colstack_head h1 {\n\tfont-size: 21px;\n}\n.rowhead h1 + h2, .opthead h1 + h2, .colstack_right .colstack_head .rowitem h1 + h2 {\n\tmargin-left: auto;\n}\n\n.sidebar .rowhead {\n\tmargin-left: 18px;\n\tmargin-top: 4px;\n\tmargin-bottom: 8px;\n}\n.sidebar .rowhead h1 {\n\tfont-size: 20px;\n}\n.sidebar .rowhead:not(:first-child) h1 {\n\tmargin-top: 12px;\n\tfont-size: 19px;\n}\n\nh2 {\n\tfont-size: 18px;\n\tmargin-top: 12px;\n\tmargin-bottom: 8px;\n\tmargin-left: 8px;\n}\n.rowhead h2, .colstack_head h2 {\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n\tmargin-left: 0px;\n}\n\n.topic_create_form {\n\tdisplay: flex;\n}\n.quick_reply_form, .topic_reply_form, .topic_create_form {\n\tbackground-color: #444444;\n\tborder-radius: 3px;\n}\n.quick_create_form {\n\tmargin-bottom: 8px;\n\tpadding: 16px;\n}\n.quick_create_form .little_row_avatar {\n\tborder-radius: 36px;\n\tmargin-left: 4px;\n\tmargin-right: 20px;\n\theight: 48px;\n\twidth: 48px;\n}\n.quick_create_form .main_form {\n\twidth: 80%;\n}\n.quick_create_form .topic_meta {\n\tdisplay: flex;\n}\n.quick_create_form input, .quick_create_form select {\n\tmargin-left: 0px;\n\tmargin-bottom: 0px;\n}\n.quick_create_form .topic_meta .topic_name_row {\n\tmargin-bottom: 8px;\n\twidth: 100%;\n\tfont-size: 14px;\n}\n.quick_create_form .topic_meta .topic_name_row:not(:only-child) {\n\tmargin-left: 6px;\n}\n.quick_create_form .topic_meta .topic_name_row:only-child input {\n\tmargin-left: 0px;\n}\n.quick_create_form .topic_meta .topic_name_row input {\n\twidth: 100%;\n}\n.quick_create_form .topic_content_row textarea {\n\twidth: 100%;\n\theight: 60px;\n\tresize: vertical;\n}\n.quick_create_form .quick_button_row .formitem {\n\tdisplay: flex;\n\tmargin-top: 6px;\n}\n.quick_create_form .quick_button_row button, .quick_create_form .quick_button_row label {\n\tmargin-right: 8px;\n}\n.quick_create_form #input_content {\n\twidth: 100%;\n\theight: 100px;\n\tresize: vertical;\n}\n.uploadItem {\n\tdisplay: inline-block;\n}\n\n.more_topic_block_initial {\n\tdisplay: none;\n}\n.more_topic_block_active {\n\tdisplay: block;\n}\n\n.hide_ajax_topic,\n.auto_hide,\n.show_on_edit:not(.edit_opened),\n.hide_on_edit.edit_opened,\n.show_on_block_edit:not(.edit_opened),\n.hide_on_block_edit.edit_opened,\n.link_select:not(.link_opened) {\n\tdisplay: none !important;\n}\n\n.topic_list_title_block {\n\tdisplay: flex;\n\tmargin-left: 8px;\n}\n.topic_list_title {\n\tmargin-left: 2px;\n}\n.topic_list_title_block .optbox {\n\tdisplay: flex;\n\tfont-size: 17px;\n\tmargin-top: 3.5px;\n\tmargin-right: 16px;\n\tmargin-right: 18px;\n\twidth: 100%;\n}\n.topic_list_title_block .pre_opt:before {\n\tcontent: \"{{lang \"topics_click_topics_to_select\" . }}\";\n\tfont-size: 17px;\n\tmargin-right: 20px;\n}\n.topic_list_title_block .opt a {\n\tcolor: #afafaf;\n\tmargin-left: 8px;\n\twhite-space: nowrap;\n}\n.topic_list_title_block .create_topic_opt a:before {\n\tcontent: \"{{lang \"quick_topic.create_button\" . }}\";\n}\n.topic_list_title_block .mod_opt a:before {\n\tcontent: \"{{lang \"topic_list.moderate\" . }}\";\n}\n.topic_list_title_block .moderate_link.moderate_open:before {\n\tcontent: \"{{lang \"topic_list.cancel_mod\" . }}\";\n}\n\n.filter_opt, .dummy_opt {\n\tmargin-right: auto;\n}\n.filter_opt.opt a.filter_opt_label {\n\tfont-size: 18px;\n\tmargin-left: 5px;\n}\n.filter_opt_pointy {\n\tmargin-left: -5px;\n}\n.link_select {\n\tbackground: #333333;\n\tbackground-color: #444444;\n\tposition: absolute;\n\tborder: 1px solid #333333;\n\tpadding: 16px;\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n\tmargin-top: 2px;\n}\n.link_select .link_option a {\n\tmargin-left: 0px;\n}\n\n.topic_row:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n.topic_row {\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tdisplay: flex;\n}\n.topic_left, .topic_right, .topic_middle {\n\tpadding: 16px;\n\tpadding-bottom: 10px;\n\tpadding-top: 16px;\n\tdisplay: flex;\n\twidth: 33%;\n}\n.topic_middle {\n\tline-height: 20px;\n}\n.topic_left {\n\tmargin-right: auto;\n}\n.topic_sticky .topic_left {\n\tborder-left: 3px solid rgb(215, 155, 0);\n\tborder-radius: 3px;\n}\n.topic_closed .topic_left {\n\tborder-left: 3px solid grey;\n\tborder-radius: 3px;\n}\n.topic_closed {\n\tbackground-color: #4b4b4b;\n}\n.topic_row.topic_selected {\n\tbackground-color: rgb(68, 68, 88);\n}\n.new_item .topic_left {\n\tborder-left: 3px solid rgb(215, 215, 215);\n\tborder-radius: 3px;\n}\n.topic_left img, .topic_right img {\n\tborder-radius: 24px;\n\theight: 38px;\n\twidth: 38px;\n\tmargin-right: 10px;\n}\n.topic_left img:hover, .topic_right img:hover {\n\tfilter: brightness(95%);\n}\n.topic_inner_left {\n\tdisplay: flex;\n\tflex-direction: column;\n\twidth: 92%;\n}\n.topic_inner_left .rowtopic {\n\twhite-space: nowrap;\n\ttext-overflow: ellipsis;\n\toverflow: hidden;\n}\n.parent_forum_sep {\n\tmargin-left: 6px;\n\tmargin-right: 6px;\n}\n.topic_right_inside {\n\tdisplay: flex;\n\tmargin-left: auto;\n\twidth: 180px;\n}\n.topic_right_inside .lastName, .topic_left .rowtopic {\n\tfont-size: 15px !important;\n\tline-height: 22px;\n\tmargin-top: -2px;\n}\n.topic_right_inside .lastName {\n\tfont-size: 14px;\n}\n.topic_right_inside .lastReplyAt, .topic_left .starter {\n\tfont-size: 14px;\n\tline-height: 14px;\n}\n.topic_right_inside span {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.topic_inner_left br,\n.topic_right_inside br,\n.topic_inner_right,\n.topic_list:not(.topic_list_weekviews) .topic_middle .weekViewCount,\n.topic_list:not(.topic_list_mostviewed) .topic_middle .viewCount,\n.topic_list_weekviews .topic_middle .likeCount,\n.topic_list_mostviewed .topic_middle .likeCount {\n\tdisplay: none;\n}\n.topic_middle_inside {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tmargin-top: -3px;\n\twidth: 80px;\n}\n.topic_status_e {\n\tdisplay: none;\n}\n\n/* TODO: Make a generic version of this so that we can have more blocks which are initially hidden but flip over to visible under certain conditions */\n.more_topic_block_initial {\n\tdisplay: none;\n}\n.more_topic_block_active {\n\tdisplay: block;\n}\n\ninput, select, button, .formbutton, .panel_right_button:not(.has_inner_button), textarea {\n\tborder-radius: 3px;\n\tbackground: rgb(90,90,90);\n\tcolor: rgb(200,200,200);\n\tborder: none;\n\tpadding: 4px;\n}\ninput:focus, select:focus, textarea:focus {\n\toutline: 1px solid rgb(120,120,120);\n}\ninput:not(input[type=search]):not(input[type=submit]) {\n\tbackground-image: url(./fa-svg/pencil-alt.svg);\n\tbackground-size: 12px;\n\tbackground-repeat: no-repeat;\n\tbackground-position: right 10px bottom 9px;\n\tbackground-position-x: right 10px;\n}\ninput {\n\tpadding: 5px;\n\tpadding-bottom: 3px;\n\tfont-size: 16px;\n}\ninput, select {\n\tmargin-left: 3px;\n\tmargin-bottom: 4px;\n}\nbutton, .formbutton, .panel_right_button:not(.has_inner_button) {\n\tbackground: rgb(110,110,210);\n\tcolor: rgb(250,250,250);\n\tfont-family: \"Segoe UI\";\n\tfont-size: 15px;\n\ttext-align: center;\n\tpadding: 6px;\n}\n.formlabel {\n\tmargin-bottom: 4px;\n}\n/*.formlabel + .formitem {\n\tmargin-left: 4px;\n}*/\n.formrow {\n\tmargin-bottom: 6px;\n}\n.form_button_row {\n\tmargin-top: 10px;\n}\n.formitem a {\n\tmargin-bottom: 5px;\n\tdisplay: block;\n}\n\n.login_mfa_token_row .formlabel {\n\tdisplay: none;\n}\n.fall_opts {\n\tdisplay: flex;\n}\n.dont_have_account, .forgot_password {\n\tmargin-top: 12px;\n\tmargin-bottom: -8px;\n}\n.forgot_password {\n\tmargin-left: auto;\n}\n\n.pageset {\n\tdisplay: flex;\n\tmargin-top: 8px;\n}\n.pageitem {\n\tfont-size: 17px;\n\tborder-radius: 3px;\n\tbackground-color: #444444;\n\tpadding: 7px;\n\tmargin-right: 6px;\n}\n.pagefirst, .pagenext, .pageprev, .pagelast {\n\tpadding-top: 2px;\n\tpadding-bottom: 6px;\n}\n.pagefirst a, .pagenext a, .pageprev a, .pagelast a {\n\tfont-size: 22px;\n}\n.pagecurrent {\n\tbackground-color: #505050;\n}\n\n#prevFloat, #nextFloat {\n\tdisplay: none;\n}\n.forum_list .rowitem {\n\tmargin-bottom: 8px;\n\tdisplay: flex;\n}\n.forum_list .forum_left {\n\tmargin-left: 8px;\n}\n.forum_list .forum_nodesc {\n\tfont-style: italic;\n}\n.forum_list .forum_right {\n\tdisplay: flex;\n\tmargin-left: auto;\n\tmargin-right: 8px;\n\tpadding-top: 2px;\n\twidth: 155px;\n}\n.forum_list .forum_right img {\n\tmargin-right: 10px;\n\tmargin-top: 2px;\n}\n.forum_list .forum_right span {\n\tline-height: 19px;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n.forum_list .forum_right span a {\n\twhite-space: nowrap;\n}\n.extra_little_row_avatar {\n\tborder-radius: 24px;\n\theight: 36px;\n\twidth: 36px;\n}\n.extra_little_row_avatar:hover {\n\tfilter: brightness(92%);\n}\n\n.colstack, .topic_item {\n\tdisplay: flex;\n}\n\n.topic_item .topic_name_forum_sep {\n\tfont-size: 20px;\n\tline-height: 30px;\n\tmargin-left: 7px;\n\tmargin-right: 7px;\n}\n.topic_item .topic_forum {\n\tfont-size: 19px;\n\tline-height: 30px;\n\tcolor: #cccccc;\n}\n.topic_view_count {\n\tfont-size: 17px;\n\tmargin-left: auto;\n\tmargin-right: 20px;\n\tmargin-top: 6px;\n}\n.topic_view_count:after {\n\tcontent: \"{{lang \"topic.view_count_suffix\" . }}\";\n}\n.edithead {\n\tmargin-left: 0px;\n\tmargin-bottom: 10px;\n}\n.topic_name_input {\n\twidth: 100%;\n\tmargin-right: 10px;\n\tmargin-bottom: 0px;\n\tmargin-left: 0px;\n}\nsp.topic_item .submit_edit {\n\t/*margin-right: 16px;*/\n}\n.zone_view_topic button, .zone_view_topic .formbutton {\n\tpadding: 5px;\n\tpadding-top: 4px;\n\tpadding-bottom: 4px;\n}\n.postImage {\n\twidth: 100%;\n\tmax-width: 320px;\n}\nvideo {\n\twidth: 100%;\n}\nblockquote {\n\tbackground-color: #555555;\n\tborder-radius: 3px;\n\tpadding: 8px;\n\tmargin: 0px;\n\tmargin-top: 8px;\n\tmargin-bottom: 8px;\n}\nblockquote + br {\n\tdisplay: none;\n}\nblockquote:only-child {\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\nblockquote:first-child {\n\tmargin-top: 0px;\n}\n.post_item {\n\tdisplay: flex;\n\tmargin-bottom: 12px;\n}\n.userinfo {\n\tmargin-right: 12px;\n\tpadding: 24px;\n\tpadding-bottom: 16px;\n\tbackground-color: #444444;\n\tborder-radius: 3px;\n\twidth: 150px;\n}\n.userinfo, .user_meta {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.avatar_item {\n\tbackground-position: 0px -10px;\n\tbackground-size: 78px;\n}\n.aitem, .avatar_item {\n\theight: 58px;\n\twidth: 58px;\n\tborder-radius: 36px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.the_name {\n\tmargin-left: auto;\n\tmargin-right: auto;\n\twhite-space: nowrap;\n\tdisplay: block;\n\tfont-size: 18px;\n\tmargin-top: 8px;\n\tline-height: 16px;\n}\n.tag_block {\n\tdisplay: flex;\n}\n.post_tag {\n\twhite-space: nowrap;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tdisplay: block;\n}\n.post_item .topic_content_input {\n\tresize: vertical;\n\theight: 150px;\n\tpadding: 16px;\n}\n.post_item .content_container {\n\tborder-radius: 3px;\n\twidth: 100%;\n\tdisplay: flex;\n\tflex-direction: column;\n\tcolor: #bbbbbb;\n}\n.action_item .content_container, .post_item .user_content, .post_item .button_container {\n\tbackground-color: #444444;\n\tborder-radius: 3px;\n\tpadding: 16px;\n}\n.user_content {\n\tword-break: break-word;\n}\n.user_content h2 {\n\tfont-size: 20px;\n}\n.user_content h3 {\n\tfont-size: 19px;\n}\n.user_content h2, .user_content h3 {\n\tmargin-top: 3px;\n\tmargin-bottom: 12px;\n\tmargin-left: 0px;\n}\n.user_content h2 + br, .user_content h3 + br {\n\tdisplay: none;\n}\n.user_content strong h2, .user_content strong h3 {\n\tfont-weight: bold;\n}\n.user_content.in_edit {\n\tpadding: 0px;\n\tbackground: none;\n}\n.user_content textarea {\n\tresize: vertical;\n\theight: 150px;\n\twidth: 100% !important;\n\tpadding: 16px;\n}\nred {\n\tcolor: red;\n}\n.hide_spoil {\n\tbackground-color: grey;\n\tcolor: grey;\n}\n.hide_spoil img {\n\tborder: 0;\n\tclip: rect(0 0 0 0);\n\theight: 1px;\n\tmargin: -1px;\n\toverflow: hidden;\n\tpadding: 50px;\n\twhite-space: nowrap;\n\twidth: 1px;\n\tbackground-color: grey;\n}\n.hide_spoil img {\n\tcontent: \"   \";\n}\n.attach_box {\n\tbackground-color: #5a5555;\n\tborder-radius: 3px;\n\tpadding: 16px;\n}\n.update_buttons {\n\tdisplay: flex;\n\tbackground-color: #444444;\n\tborder-radius: 4px;\n\tmargin-top: 4px; /*8 without <br>*/\n\tpadding: 6px;\n}\n.user_content.in_edit a {\n\tmargin-right: 8px;\n}\n.post_item .button_container {\n\tdisplay: flex;\n\tmargin-top: 8px;\n\tmargin-bottom: auto;\n\tpadding: 14px;\n}\n.post_item .action_button {\n\tmargin-right: 5px;\n\tfont-size: 15px;\n\tcolor: #dddddd;\n\twhite-space: nowrap;\n}\n.post_item .action_button_left, .post_item .action_button_right {\n\tdisplay: flex;\n}\n.post_item .action_button_right {\n\tmargin-left: auto;\n}\n.post_item .controls:not(.has_likes) .like_count, .action_item .userinfo, .action_item .action_icon {\n\tdisplay: none;\n}\n.action_item .content_container {\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n}\n.action_item .content_container span {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n\ninput[type=checkbox] {\n\tdisplay: none;\n}\ninput[type=checkbox] + label {\n\tdisplay: inline-flex;\n\twidth: 18px;\n\theight: 18px;\n\tmargin-bottom: -2px;\n\tmargin-right: 8px;\n\tbackground-color: rgb(90,90,90);\n\tpadding-top: 1px;\n\tborder-radius: 2px;\n}\ninput[type=checkbox]:checked + label .sel,\ninput[type=checkbox]:not(:checked):hover + label .sel {\n\twidth: 8px;\n\theight: 8px;\n\tmargin: auto;\n\tborder-radius: 2px;\n}\ninput[type=checkbox]:checked + label .sel {\n\tbackground: rgb(140,140,140);\n}\ninput[type=checkbox]:not(:checked):hover + label .sel {\n\tbackground: rgb(120,120,120);\n}\n.poll_option {\n\tdisplay: flex;\n\tmargin-bottom: 10px;\n}\n.poll_option_text {\n\tline-height: 14px;\n}\n.poll_buttons {\n\tpadding-top: 4px;\n}\n.poll_buttons button {\n\tmargin-right: 8px;\n}\n.poll_results {\n\tmargin-left: 12px;\n}\n.pollinput {\n\tmargin-bottom: 5px;\n}\n.pollinput:last-child {\n\tmargin-bottom: 12px;\n}\n\n.ip_item {\n\tdisplay: none;\n}\n\n.add_like:before, .remove_like:before {\n\tcontent:\"{{lang \"topic.plus_one\" . }}\";\n}\n.remove_like:before {\n\tcontent:\"{{lang \"topic.minus_one\" . }}\";\n}\n.button_container .open_edit:after, .edit_item:after {\n\tcontent:\"{{lang \"topic.edit_button_text\" . }}\";\n}\n.ip_item_button:after {\n\tcontent:\"{{lang \"topic.ip_button_text\" . }}\";\n}{{$p := .}}\n{{range (toArr \"quote\" \"delete\" \"lock\" \"unlock\" \"pin\" \"unpin\" \"report\")}}\n.{{.}}_item:after {\n\tcontent:\"{{lang (concat \"topic.\" . \"_button_text\") ($p) }}\";\n}{{end}}\n.like_count:after {\n\tcontent:\"{{lang \"topic.like_count_suffix\" . }}\";\n}\n\n.attach_item {\n\tdisplay: flex;\n\tbackground-color: #444444;\n\tborder-radius: 4px;\n\tmargin-top: 8px;\n\tpadding: 6px;\n\ttext-overflow: ellipsis;\n\toverflow: hidden;\n}\n.attach_item_selected {\n\tbackground-color: #446644\n}\n.attach_item img {\n\tmargin-right: 8px;\n\tborder-radius: 4px;\n}\n.attach_edit_bay button {\n\tmargin-top: 8px;\n\tmargin-left: 8px;\n}\n\n/* New */\n.attach_item {\n\tpadding: 8px;\n\twidth: 100%;\n}\n.attach_item_item span {\n\tmargin-bottom: 4px;\n\tmargin-right: auto;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twidth: 350px;\n}\n.attach_image_holder span {\n\twidth: 300px;\n}\n.attach_item button {\n\tmargin-top: -1px;\n}\n.post_item:not(.has_attachs):not(.top_post) .attach_item_buttons,\n.post_item:not(.has_attachs) .attach_item_delete,\n.has_attachs .update_buttons .add_file_button {\n\tdisplay: none;\n}\n\n.zone_view_topic .pageset {\n\tmargin-bottom: 14px;\n}\n.topic_reply_container {\n\tdisplay: flex;\n}\n\n.rowlist.bgavatars:not(.not_grid), .micro_grid {\n\tdisplay: grid;\n\t/*grid-gap: 16px;\n\tgrid-row-gap: 8px;*/\n\tgrid-gap: 24px;\n\tgrid-row-gap: 16px;\n\tgrid-template-columns: repeat(3, 1fr);\n\tgrid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n}\n.rowlist.bgavatars.micro_grid, .micro_grid {\n\tgrid-gap: 16px;\n\tgrid-row-gap: 4px;\n\tgrid-template-columns: repeat(auto-fit, minmax(100px, 1fr));\n}\n.rowlist.bgavatars .rowitem, .micro_grid .rowitem {\n\tdisplay: flex;\n\tflex-direction: column;\n\t/*width: 180px;*/\n\tbackground-image: none !important;\n\tmargin-bottom: 10px;\n\tpadding: 16px;\n}\n.rowlist.not_grid .rowitem {\n\tflex-direction: row;\n}\n.rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowTitle, .rowlist.bgavatars .rowAvatar {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.rowlist.bgavatars .bgsub {\n\tborder-radius: 32px;\n\theight: 64px;\n\twidth: 64px;\n}\n.rowlist.bgavatars .rowTitle {\n\tfont-size: 18px;\n\tmargin-top: 4px;\n}\n.rowlist.bgavatars .rowAvatar {\n\tmargin-bottom: -4px;\n}\n.rowlist.bgavatars.not_grid .bgsub {\n\theight: 28px;\n\twidth: 28px;\n\tmargin-left: 4px;\n\tmargin-right: 10px;\n}\n.rowlist.bgavatars.not_grid .rowTitle {\n\tfont-size: 17px;\n\tmargin-left: 0px;\n\tmargin-top: 0px;\n}\n.loglist .to_left small {\n\tmargin-left: 2px;\n\tfont-size: 12px;\n}\n\n.ip_search_block {\n\tmargin-bottom: 12px;\n}\n.ip_search_input {\n\twidth: 100%;\n\tmargin-right: 8px;\n}\n\n.footer .widget, .elapsed {\n\tpadding: 12px;\n\tborder-bottom: 1px solid #555555;\n}\n.elapsed {\n\tpadding: 6px;\n\tbackground: rgb(82,82,82);\n\tborder-radius: 3px;\n\tfont-size: 13.5px;\n\tcolor: rgb(200,200,200);\n\tmargin-top: 1px;\n\tmargin-bottom: 4px;\n\tpadding-bottom: 2px;\n\tpadding-top: 3px;\n\tmargin-right: 3px;\n}\n#poweredByHolder {\n\tdisplay: flex;\n\tpadding-top: 12px;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n\tpadding-bottom: 8px;\n}\n#poweredBy {\n\tmargin-right: auto;\n}\n.footer .widget, #poweredByHolder {\n\tbackground-color: #444444;\n}\n\n.level_complete, .level_future, .level_inprogress, .progressWrap {\n\tdisplay: flex;\n}\n.level_inprogress {\n\tposition: relative;\n}\n.level_complete {\n\tbackground-color: rgb(68, 93, 68) !important;\n\twidth: 100%;\n}\n.level_future {\n\tbackground-color: rgb(88, 68, 68) !important;\n\twidth: 100%;\n}\n.progressWrap {\n\tmargin-left: auto;\n}\n.levelBit {\n\tcolor: #dadada;\n}\n/* CSS behaves in stupid ways, so we need to be very specific about this */\n.rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem.level_inprogress:not(.post_item),\n.coldyn_item .rowitem.level_inprogress {\n\tpadding: 0px !important;\n}\n.level_inprogress > div {\n\tdisplay: flex;\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\tpadding-left: 12px;\n\tborder-radius: 3px;\n\t/*width: 100%;*/\n}\n.level_inprogress:not(.level_zero) .levelBit {\n\tbackground-color: rgb(68, 93, 68) !important;\n}\n.level_inprogress .levelBit {\n\tdisplay: inline;\n\tposition: absolute;\n\tz-index: 1;\n}\n.level_inprogress .levelBit a {\n\twhite-space: nowrap;\n}\n.level_inprogress .progressWrap {\n\t/*width: 100%;*/\n\tpadding-left: 0px;\n\tpadding-right: 12px;\n\t/*background-color: rgb(68, 68, 68) !important;*/\n\tz-index: 2;\n}\n.level_inprogress .progressWrap div {\n\tmargin-left: auto;\n\twhite-space: nowrap;\n}\n\n@media(max-width: 600px) {\n\t.rowhead h1, .opthead h1, .colstack_head h1 {\n\t\tfont-size: 19px;\n\t}\n\t.topic_list_title_block .opt {\n\t\tmargin-top: -1px;\n\t}\n\t.topic_list_title_block .opt a {\n\t\tfont-size: 16px;\n\t}\n\n\t.topic_list .topic_middle {\n\t\tdisplay: none;\n\t}\n\t.topic_left, .topic_right, .topic_middle {\n\t\twidth: 50%;\n\t}\n\t.topic_right_inside .lastName, .topic_left .rowtopic {\n\t\tmargin-top: -4px;\n\t}\n\t.topic_left img, .topic_right img {\n\t\theight: 32px;\n\t\twidth: 32px;\n\t}\n\t.topic_list .topic_right_inside .lastReplyAt, .topic_list .topic_left .starter {\n\t\twhite-space: nowrap;\n\t}\n\n\t.userinfo {\n\t\tpadding: 18px;\n\t\twidth: 140px;\n\t}\n\t.avatar_item {\n\t\theight: 48px;\n\t\twidth: 48px;\n\t\tbackground-size: 68px;\n\t}\n\t.the_name {\n\t\tfont-size: 17px;\n\t}\n}\n\n@media(max-width: 500px) {\n\t.sidebar, .topic_view_count {\n\t\tdisplay: none;\n\t}\n\t\n\t.post_item .button_container {\n\t\tdisplay: block;\n\t\tmargin-top: 8px;\n\t\tbackground: transparent;\n\t\tpadding: 0px;\n\t}\n\t.post_item .action_button_left {\n\t\tdisplay: block;\n\t\tbackground-color: #444444;\n\t\tborder-radius: 3px;\n\t\tpadding: 10px;\n\t}\n\t.post_item .action_button_right {\n\t\tbackground-color: #444444;\n\t\tborder-radius: 3px;\n\t\tpadding: 10px;\n\t\tpadding-left: 14px;\n\t\t/*padding-right: 12px;*/\n\t\tmargin-top: 8px;\n\t}\n\t.post_item .controls:not(.has_likes) .like_count {\n\t\tdisplay: inline;\n\t}\n\t.post_item .created_at {\n\t\tmargin-left: auto;\n\t}\n\t.post_item, .topic_reply_container {\n\t\tflex-direction: column;\n\t}\n\t.userinfo {\n\t\tmargin-right: 0px;\n\t\twidth: auto;\n\t\tflex-direction: row;\n\t\tmargin-bottom: 8px;\n\t\tpadding: 18px;\n\t\tpadding-bottom: 14px;\n\t}\n\t.avatar_item {\n\t\theight: 46px;\n\t\twidth: 46px;\n\t\tmargin-left: 0px;\n\t\tmargin-right: 0px;\n\t}\n\t.user_meta {\n\t\tmargin-left: 10px;\n\t\tmargin-top: -4px;\n\t}\n}\n\n@media(max-width: 460px) {\n\tul {\n\t\tbackground: #3f3f3f;\n\t}\n\t.topic_list_title, .filter_opt_sep {\n\t\tdisplay: none;\n\t}\n\t.topic_list_title_block .create_topic_opt a:before {\n\t\tcontent: \"{{lang \"quick_topic.create_button_short\" . }}\";\n\t}\n\t.topic_list_title_block .mod_opt a:before {\n\t\tcontent: \"{{lang \"topic_list.moderate_short\" . }}\";\n\t}\n\t.topic_inner_left .parent_forum, .parent_forum_sep {\n\t\tdisplay: none;\n\t}\n}\n\n@media(max-width: 601px) {\n\tul {\n\t\tpadding-left: 14px;\n\t}\n\tli a {\n\t\tpadding-bottom: 6px;\n\t\tfont-size: 15px;\n\t\tcolor: #bfbfbf;\n\t}\n\n\t#menu_overview {\n\t\tmargin-right: 10px;\n\t}\n\t#menu_overview a {\n\t\tfont-size: 17px;\n\t\tpadding-bottom: 7px;\n\t\tcolor: rgb(226,226,226);\n\t\tpadding-top: 12px;\n\t}\n\t.menu_left.menu_active a {\n\t\tcolor: #cfcfcf;\n\t}\n}\n\n@media (max-width: 750px) and (min-width: 600px) {\n\tul {\n\t\tpadding-left: 16px;\n\t}\n\t#menu_overview {\n\t\tmargin-right: 12px;\n\t}\n\t#menu_overview a {\n\t\tfont-size: 19px;\n\t\tpadding-bottom: 5px;\n\t\tcolor: rgb(231,231,231);\n\t\tpadding-top: 11px;\n\t}\n\tli a {\n\t\tpadding-bottom: 13px;\n\t\tfont-size: 16px;\n\t\tcolor: #cfcfcf;\n\t}\n\t.menu_left.menu_active a {\n\t\tcolor: #dddddd;\n\t}\n}\n\n@media (max-width: 750px) {\n\tnav.nav {\n\t\tbackground: #2a2a2a;\n\t\twidth: 100%;\n\t}\n\tul {\n\t\tdisplay: flex;\n\t\tpadding-right: 0px;\n\t}\n\tli {\n\t\tfloat: left;\n\t\tmargin-right: 6px;\n\t}\n\tli a {\n\t\tpadding-top: 14px;\n\t\tdisplay: inline-block;\n\t}\n\t.menu_left.menu_active a {\n\t\tpadding-bottom: 15px;\n\t\tborder: none;\n\t}\n\t.more_menu {\n\t\ttop: 50px;\n\t}\n\n\t.right_of_nav {\n\t\twidth: auto;\n\t\tpadding: 0px;\n\t}\n\t.user_box, .elapsed {\n\t\tdisplay: none;\n\t}\n\t#back {\n\t\tflex-direction: column;\n\t}\n\n\t.topic_item .topic_name_forum_sep {\n\t\tfont-size: 17px;\n\t\tline-height: 28px;\n\t\tmargin-left: 5px;\n\t\tmargin-right: 5px;\n\t}\n\t.topic_item .topic_forum {\n\t\tfont-size: 17px;\n\t\tline-height: 28px;\n\t}\n}\n\n@media(min-width: 751px) {\n\t.menu_profile {\n\t\tdisplay: none;\n\t}\n\t.shrink_main #main {\n\t\tmax-width: calc(100% - 180px);\n\t}\n}\n{{/**@media(max-width: 850px) {\n\t//\n}**/}}\n\n@media(min-width: 1010px) {\n\t#container {\n\t\tbackground-color: {{$second_dark_bg}};\n\t}\n\t#back, .footer {\n\t\twidth: 1000px;\n\t\tmargin-left: auto;\n\t\tmargin-right: auto;\n\t}\n\t.footBlock, .footer {\n\t\tdisplay: flex;\n\t}\n\t.footer {\n\t\tflex-direction: column;\n\t}\n\n\t.userinfo {\n\t\twidth: 180px;\n\t\tpadding-bottom: 18px;\n\t}\n\t.userinfo .avatar_item {\n\t\theight: 64px;\n\t\twidth: 64px;\n\t\tbackground-size: 88px;\n\t}\n\t.userinfo .the_name {\n\t\tfont-size: 19px;\n\t}\n\t.userinfo .post_tag {\n\t\tfont-size: 17px;\n\t}\n}\n\n@media(min-width: 1330px) {\n\tnav.nav {\n\t\twidth: calc(85% - 200px)\n\t}\n\tul {\n\t\tmargin-left: 205px;\n\t}\n\t.right_of_nav {\n\t\twidth: calc(15% + 200px);\n\t}\n\t.user_box {\n\t\twidth: 200px;\n\t}\n}"
  },
  {
    "path": "themes/nox/public/misc.js",
    "content": "\"use strict\";\n\nfunction noxMenuBind() {\n\t$(\".more_menu\").remove();\n\t$(\"#main_menu li:not(.menu_hamburger\").removeClass(\"menu_hide\");\n\n\tlet mWidth = $(\"#main_menu\").width();\n\tlet iWidth = 0;\n\tlet lastElem = null;\n\t$(\"#main_menu > li:not(.menu_hamburger)\").each(function(){\n\t\tiWidth += $(this).outerWidth();\n\t\tif(iWidth > (mWidth - 100) && (mWidth - 100) > 0) {\n\t\t\tthis.classList.add(\"menu_hide\");\n\t\t\tif(lastElem!==null) lastElem.classList.add(\"menu_hide\");\n\t\t}\n\t\tlastElem = this;\n\t});\n\tif(iWidth > (mWidth - 100) && (mWidth - 100) > 0) $(\".menu_hamburger\").show();\n\telse $(\".menu_hamburger\").hide();\n\n\tlet div = document.createElement('div');\n\tdiv.className = \"more_menu\";\n\t$(\"#main_menu > li:not(.menu_hamburger):not(#menu_overview)\").each(function(){\n\t\tif(!this.classList.contains(\"menu_hide\")) return;\n\t\tlet cop = this.cloneNode(true);\n\t\tcop.classList.remove(\"menu_hide\");\n\t\tdiv.appendChild(cop);\n\t});\n\tdocument.getElementsByClassName(\"menu_hamburger\")[0].appendChild(div);\n}\n\n(() => {\n\tif(window.location.pathname.startsWith(\"/panel/\")) {\n\t\taddInitHook(\"pre_global\", () => noAlerts = true);\n\t}\n\t\n\tfunction moveAlerts() {\n\t\t// Move the alerts above the first header\n\t\tlet cSel = $(\".colstack_right .colstack_head:first\");\n\t\tlet cSelAlt = $(\".colstack_right .colstack_item:first\");\n\t\tlet cSelAltAlt = $(\".colstack_right .coldyn_block:first\");\n\t\tif(cSel.length > 0) $('.alert').insertBefore(cSel);\n\t\telse if (cSelAlt.length > 0) $('.alert').insertBefore(cSelAlt);\n\t\telse if (cSelAltAlt.length > 0) $('.alert').insertBefore(cSelAltAlt);\n\t\telse $('.alert').insertAfter(\".rowhead:first\");\n\t}\n\t\n\taddInitHook(\"after_update_alert_list\", count => {\n\t\tlog(\"misc.js\");\n\t\tlog(\"count\",count);\n\t\tif(count==0) {\n\t\t\t$(\".alerts\").html(phraseBox[\"alerts\"][\"alerts.no_alerts_short\"]);\n\t\t\t$(\".user_box\").removeClass(\"has_alerts\");\n\t\t} else {\n\t\t\t// TODO: Localise this\n\t\t\t$(\".alerts\").html(count+\" new alerts\");\n\t\t\t$(\".user_box\").addClass(\"has_alerts\");\n\t\t}\n\t});\n\taddHook(\"open_edit\", () => $('.topic_block').addClass(\"edithead\"));\n\taddHook(\"close_edit\", () => $('.topic_block').removeClass(\"edithead\"));\n\n\taddInitHook(\"end_init\", () => {\n\t\t$(\".alerts\").click(ev => {\n\t\t\tev.stopPropagation();\n\t\t\tlet alerts = $(\".menu_alerts\")[0];\n\t\t\tif($(alerts).hasClass(\"selectedAlert\")) return;\n\t\t\tif(!conn) loadAlerts(alerts);\n\t\t\talerts.className += \" selectedAlert\";\n\t\t\tdocument.getElementById(\"back\").className += \" alertActive\"\n\t\t});\n\n\t\t$(window).resize(() => noxMenuBind());\n\t\tnoxMenuBind();\n\t\tmoveAlerts();\n\n\t\t$(\".menu_hamburger\").click(function() {\n\t\t\tevent.stopPropagation();\n\t\t\tlet mm = document.getElementsByClassName(\"more_menu\")[0];\n\t\t\tmm.classList.add(\"more_menu_selected\");\n\t\t\tlet calc = $(this).offset().left - (mm.offsetWidth / 4);\n\t\t\tmm.style.left = calc+\"px\";\n\t\t});\n\n\t\t$(document).click(() => $(\".more_menu\").removeClass(\"more_menu_selected\"));\n\t});\n\n\taddInitHook(\"after_notice\", moveAlerts);\n})();"
  },
  {
    "path": "themes/nox/public/panel.css",
    "content": "#back {\n\tpadding: 0px;\n}\n#back, .footer .widget, #poweredByHolder {\n\tborder: none;\n}\n.left_of_nav, .nav, .right_of_nav, .sidebar {\n\tdisplay: none;\n}\n.footer .widget, #poweredByHolder {\n\tbackground-color: #393939;\n}\n.submenu a:before {\n\tcontent: \"-\";\n\tmargin-right: 8px;\n}\n\n{{template \"acc_panel_common.css\" }}\n.colstack_left .colstack_head {\n\tfont-size: 19px;\n\t/*padding-top: 10px;\n\tpadding-bottom: 10px;*/\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n}\n.menu_stats {\n\tmargin-left: 4px;\n}\n.above_right {\n\tbackground-color: rgb(62, 62, 62);\n\tmargin-top: -12px;\n\tmargin-left: -24px;\n\tmargin-right: -24px;\n\tdisplay: flex;\n}\n.above_right .left_bit {\n\tpadding-left: 20px;\n\tmargin-top: 16px;\n\tfont-size: 18px;\n}\n.above_right .left_bit a {\n\tcolor: #bbbbbb;\n}\n.above_right .right_bit {\n\tmargin-left: auto;\n\tdisplay: flex;\n\tbackground-color: rgb(72, 72, 72);\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\tpadding-right: 22px;\n\tpadding-left: 20px;\n}\n.above_right img {\n\tborder-radius: 24px;\n}\n.above_right span {\n\tmargin-left: 12px;\n\tmargin-top: 5px;\n\tcolor: rgb(180, 180, 180);\n}\n\n.colstack_right {\n\tbackground-color: #333333;\n\twidth: 90%;\n\tpadding-top: 12px;\n\tpadding-right: 24px;\n\tpadding-bottom: 24px;\n\tpadding-left: 24px;\n}\n.colstack_right .colstack_head {\n\tmargin-bottom: 5px;\n}\n.colstack_right .colstack_head + .colstack_head:not(:first-child) {\n\tmargin-top: 5px;\n}\n.colstack_right .colstack_head h1 {\n\tfont-size: 21px;\n}\n.colstack_right .colstack_head h1 + h2.hguide {\n\tmargin-left: auto;\n\tfont-size: 17px;\n}\n.colstack_right .colstack_item.the_form, .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem {\n\tbackground-color: #444444;\n}\n.colstack_right .colstack_head.colstack_sub_head:not(:first-child) {\n\tmargin-top: 12px;\n}\n.colstack_head + .colstack_head.colstack_sub_head:not(:first-child) {\n\tmargin-top: 2px;\n}\n.alert {\n\tmargin-top: 18px;\n}\n.rowitem, .formitem.avataritem {\n\tdisplay: flex;\n}\n.formitem.avataritem {\n\tflex-direction: column;\n}\n.avataritem .avatarbuttons {\n\tmargin-top: 7px;\n\tmargin-bottom: 3px;\n}\n\n.colstack_grid {\n\tdisplay: grid;\n\tgrid-gap: 8px;\n\tgrid-template-columns: repeat(3, 1fr);\n}\n.rowlist.bgavatars, .micro_grid {\n\tgrid-template-columns: repeat(auto-fit, minmax(120px, 1fr));\n}\n.grid_item {\n\tborder-radius: 3px;\n\tcolor: rgb(190,190,190);\n\tbackground-color: rgb(68,68,68);\n\tpadding: 12px;\n}\n.grid_item a {\n\tcolor: rgb(195,195,195);\n}\n.stat_green {\n\tbackground-color: rgb(68,88,68);\n}\n.stat_orange {\n\tbackground-color: rgb(88,78,68);\n}\n.stat_red {\n\tbackground-color: rgb(88,68,68);\n}\n.grid2 {\n\tmargin-top: 12px;\n}\n\n.panel_buttons, .panel_floater {\n\tmargin-left: auto;\n}\n\n.colstack_right input, .colstack_right select, .colstack_right textarea, .formitem img {\n\tpadding: 4px;\n\tpadding-bottom: 3px;\n\tpadding-left: 6px;\n\tpadding-right: 6px;\n}\n\n#panel_users .rowitem {\n\tpadding-top: 20px;\n\tpadding-left: 4px;\n\tpadding-right: 4px;\n\tpadding-bottom: 18px;\n}\nbutton, .formbutton, .panel_right_button:not(.has_inner_button), #panel_users .profile_url {\n\tbackground: rgb(100,100,200);\n}\n#panel_users .panel_tag:not(.panel_right_button) {\n\tbackground: rgb(50,150,50);\n}\n.panel_right_button:not(.has_inner_button),\n.panel_right_button button,\n#panel_users .panel_tag:not(.panel_right_button),\n#panel_users .profile_url {\n\tmargin-left: 2px;\n\tpadding: 5px;\n\tpadding-left: 6px;\n\tpadding-right: 6px;\n}\n#panel_users .panel_tag:not(.panel_right_button), #panel_users .profile_url {\n\tcolor: rgb(250,250,250);\n    font-size: 15px;\n    text-align: center;\n\tborder-radius: 3px;\n}\n.edit_button:after {\n\tcontent: \"{{lang \"panel_edit_button_text\" . }}\";\n}\n.delete_button:after {\n\tcontent: \"{{lang \"panel_delete_button_text\" . }}\";\n}\n/*#themeSelector select {\n\tbackground: rgb(90,90,90);\n    color: rgb(200,200,200);\n}*/\n\nselect + .timeRangeSelector {\n\tmargin-left: 8px;\n}\n\n.colstack_graph_holder {\n\tbackground-color: #444444;\n\tborder-radius: 3px;\n\tpadding: 16px;\n\tpadding-bottom: 0px;\n\tpadding-left: 0px;\n\tpadding-right: 0px;\n\tmargin-bottom: 10px;\n}\n.colstack_graph_holder.scrolly {\n\toverflow-x: scroll;\n\twidth: 800px;\n}\n.colstack_graph_holder.scrolly .ct_chart {\n\twidth: 1000px;\n}\n.colstack_graph_holder .ct-label {\n\tcolor: rgb(195,195,195);\n\tfont-size: 13px;\n\twhite-space: nowrap;\n}\n.colstack_graph_holder .ct-horizontal {\n\tmargin-top: 3px;\n}\n.colstack_graph_holder .ct-grid {\n\tstroke: rgb(125,125,125);\n}\n.ct-legend {\n\tmargin-left: 0px;\n}\n.ct-series-e .ct-bar, .ct-series-e .ct-line, .ct-series-e .ct-point, .ct-series-e .ct-slice-donut {\n    stroke: #c73eaf !important;\n}\n.ct-series-e.ct-point {\n\tstroke: #c73eaf !important;\n}\n.ct-series-e.ct-point:hover {\n\tstroke: #c73eaf !important;\n}\n.ct-legend .ct-series-4:before {\n\tbackground-color: #c73eaf !important;\n\tborder-color: /*#ed4cd0*/#c73eaf !important;\n}\n\n/*.ct-series-f .ct-bar, .ct-series-f .ct-line, .ct-series-f .ct-point, .ct-series-f .ct-slice-donut {\n    stroke: darkred !important;\n}\n.ct-series-f.ct-point {\n\tstroke: darkred !important;\n}\n.ct-series-f.ct-point:hover {\n\tstroke: darkred !important;\n}\n.ct-legend .ct-series-5:before {\n\tbackground-color: darkred !important;\n\tborder-color: darkred !important;\n}*/\n.ct-legend .ct-series-7:before {\n\tbackground-color: #6b0392 !important;\n\tborder-color: #6b0392 !important;\n}\n\n#panel_setting .formlabel {\n\tdisplay: none;\n}/*\n#panel_setting textarea {\n\twidth: 100%;\n\theight: 80px;\n}\n\n.micro_grid .to_right, .micro_grid .panel_buttons {\n\tmargin-left: 0px;\n}\n#panel_settings .panel_upshift {\n\tmargin-bottom: 12px;\n}\n#panel_settings .to_right {\n\twhite-space: nowrap;\n\tmargin-top: auto;\n\tpadding-top: 10px;\n\tbackground-color: #555555;\n\tborder-radius: 5px;\n\tpadding-left: 5px;\n\tpadding: 12px;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n#panel_settings.rowlist.bgavatars.micro_grid, .micro_grid {\n\tgrid-gap: 24px;\n    grid-row-gap: 16px;\n\tgrid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n}\n\n#panel_word_filters {\n\tgrid-template-columns: repeat(auto-fit, minmax(120px, 1fr));\n}\n#panel_word_filters .filters_find {\n\tmargin-bottom: 1px;\n}\n#panel_word_filters .itemSeparator:before {\n\tcontent: \"{{lang \"panel_word_filters_to\" . }}\";\n\tfont-size: 17px;\n\tmargin-bottom: 1px;\n}\n#panel_word_filters .panel_buttons {\n\tmargin-top: 14px;\n}\n\n#panel_users .rowitem .to_right {\n\torder: 0;\n\tmargin-right: auto;\n}\n#panel_users .rowitem .profile_url {\n\torder: 1;\n}\n#panel_users .rowitem .panel_floater {\n\torder: 2;\n\tmargin-top: 8px;\n\tmargin-right: auto;\n}\n.panel_group_promotions .formitem {\n\tdisplay: flex;\n}\n\n.perm_preset_no_access:before {\n\tcontent: \"{{lang \"panel_perms_no_access\" . }}\";\n\t/*color: hsl(0,100%,20%);*/\n}\n/*.perm_preset_read_only:before, .perm_preset_can_post:before {\n\tcolor: hsl(120,100%,20%);\n}*/\n.perm_preset_read_only:before {\n\tcontent: \"{{lang \"panel_perms_read_only\" . }}\";\n}\n.perm_preset_can_post:before {\n\tcontent: \"{{lang \"panel_perms_can_post\" . }}\";\n}\n.perm_preset_can_moderate:before {\n\tcontent: \"{{lang \"panel_perms_can_moderate\" . }}\";\n\t/*color: hsl(240,100%,20%);*/\n}\n.perm_preset_quasi_mod:before {\n\tcontent: \"{{lang \"panel_perms_quasi_mod\" . }}\";\n}\n.perm_preset_custom:before {\n\tcontent: \"{{lang \"panel_perms_custom\" . }}\";\n\t/*color: hsl(0,0%,20%);*/\n}\n.perm_preset_default:before {\n\tcontent: \"{{lang \"panel_perms_default\" . }}\";\n}\n\n.panel_submitrow {\n\tmargin-top: 8px;\n}\n.colstack_right .colstack_item:not(.colstack_head):not(.rowhead).panel_submitrow .rowitem {\n\tpadding-bottom: 14px;\n}\n.panel_submitrow .rowitem button:first-child {\n\tmargin-left: auto;\n}\n.panel_submitrow .rowitem button:last-child {\n\tmargin-right: auto;\n}\n\n/*.has_inner_button button {\n\tmargin-right: 8px;\n}*/\n#forum_quick_perms .formitem, #forum_quick_perms .panel_floater {\n\tdisplay: flex;\n}\n#forum_quick_perms .edit_fields {\n\tmargin-left: 4px;\n}\n\nspan.grip {\n\tcontent: '....';\n\twidth: 20px;\n\tdisplay: inline-block;\n\toverflow: hidden;\n\tline-height: 5px;\n\tpadding: 3px 4px;\n\tcursor: move;\n\tvertical-align: middle;\n\tmargin-top: -16px;\n\tmargin-right: 12px;\n\tfont-size: 12px;\n\tfont-family: sans-serif;\n\tletter-spacing: -3px;\n\tcolor: #888888;\n\ttext-shadow: 1px 0 1px black;\n\tmargin-left: -12px;\n\theight: 100%;\n\tfont-size: 40px;\n\tmargin-bottom: -4px;\n\tline-height: 8px;\n}\nspan.grip::after {\n\tcontent: '... ... ... ... ... ... ...';\n}\n.forum_no_desc span.grip, .panel_menu_item span.grip {\n\theight: 40px;\n}\n\n.panel_plugin_meta {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.panel_plugin_meta br {\n\tdisplay: none;\n}\n.panel_plugin_meta small {\n\tmargin-left: 0px !important;\n\tmargin-top: 1px;\n}\n/* TODO: Switch out this hack for vertically aligning the buttons */\n/* margin-top: 10px; */\n#panel_plugins .to_right {\n\tdisplay: flex;\n}\n#panel_plugins .to_right .panel_right_button {\n\tmargin-top: auto;\n\tmargin-bottom: auto;\n}\n\n.widget_normal {\n\tdisplay: flex;\n\twidth: 100%;\n}\n.bg_red.in_edit.widget_item {\n\tbackground-color: #444444 !important;\n}\n.widget_item .form_button_row .rowitem {\n\tdisplay: flex;\n}\n.widget_edit .form_button_row .formitem a {\n\tdisplay: inline;\n}\n.colstack_right .colstack_item.the_form, .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem.widget_new {\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n}\n#widgetTmpl, .widget_disabled {\n\tdisplay: none;\n}\n.bg_red .widget_disabled {\n\tdisplay: inline;\n}\n.wtypes .formrow {\n\tdisplay: none;\n}\n.wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default {\n\tdisplay: block;\n}\n.wtext, .rwtext {\n\twidth: 100%;\n\theight: 80px;\n}\n\n#panel_reglogs .panel_compactrow {\n\tflex-direction: column;\n}\n.logdetail {\n\tdisplay: flex;\n\twidth: 100%;\n\tmargin-top: 3px;\n}\n#panel_reglogs .logdetail small, #panel_reglogs .logdetails span {\n\tfont-size: 14px;\n}\n\n#panel_debug .grid_stat:not(.grid_stat_head) {\n\tmargin-bottom: 5px;\n}\n\n@media (max-width: 1000px) {\n\t#panel_settings.rowlist.bgavatars.micro_grid, .micro_grid {\n\t\tgrid-gap: 12px;\n\t\tgrid-row-gap: 4px;\n\t\tgrid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n\t}\n}"
  },
  {
    "path": "themes/nox/public/profile.css",
    "content": "#main {\n\tmax-width: none !important;\n}\n#profile_left_lane {\n\tmargin-right: 24px;\n}\n.avatarRow {\n\tdisplay: flex;\n\twidth: 100%;\n}\n.avatar, .nameRow span, .passiveBlock .passive {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.avatar {\n\twidth: 64px;\n\theight: 64px;\n\tborder-radius: 32px;\n}\n.avatarRow a {\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.avatarRow .avatar {\n\tdisplay: block;\n}\n.nameRow {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n.profileName {\n\tfont-size: 21px;\n}\n.topBlock, .levelBlock, .passiveBlock {\n\tbackground-color: #444444;\n\tborder-radius: 3px;\n\twidth: 180px;\n\tpadding: 16px;\n}\n.levelBlock, .passiveBlock {\n\tmargin-top: 12px;\n\tpadding: 12px;\n}\n.levelBlock {\n\tpadding: 0px;\n}\n\n.colstack_right .colstack_head:not(:first-child) {\n\tmargin-top: 0px;\n}\n#profile_right_lane {\n\twidth: 100%;\n}\n#profile_comments .rowitem .topRow {\n\tdisplay: flex;\n\twidth: 100%;\n}\n#profile_comments .rowitem .userbit {\n\tdisplay: flex;\n}\n#profile_comments .rowitem .topRow .nameAndTitle {\n\tdisplay: flex;\n\tflex-direction: column;\n\tmargin-left: 8px;\n}\n.nameAndTitle .real_username {\n\tfont-size: 17px;\n\tline-height: 16px;\n}\n.userbit > a {\n\theight: 40px;\n}\n.userbit img {\n\twidth: 40px;\n\theight: 40px;\n\tborder-radius: 24px;\n}\n.controls {\n\tmargin-left: auto;\n}\n.controls a {\n\tmargin-right: 8px;\n}\n.content_column {\n\tmargin-top: 5px;\n}\n.topic_reply_form {\n\tmargin-top: 8px;\n\tpadding: 12px;\n}\n.input_content {\n\twidth: 100%;\n\theight: 100px;\n\tresize: vertical;\n}\n\n.footer .widget, .sidebar {\n\tdisplay: none;\n}\n\n@media(max-width: 500px) {\n\t.colstack {\n\t\tdisplay: block;\n\t}\n\t#profile_left_lane {\n\t\tmargin-right: 0px;\n\t\tmargin-bottom: 12px;\n\t}\n\t.topBlock, .levelBlock, .passiveBlock {\n\t\twidth: auto;\n\t}\n}"
  },
  {
    "path": "themes/nox/theme.json",
    "content": "{\n\t\"Name\": \"nox\",\n\t\"FriendlyName\": \"Nox\",\n\t\"Version\": \"0.0.1\",\n\t\"Creator\": \"Azareal\",\n\t\"URL\": \"github.com/Azareal/Gosora\",\n\t\"Tag\": \"WIP\",\n\t\"Docks\":[\"topMenu\",\"rightSidebar\",\"footer\"],\n\t\"GridLists\": true,\n\t\"MapTmplToDock\": {\n\t\t\"rightOfNav\": {\n\t\t\t\"File\": \"./templates/userDock.html\"\n\t\t}\n\t},\n\t\"Templates\": [\n\t\t{\n\t\t\t\"Name\": \"topic\",\n\t\t\t\"Source\": \"topic_alt\"\n\t\t},\n\t\t{\n\t\t\t\"Name\": \"topic_mini\",\n\t\t\t\"Source\": \"topic_alt_mini\"\n\t\t}\n\t],\n\t\"Resources\": [\n\t\t{\n\t\t\t\"Name\":\"trumbowyg/trumbowyg.min.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Loggedin\":true\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"trumbowyg/ui/trumbowyg.custom.css\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Loggedin\":true\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"nox/misc.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Async\":true\n\t\t}\n\t]\n}"
  },
  {
    "path": "themes/shadow/DEVELOPERS.md",
    "content": "# Theme Notes\n\n/public/post-avatar-bg.jpg is a solid rgb(71,71,71)\n\n"
  },
  {
    "path": "themes/shadow/overrides/login.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"login_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"login_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/login/submit/\" method=\"post\">\n\t\t\t<div class=\"formrow login_name_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_name_label\">{{lang \"login_account_name\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"username\" type=\"text\" placeholder=\"{{lang \"login_account_name\"}}\" aria-labelledby=\"login_name_label\" required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_password_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_password_label\">{{lang \"login_account_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\" type=\"password\" autocomplete=\"current-password\" placeholder=\"*****\" aria-labelledby=\"login_password_label\" required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\" class=\"formbutton\">{{lang \"login_submit_button\"}}</button></div>\n\t\t\t\t<div class=\"fall_opts\">\n\t\t\t\t\t<div class=\"formitem dont_have_account\">\n\t\t\t\t\t\t<a href=\"/accounts/create/\">{{lang \"login_no_account\"}}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"formitem forgot_password\">\n\t\t\t\t\t\t<a href=\"/accounts/password-reset/\">{{lang \"login_forgot_password\"}}</a>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "themes/shadow/public/account.css",
    "content": "#account_dashboard .colstack_right .coldyn_block {\n\tdisplay: flex;\n}\n#dash_left {\n\tpadding: 18px;\n\tpadding-right: 0px;\n    padding-top: 11px;\n    padding-left: 0px;\n\twidth: 260px;\n\tposition: relative;\n}\n#dash_left .rowitem {\n\tmargin-top: 0px;\n}\n#dash_username {\n\tdisplay: flex;\n}\n#dash_username button {\n\tmargin-left: 6px;\n}\n#dash_left .rowitem img {\n\twidth: 100%;\n\tmargin-top: 8px;\n\tmargin-bottom: 4px;\n\tmargin-left: 0px;\n\tmargin-right: 12px;\n}\n#dash_avatar_buttons {\n\tdisplay: flex;\n}\n#dash_avatar_buttons button {\n\tmargin-left: 8px;\n}\n#dash_right {\n\twidth: 100%;\n\tpadding: 16px;\n\tpadding-top: 3px;\n    padding-left: 8px;\n    padding-right: 0px;\n}\n\n.account_soon, .dash_security {\n\tfont-size: 13px;\n\tcolor: rgba(255, 80, 80, 1);\n}\n.rowmenu .account_soon, .rowmenu .dash_security {\n\tfont-size: 11px;\n}\n\n.validated_email {\n\tcolor: rgb(0, 170, 0);\n}\n.invalid_email {\n\tcolor: crimson;\n}"
  },
  {
    "path": "themes/shadow/public/convo.css",
    "content": ".convos_item_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.parti {\n\tmargin-bottom: 8px;\n}\n.parti .rowitem {\n\tdisplay: flex;\n}\n.parti_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.convo_row_box .rowitem {\n\tbackground-repeat: no-repeat, repeat-y;\n\tbackground-size: 128px;\n\tpadding-left: 136px;\n}"
  },
  {
    "path": "themes/shadow/public/main.css",
    "content": "/* Patch for Edge, until they fix emojis in arial x.x */\n@supports (-ms-ime-align:auto) { .user_content { font-family: Segoe UI Emoji, arial; } }\n\n:root {\n\t--main-block-color: rgb(61,61,61);\n\t--main-text-color: white;\n\t--dim-text-color: rgb(205,205,205);\n\t--main-background-color: #222222;\n\t--inner-background-color: #333333;\n\t--input-background-color: #444444;\n\t--input-border-color: #555555;\n\t--input-text-color: #999999;\n\t--bright-input-background-color: #555555;\n\t--bright-input-border-color: #666666;\n\t--input-text-color: #a3a3a3;\n}\n\nbody {\n\tfont-family: arial;\n\tcolor: var(--main-text-color);\n\tbackground-color: var(--main-background-color);\n\tmargin: 0;\n}\n*::selection {\n\tbackground-color: hsl(0,0%,75%);\n\tcolor: hsl(0,0%,20%);\n\tfont-weight: 100;\n}\n\n#back {\n\tmargin-left: auto;\n\tmargin-right: auto;\n\twidth: 70%;\n\tbackground-color: var(--inner-background-color);\n\tposition: relative;\n\ttop: -2px;\n}\n#main {\n\tpadding-bottom: 5px;\n}\n\n#main_menu {\n\tlist-style-type: none;\n\tbackground-color: var(--main-block-color);\n\tborder-bottom: 1px solid var(--main-background-color);\n\tpadding-left: 15%;\n\tpadding-right: 15%;\n\tmargin: 0;\n\theight: 41px;\n}\n\n.menu_left, .menu_right li {\n\tfloat: left;\n\theight: 29.5px;\n\tpadding-top: 12px;\n\tmargin: 0;\n}\n.menu_left {\n\tmargin-right: 10px;\n}\n.menu_right {\n\tfloat: right;\n}\n\n#main_menu #menu_overview {\n\tmargin-right: 13px;\n\tmargin-left: 10px;\n\tfont-size: 16px;\n}\n\n#main_menu .menu_left:not(#menu_overview) {\n\tfont-size: 15px;\n\tpadding-top: 13px;\n}\n\n.alert_bell {\n\tfloat: right;\n}\n.menu_alerts {\n\tfloat: right;\n\tpadding-top: 14px;\n}\n.alert_counter {\n\tbackground-color: rgb(200,0,0);\n\tborder-radius: 2px;\n\tfont-size: 11px;\n\tpadding: 3px;\n\tfloat: right;\n\tposition: relative;\n\ttop: -1px;\n}\n.alert_aftercounter {\n\tfloat: right;\n\tmargin-right: 4px;\n\tfont-size: 14px;\n}\n.alert_aftercounter:before {\n\tcontent: \"{{lang \"menu_alerts\" . }}\";\n}\n\n.menu_alerts .alertList, .hide_on_big, .show_on_mobile {\n\tdisplay: none;\n}\n.auto_hide {\n\tdisplay: none !important;\n}\n.selectedAlert .alertList {\n\tdisplay: block;\n\tposition: absolute;\n\ttop: 44px;\n\tfloat: left;\n\twidth: 200px;\n\tz-index: 50;\n\tright: 15%;\n\tfont-size: 13px;\n\tbackground-color: var(--inner-background-color);\n}\n\n.alertItem {\n\tmargin-bottom: 2px;\n}\n.alertItem.withAvatar {\n\theight: 40px;\n\tbackground-size: 48px;\n\tbackground-repeat: no-repeat;\n\tbackground-color: var(--main-block-color);\n\tpadding-left: 56px;\n\tpadding-top: 8px;\n}\n\na {\n\ttext-decoration: none;\n\tcolor: var(--main-text-color);\n}\n\n.alertbox {\n\tdisplay: flex;\n}\n.alert {\n\tpadding-bottom: 12px;\n\tbackground-color: var(--main-block-color);\n\tborder-left: 4px solid hsl(21, 100%, 50%);\n\tpadding: 12px;\n\tdisplay: block;\n\tmargin-top: 8px;\n\tmargin-bottom: -3px;\n\tmargin-left: 8px;\n\tmargin-right: 8px;\n\twidth: 100%;\n}\n\n.rowblock {\n\tmargin-left: 8px;\n\tmargin-right: 8px;\n}\n\n.opthead, .rowhead, .colstack_head {\n\tpadding-bottom: 0px;\n\tpadding-top: 3px !important;\n\twhite-space: nowrap;\n}\n\n.rowblock:not(.opthead):not(.colstack_head):not(.rowhead) .rowitem {\n\tfont-size: 15px; /*16px*/\n}\n.rowblock:last-child, .colstack_item:last-child {\n\tpadding-bottom: 10px;\n}\n\n.rowitem, .formitem {\n\tpadding-bottom: 12px;\n\tbackground-color: var(--main-block-color);\n\tmargin-top: 8px;\n\tpadding: 12px;\n}\n.rowitem h1, .rowitem h2 {\n\tfont-size: 16px;\n\tdisplay: inline;\n}\nh1, h2, h3, h4, h5 {\n\t-webkit-margin-before:0;\n\t-webkit-margin-after:0;\n\tmargin-block-start:0;\n\tmargin-block-end:0;\n\tmargin-top:0;\n\tmargin-bottom:0;\n\tfont-weight: normal;\n}\n.rowsmall {\n\tfont-size: 12px;\n}\n\n.colstack {\n\tdisplay: flex;\n}\n.colstack_left, .colstack_right {\n\tmargin-left: 8px;\n}\n.colstack_left {\n\tfloat: left;\n\twidth: 30%;\n}\n.colstack_right {\n\tfloat: left;\n\twidth: calc(70% - 24px);\n}\n.colstack_left:empty,\n.colstack_right:empty,\n.show_on_edit:not(.edit_opened),\n.hide_on_edit.edit_opened,\n.show_on_block_edit:not(.edit_opened),\n.hide_on_block_edit.edit_opened,\n.link_select:not(.link_opened) {\n\tdisplay: none;\n}\n\n.colline {\n\tfont-size: 14px;\n\tbackground-color: var(--main-block-color);\n\tmargin-top: 5px;\n\tpadding: 10px;\n}\n\n/* Align to right in a flex head */\n.to_left {\n\tfloat: left;\n}\n.to_right {\n\tfloat: right;\n\tmargin-left: auto;\n}\n\n/* Topic View */\n\n/* TODO: How should we handle the sticky headers? */\n.topic_sticky_head {}\n\n/* TODO: Add the avatars to the forum list */\n.forum_list .forum_nodesc {\n\tfont-style: italic;\n}\n.extra_little_row_avatar {\n\tdisplay: none;\n}\n.shift_left {\n\tfloat: left;\n}\n.shift_right {\n\tfloat: right;\n}\n\n.action_item .action_icon {\n\tfont-size: 18px;\n\tpadding-right: 5px;\n}\n\n/* TODO: Rewrite the closed topic header so that it looks more consistent with the rest of the theme */\n.topic_closed_head .topic_status_closed {\n\tmargin-bottom: -10px;\n\tfont-size: 19px;\n}\n\n.post_item {\n\tbackground-size: 128px;\n\tpadding-left: calc(128px + 12px);\n}\n.user_content {\n\tword-break: break-word;\n}\n.user_content h2 {\n\tfont-size: 18px;\n}\n.user_content h2, .user_content h3 {\n\tmargin-bottom: 12px;\n\tdisplay: block;\n}\n.user_content h4 {\n\tmargin-bottom: 8px;\n\tdisplay: block;\n}\n.user_content strong h2, .user_content strong h3, .user_content strong h4 {\n\tfont-weight: bold;\n}\nred {\n\tcolor: red;\n}\n.update_buttons .add_file_button {\n\tdisplay: none;\n}\n\n.controls {\n\twidth: 100%;\n\tdisplay: inline-block;\n\tmargin-top: 20px;\n}\n\n.staff_post {\n\tborder: 1px solid rgb(101, 71, 101)\n}\n\n.user_tag {\n\tfloat: right;\n\tcolor: var(--dim-text-color);\n}\n.real_username {\n\tfloat: left;\n\tmargin-right: 7px;\n}\n\n.mod_button {\n\tmargin-right: 5px;\n\tdisplay: block;\n\tfloat: left;\n}\n.mod_button button {\n\tborder: none;\n\tbackground: none;\n\tcolor: var(--main-text-color);\n\tfont-size: 12px;\n\tpadding: 0;\n}\n\n.like_label:before {\n\tcontent: \"{{lang \"topic.plus_one\" . }}\";\n}{{$out := .}}\n{{range (toArr \"quote\" \"edit\" \"delete\" \"pin\" \"lock\" \"unlock\" \"unpin\" \"ip\" \"flag\")}}\n.{{.}}_label:before {\n\tcontent: \"{{lang (concat \"topic.\" . \"_button_text\") ($out) }}\";\n}{{end}}\n\n.like_count_label, .like_count {\n\tdisplay: none;\n}\n.like_count_label:before {\n\tcontent: \"{{lang \"topics_likes_suffix\" . }}\";\n}\n.has_likes .like_count_label, .has_likes .like_count {\n\tfont-size: 12px;\n\tdisplay: block;\n\tfloat: left;\n\tline-height: 19px;\n}\n.has_likes .like_count {\n\tmargin-right: 2px;\n}\n.like_count:before {\n\tcontent: \"{{lang \"pipe\" . }}\";\n\tmargin-right: 5px;\n}\n\n.level_label, .level {\n\tcolor: var(--dim-text-color);\n\tfloat: right;\n}\n.level_label:before {\n\tcontent: \"{{lang \"topic.level_tooltip\" . }}\";\n}\n.level {\n\tmargin-left: 3px;\n}\n\n.hide_spoil {\n\tbackground-color: grey;\n\tcolor: grey;\n}\n.hide_spoil img {\n\tborder: 0;\n\tclip: rect(0 0 0 0);\n\theight: 1px;\n\tmargin: -1px;\n\toverflow: hidden;\n\tpadding: 50px;\n\twhite-space: nowrap;\n\twidth: 1px;\n\tbackground-color: grey;\n}\n.hide_spoil img {\n\tcontent: \"   \";\n}\n.attach_box {\n\tbackground-color: #5a5555;\n\tbackground-color: rgb(71,71,76);\n\tborder-radius: 3px;\n\tpadding: 16px;\n\toverflow-wrap: break-word;\n}\n\n.formrow.real_first_child, .formrow:first-child {\n\tmargin-top: 8px;\n}\n.formrow.real_first_child .formitem, .formrow:first-child .formitem {\n\tpadding-top: 12px;\n}\n.formrow:last-child .formitem {\n\tpadding-bottom: 12px;\n}\n\n.login_button_row {\n\tdisplay: flex;\n}\n.login_button_row .formitem > * {\n\tpadding-top: 5px;\n}\n.fall_opts {\n\tdisplay: flex;\n}\n.dont_have_account {\n\tmargin-left: auto;\n\tpadding-right: 0px;\n}\n.dont_have_account:after {\n\tcontent: \"|\";\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n}\n.forgot_password {\n\tpadding-left: 0px;\n}\n.formitem.dont_have_account, .formitem.forgot_password {\n\tcolor: #909090;\n\tfont-size: 12px;\n\tfont-weight: normal;\n\tpadding-top: 11px;\n}\n\ntextarea {\n\tbackground-color: var(--input-background-color);\n\tborder-color: var(--input-border-color);\n\tcolor: var(--input-text-color);\n\twidth: calc(100% - 15px);\n\tmin-height: 80px;\n}\ntextarea:focus, input:focus, select:focus, button:focus {\n\toutline-color: rgb(95,95,95);\n}\ntextarea.large {\n\tmin-height: 120px;\n\tmargin-top: 1px;\n\tpadding: 5px;\n\tdisplay: block;\n}\n\n.formitem button, .formbutton, .mod_floater_submit, .pane_buttons button {\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tpadding: 7px;\n\tpadding-bottom: 6px;\n\tfont-size: 13px;\n}\n.mod_floater_submit {\n\tpadding: 5px;\n\tpadding-bottom: 4px;\n\tmargin-left: 2px;\n}\n.pane_buttons button {\n\tpadding: 5px;\n\tpadding-bottom: 4px;\n}\n\n.formrow {\n\tflex-direction: row;\n\tdisplay: flex;\n}\n\n.formitem {\n\tmargin-top: 0px;\n\tpadding-bottom: 2px;\n\tpadding-top: 3px;\n\tflex-grow: 2;\n}\n\n.formlabel {\n\tflex-grow: 0;\n\twidth: 20%;\n\tpadding-top: 9px;\n}\n\n/* If the form label is on the right */\n.formlabel:not(:first-child) {\n\tfont-size: 15px;\n\tflex-grow: 2;\n}\n\n.formrow.real_first_child .formlabel, .formrow:first-child .formlabel {\n\tpadding-top: 17px;\n}\n\n/* Too big compared to the other items in the Control Panel and Account Panel */\n/*.colstack_item .formrow.real_first_child, .colstack_item .formrow:first-child {\n\tmargin-top: 8px;\n}*/\n.colstack_item .formrow.real_first_child, .colstack_item .formrow:first-child {\n\tmargin-top: 3px;\n}\n\n.thin_margins .formrow.real_first_child, .thin_margins .formrow:first-child {\n\tmargin-top: 5px;\n}\n\n.formitem a {\n\tfont-size: 14px;\n}\n.rowmenu .rowitem, .rowlist .rowitem, .rowlist .formitem {\n\tmargin-top: 3px;\n\tfont-size: 13px;\n\tpadding: 10px;\n}\n.menu_stats {\n\tfont-size: 12px;\n}\n\n/* Mini paginators aka panel paginators */\n.pageset {\n\tmargin-top: 4px;\n\tdisplay: flex;\n\tflex-direction: row;\n\tmargin-left: 8px;\n\tmargin-bottom: 8px;\n}\n.pageitem {\n\tbackground-color: var(--main-block-color);\n\tpadding: 10px;\n\tmargin-right: 4px;\n\tfont-size: 13px;\n}\n\n.bgsub {\n\tdisplay: none;\n}\n.rowlist.bgavatars .rowitem {\n\tbackground-repeat: no-repeat;\n\tbackground-size: 40px;\n\tpadding-left: 46px;\n}\n.bgavatars:not(.rowlist) .rowitem {\n\tbackground-repeat: no-repeat;\n\tbackground-size: 40px;\n\tpadding-left: 46px;\n}\n.rowlist .formrow, .rowlist .formrow:first-child {\n\tmargin-top: 0px;\n}\n.loglist .to_left small {\n\tmargin-left: 2px;\n\tfont-size: 12px;\n}\n.loglist .to_right span {\n\tfont-size: 14px;\n}\n\ninput {\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tpadding-bottom: 6px;\n\tfont-size: 13px;\n\n\tpadding: 5px;\n \twidth: calc(100% - 16px);\n}\nselect {\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tfont-size: 13px;\n\tpadding: 4px;\n}\n.rowlist .formitem select {\n\tpadding: 2px;\n\tfont-size: 11px;\n\tmargin-top: -5px;\n}\n\ninput, select, textarea {\n\tcaret-color: rgb(95,95,95);\n}\n\n.form_middle_button {\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tdisplay: block;\n\tmargin-top: 5px;\n}\n\n.little_row_avatar {\n\tdisplay: none;\n}\n.topic_create_form .topic_board_row .formitem, .topic_create_form .topic_name_row .formitem {\n\tpadding-bottom: 5px;\n}\n.topic_create_form input, .topic_create_form select {\n\tpadding: 7px;\n\tfont-size: 13px;\n}\n.topic_create_form select {\n\tpadding: 6px;\n}\n.topic_create_form input {\n\twidth: calc(100% - 14px);\n}\n.topic_create_form textarea, .topic_reply_form textarea {\n\twidth: calc(100% - 26px);\n\tmin-height: 80px;\n\tfont-family: arial;\n\tfont-size: 14px;\n\tpadding: 12px;\n}\n.topic_create_form textarea {\n\tpadding: 7px;\n\twidth: calc(100% - 14px);\n}\n\n.quick_button_row .formitem, .quick_create_form .upload_file_dock {\n\tdisplay: flex;\n}\n.quick_create_form .add_file_button, .quick_create_form #add_poll_button {\n\tmargin-left: 8px;\n}\n.quick_create_form .close_form {\n\tmargin-left: auto;\n}\n.quick_create_form .uploadItem {\n\tdisplay: inline-block;\n\tmargin-left: 8px;\n\tbackground-size: 25px 30px;\n\tbackground-repeat: no-repeat;\n\tpadding-left: 30px;\n}\n\n.footBlock {\n\tmargin-top: -2px;\n\tdisplay: flex;\n}\n.footer {\n\twidth: 70%;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n.elapsed {\n\tdisplay: none;\n}\n#poweredByHolder {\n\tbackground-color: var(--main-block-color);\n\tpadding: 10px;\n\tfont-size: 14px;\n\tpadding-left: 13px;\n\tpadding-right: 13px;\n\tclear: left;\n\theight: 25px;\n}\n#poweredByHolder select {\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tfont-size: 13px;\n\tpadding: 4px;\n}\n#poweredBy {\n\tfloat: left;\n\tmargin-top: 4px;\n}\n#poweredBy span {\n\tfont-size: 12px;\n}\n#themeSelector {\n\tfloat: right;\n}\n\n.poll_item {\n\tdisplay: flex;\n}\n.poll_option {\n\tmargin-bottom: 3px;\n}\ninput[type=checkbox] {\n\tdisplay: none;\n}\ninput[type=checkbox] + label {\n\tdisplay: inline-block;\n\twidth: 12px;\n\theight: 12px;\n\tmargin-bottom: -2px;\n\tborder: 1px solid var(--bright-input-border-color);\n\tbackground-color: var(--bright-input-background-color);\n}\ninput[type=checkbox]:checked + label .sel {\n\tdisplay: inline-block;\n\twidth: 5px;\n\theight: 5px;\n\tbackground-color: var(--bright-input-background-color);\n}\ninput[type=checkbox] + label.poll_option_label {\n\twidth: 14px;\n\theight: 14px;\n\tmargin-right: 3px;\n\tbackground-color: var(--bright-input-background-color);\n\tborder: 1px solid var(--bright-input-border-color);\n\tcolor: var(--bright-input-text-color);\n}\ninput[type=checkbox]:checked + label.poll_option_label .sel {\n\tdisplay: inline-block;\n\twidth: 10px;\n\theight: 10px;\n\tmargin-left: 3px;\n\tbackground: var(--bright-input-border-color);\n}\n.pollinput {\n\tdisplay: flex;\n\tmargin-bottom: 8px;\n}\n.quick_create_form  .pollinputlabel {\n\tdisplay: none;\n}\n\n/*#poll_option_text_0 {\n\tcolor: hsl(359,98%,43%);\n}*/\n.poll_buttons {\n\tmargin-top: 12px;\n}\n.poll_buttons button {\n\tbackground-color: var(--bright-input-background-color);\n\tborder: 1px solid var(--bright-input-border-color);\n\tcolor: var(--bright-input-text-color);\n\tpadding: 7px;\n\tpadding-bottom: 6px;\n\tfont-size: 13px;\n}\n.poll_buttons > *:not(:first-child) {\n\tmargin-left: 5px;\n}\n.poll_results {\n\tmargin-left: auto;\n\tmax-height: 120px;\n}\n\n/* Forum View */\n.rowhead, .opthead, .colstack_head, .rowhead .rowitem {\n\tdisplay: flex;\n\tflex-direction: row;\n}\n.rowhead:not(.has_opt) .rowitem, .opthead .rowitem, .colstack_head .rowitem {\n\twidth: 100%;\n}\n\n.optbox {\n\tdisplay: flex;\n\tpadding-left: 5px;\n\tpadding-top: 10.5px;\n\tmargin-top: 7px;\n\twidth: 100%;\n\tbackground-color: var(--main-block-color);\n}\n.has_opt .rowitem {\n\tmargin-right: 0px;\n\tdisplay: inline-block;\n\tpadding-right: 0px;\n\tmargin-top: 7px;\n\tpadding-left: 12px;\n\tpadding-top: 12px;\n}\n.opt a {\n\tfont-size: 11px;\n}\n\n.topic_list_title_block .pre_opt:before {\n\tcontent: \"{{lang \"topics_click_topics_to_select\" . }}\";\n\tfont-size: 14px;\n}\n.create_topic_opt a:before {\n\tcontent: \"{{lang \"topics_new_topic\" . }}\";\n\tmargin-left: 3px;\n}\n.locked_opt a:before {\n\tcontent: \"{{lang \"forum_locked\" . }}\";\n}\n.mod_opt a {\n\tmargin-left: 4px;\n}\n.mod_opt a:after {\n\tcontent: \"{{lang \"topics_moderate\" . }}\";\n\tpadding-left: 1px;\n}\n.topic_list_title_block .moderate_link.moderate_open:after {\n\tcontent: \"{{lang \"topic_list.cancel_mod\" . }}\";\n}\n.create_topic_opt {\n\torder: 1;\n}\n.mod_opt {\n\torder: 2;\n}\n.pre_opt {\n\torder: 3;\n\tmargin-left: auto;\n\tmargin-right: 12px;\n}\n.filter_opt {\n\tdisplay: none;\n}\n\n@keyframes fadein {\n\tfrom { opacity: 0; }\n\tto { opacity: 1; }\n}\n.mod_floater {\n\tposition: fixed;\n\tbottom: 15px;\n\tright: 15px;\n\twidth: 150px;\n\theight: 65px;\n\tfont-size: 14px;\n\tpadding: 14px;\n\tz-index: 9999;\n\tanimation: fadein 0.8s;\n\tbackground-color: var(--main-block-color);\n}\n.mod_floater_head {\n\tmargin-bottom: 8px;\n}\n.modal_pane {\n\tposition: fixed;\n\tleft: 50%;\n\ttop: 50%;\n\ttransform: translate(-50%, -50%);\n\tbackground-color: var(--main-block-color);\n\tborder: 2px solid #333333;\n\tpadding-left: 24px;\n\tpadding-right: 24px;\n\tz-index: 9999;\n\tanimation: fadein 0.8s;\n}\n.pane_header {\n\tfont-size: 15px;\n}\n.pane_header h3 {\n\t-webkit-margin-before: 0;\n\t-webkit-margin-after: 0;\n\tmargin-block-start: 0;\n\tmargin-block-end: 0;\n\tmargin-top: 10px;\n\tmargin-bottom: 10px;\n\tfont-weight: normal;\n}\n.pane_row {\n\tfont-size: 14px;\n\tmargin-bottom: 1px;\n}\n.pane_selected {\n\tfont-weight: bold;\n}\n.pane_buttons {\n\tmargin-top: 7px;\n\tmargin-bottom: 8px;\n}\n\n.topic_list .topic_row {\n\tdisplay: flex;\n}\n.topics_moderate .topic_row:not(.can_mod) .rowitem {\n\tbackground-color: hsla(0, 0%, 22%, 1);\n}\n.topics_moderate .can_mod .rowitem {\n\tbackground-color: hsla(0, 0%, 25%, 1);\n}\n.topics_moderate .can_mod:hover .rowitem {\n\tbackground-color: hsla(0, 0%, 29%, 1);\n}\n.topic_row.topic_selected .rowitem {\n\tbackground-color: hsla(0, 0%, 31%, 1);\n}\n/* Temporary hack, so that I don't break the topic lists of the other themes */\n.topic_list .topic_inner_right {\n\tdisplay: none;\n}\n.topic_list .rowitem {\n\tfloat: left;\n\toverflow: hidden;\n}\n.topic_list .topic_left {\n\twidth: 100%;\n\theight: 59px;\n\tdisplay: flex;\n\tpadding: 0px;\n\toverflow: hidden;\n}\n.topic_sticky .topic_left .topic_inner_left {\n\tborder-top: 4px solid hsl(41, 100%, 50%);\n\tpadding-left: 10px;\n\tpadding-top: 10px;\n\tmargin-top: 0px;\n\tmargin-left: 0px;\n\twidth: 100%;\n}\n.topic_list .topic_right {\n\theight: 59px;\n\tmargin-left: 8px;\n\tdisplay: flex;\n\twidth: 284px;\n\tpadding: 0px;\n}\n.topic_right_inside {\n\tdisplay: flex;\n}\n.topic_list .topic_left img, .topic_list .topic_right img {\n\twidth: 64px;\n}\n.topic_list .topic_inner_left, .topic_right_inside > span {\n\tmargin-left: 8px;\n\tmargin-top: 12px;\n}\n.topic_right_inside .lastName {\n\tfont-size: 14px;\n}\n.topic_list .topic_row:last-child {\n\tmargin-bottom: 10px;\n}\n.topic_list .lastReplyAt {\n\twhite-space: nowrap;\n}\n.topic_list .lastReplyAt:before {\n\tcontent: \"{{lang \"topics_last\" . }}: \";\n}\n.topic_list .starter:before {\n\tcontent: \"{{lang \"topics_starter\" . }}: \";\n}\n.topic_middle {\n\tdisplay: none;\n}\n\n.more_topic_block_initial {\n\tdisplay: none;\n}\n.more_topic_block_active {\n\tdisplay: block;\n}\n\n.topic_name_input {\n\twidth: 100%;\n\tmargin-right: 10px;\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tpadding-bottom: 6px;\n\tfont-size: 13px;\n\tpadding: 5px;\n}\n.topic_item .submit_edit {\n\tmargin-left: auto;\n}\n.topic_item .topic_status_closed {\n\tmargin-left: auto;\n\tposition: relative;\n\ttop: -5px;\n}\n\n.prev_link, .next_link {\n\tdisplay: none;\n}\n\n.postImage {\n\tmax-width: 100%;\n\tmax-height: 200px;/*300px;*/\n\tbackground-color: rgb(71,71,71);\n\tpadding: 10px;\n}\nvideo {\n\twidth: 100%;\n}\nblockquote {\n\tbackground-color: rgb(71,71,71);\n\tmargin: 0px;\n\tmargin-top: 10px;\n\tpadding: 10px;\n}\nblockquote:first-child {\n\tmargin-top: 0px;\n}\n\n/* Profiles */\n#profile_left_lane {\n\twidth: 220px;\n\tmargin-top: 5px;\n}\n#profile_left_lane .avatarRow {\n\toverflow: hidden;\n\tmax-height: 220px;\n\tpadding: 0;\n}\n#profile_left_lane .avatar {\n\twidth: 100%;\n\tmargin: 0;\n\tdisplay: block;\n}\n#profile_left_lane .username {\n\tfont-size: 14px;\n\tdisplay: block;\n\tmargin-top: 3px;\n}\n#profile_left_pane .nameRow .username {\n\tfloat: right;\n\tfont-weight: normal;\n}\n#profile_left_pane .report_item:after {\n\tcontent: \"{{lang \"topic.report_button_text\" . }}\";\n}\n#profile_left_lane .profileName {\n\tfont-size: 18px;\n}\n#profile_right_lane {\n\twidth: calc(100% - 245px);\n}\n#profile_right_lane .rowitem,\n#profile_right_lane .colstack_item .formrow.real_first_child,\n#profile_right_lane .colstack_item .formrow:first-child {\n\tmargin-top: 5px;\n}\n.simple .user_tag {\n\tfont-size: 14px;\n}\n/* TODO: Have a has_avatar class for profile comments and topic replies to allow posts without avatars? Won't that look inconsistent next to everything else for just about every theme though? */\n#profile_comments .rowitem {\n\tbackground-repeat: no-repeat, repeat-y;\n\tbackground-size: 128px;\n\tpadding-left: 136px;\n}\n\n.ip_search_block .rowitem {\n\tdisplay: flex;\n\tflex-direction: row;\n}\n.ip_search_block input {\n\tbackground-color: var(--input-background-color);\n\tborder: 1px solid var(--input-border-color);\n\tcolor: var(--input-text-color);\n\tmargin-top: -3px;\n\tmargin-bottom: -3px;\n\tpadding: 4px;\n\tpadding-bottom: 3px;\n}\n.ip_search_input {\n\tfont-size: 15px;\n\twidth: 100%;\n\tmargin-left: 0px;\n}\n.ip_search_search {\n\tfont-size: 14px;\n\tmargin-left: 8px;\n}\n\n.level_complete, .level_future, .level_inprogress {\n\tdisplay: flex;\n}\n.progressWrap {\n\tmargin-left: auto;\n\twidth: auto !important;\n}\n\n.colstack_grid {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, 1fr);\n\tmargin-top: 3px;\n\tgrid-gap: 3px;\n\ttext-align: center;\n}\n\n.grid_stat, .grid_istat {\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n\tfont-size: 13px;\n\tbackground-color: var(--main-block-color);\n}\n\n@media(max-width: 935px) {\n\t.simple .user_tag {\n\t\tdisplay: none;\n\t}\n\t#profile_left_lane {\n\t\twidth: 160px;\n\t}\n\t#profile_left_lane .avatarRow {\n\t\tmax-height: 160px;\n\t}\n\t#profile_left_lane .profileName {\n\t\tfont-size: 16px;\n\t}\n\t#profile_right_lane {\n\t\twidth: calc(100% - 185px);\n\t}\n}\n\n@media(max-width: 830px) {\n\t#main_menu {\n\t\tpadding-left: 10px;\n\t\tpadding-right: 0px;\n\t\theight: 35px;\n\t}\n\tli {\n\t\theight: 26px;\n\t}\n\n\t#menu_overview {\n\t\tmargin-right: 9px;\n\t\tmargin-left: 0px;\n\t\tfont-size: 15px;\n\t\twidth: 32px;\n\t\ttext-align: center;\n\t}\n\t.menu_left {\n\t\tmargin-right: 7px;\n\t}\n\t.menu_left:not(#menu_overview) {\n\t\tfont-size: 13px;\n\t\tpadding-top: 10px;\n\t}\n\n\t.menu_alerts {\n\t\tpadding-top: 9px;\n\t\tfloat: left;\n\t\tmargin-right: 6px;\n\t}\n\t.alert_counter {\n\t\tborder-radius: 8px;\n\t\tfont-size: 0px;\n\t\tcolor: #c80000;\n\t\tleft: 2px;\n\t}\n\t.alert_aftercounter {\n\t\tfloat: none;\n\t\tmargin-right: 0px;\n\t\tfont-size: 13px;\n\t\tpadding-top: 1.5px;\n\t}\n\t.has_alerts .alert_aftercounter {\n\t\tposition: relative;\n\t\ttop: -5px;\n\t}\n\t.menu_alerts:not(.has_alerts) .alert_counter {\n\t\tdisplay: none;\n\t}\n\n\t.selectedAlert .alertList {\n\t\tright: 10px;\n\t\ttop: 42px;\n\t\twhite-space: nowrap;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t}\n\t.alertItem.withAvatar {\n\t\theight: 28px;\n\t\tbackground-size: 38px;\n\t\tpadding-left: 46px;\n\t\tpadding-top: 10px;\n\t\toverflow: hidden;\n\t\ttext-overflow: ellipsis;\n\t}\n\n\t#back, .footer {\n\t\twidth: calc(100% - 20px);\n\t}\n\t.opthead, .rowhead, .colstack_head {\n\t\tpadding-top: 0px !important;\n\t\tfont-size: 15px;\n\t}\n\t.rowblock:not(.opthead):not(.colstack_head):not(.rowhead) .rowitem {\n\t\tfont-size: 14px;\n\t}\n\t.rowsmall {\n\t\tfont-size: 11px;\n\t}\n\n\t@media(min-width: 400px) {\n\t\t#main_menu {\n\t\t\theight: 40px;\n\t\t}\n\t\t#menu_overview {\n\t\t\tfont-size: 16px;\n\t\t}\n\t\t.menu_left:not(#menu_overview) {\n\t\t\tfont-size: 14px;\n\t\t\tpadding-top: 13px;\n\t\t}\n\t\t.alert_aftercounter {\n\t\t\tfont-size: 14px;\n\t\t\tpadding-top: 4px;\n\t\t}\n\t}\n}\n\n@media(max-width: 520px) {\n\t.user_tag, .level_label, .level {\n\t\tdisplay: none;\n\t}\n\t#profile_left_lane {\n\t\twidth: 100px;\n\t}\n\t#profile_comments .rowitem {\n\t\tbackground-size: 80px;\n\t\tpadding-left: calc(80px + 12px);\n\t}\n\t#profile_left_lane .avatarRow {\n\t\tmax-height: 100px;\n\t}\n\t#profile_right_lane {\n\t\twidth: calc(100% - 125px);\n\t}\n}\n\n@media(max-width: 500px) {\n\t.topic_list .rowitem {\n\t\tfloat: none;\n\t}\n\t.topic_list .topic_left {\n\t\twidth: 100%;\n\t}\n\t.topic_list .topic_right, #poweredBy span {\n\t\tdisplay: none;\n\t}\n}\n\n@media(max-width: 470px) {\n\t.like_count_label, .like_count {\n\t\tdisplay: none;\n\t}\n\t.post_item {\n\t\tbackground-size: 100px;\n\t\tpadding-left: calc(100px + 12px);\n\t}\n}\n\n@media(max-width: 370px) {\n\t.menu_profile {\n\t\tdisplay: none;\n\t}\n\t.post_item {\n\t\tbackground-size: 80px;\n\t\tpadding-left: calc(80px + 12px);\n\t}\n\t.controls {\n\t\tmargin-top: 14px;\n\t}\n\t#profile_comments .rowitem {\n\t\tbackground-image: none !important;\n\t\tpadding-left: 10px !important;\n\t}\n}\n\n@media(max-width: 324px) {\n\t#main_menu {\n\t\tpadding-left: 5px;\n\t}\n}\n"
  },
  {
    "path": "themes/shadow/public/misc.js",
    "content": "(() => {\naddInitHook(\"end_init\", () => {\n\t// TODO: Run this when the image is loaded rather than when the document is ready?\n\t$(\".topic_list img\").each(function(){\n\t\tlet aspectRatio = this.naturalHeight / this.naturalWidth;\n\t\tlog(\"aspectRatio\",aspectRatio);\n\t\tlog(\"height\",this.naturalHeight);\n\t\tlog(\"width\",this.naturalWidth);\n\n\t\t$(this).css({ height: aspectRatio * this.width });\n\t});\n});\n})()"
  },
  {
    "path": "themes/shadow/public/panel.css",
    "content": ".submenu:before {\n\tcontent: \"-\";\n\tmargin-right: 6px;\n}\n.colstack_head .rowitem {\n\tdisplay: flex;\n}\n.colstack_head .rowitem h1, .colstack_head .rowitem a {\n\tmargin-right: auto;\n}\n.colstack_head .rowitem a h1 {\n\tmargin-right: 0px;\n}\n.rowitem h2.hguide {\n\tfont-size: 15px;\n}\n\n.rowlist .tag-mini {\n\tfont-size: 10px;\n\tmargin-left: 2px;\n}\n\n.analytics .colstack_head:first-child {\n\tpadding-bottom: 4px;\n}\n.analytics .colstack_head select {\n\tpadding: 2px;\n\tmargin-top: -3px;\n\tmargin-bottom: -3px;\n}\n.panel_floater {\n\tmargin-left: auto;\n}\n.panel_right_button {\n\tfloat: right;\n\tmargin-left: 5px;\n}\n\n.edit_button:before {\n\tcontent: \"{{lang \"panel_edit_button_text\" . }}\";\n}\n.delete_button:after {\n\tcontent: \"{{lang \"panel_delete_button_text\" . }}\";\n}\n\n#panel_dashboard_right .colstack_head .rowitem {\n\tpadding: 10px;\n}\n#panel_dashboard_right .colstack_head .rowitem h1, #panel_dashboard_right .colstack_sub_head .rowitem h2 {\n\tfont-size: 15px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n#panel_dashboard_right .colstack_head a, #panel_dashboard_right .colstack_sub_head a {\n\ttext-align: center;\n\twidth: 100%;\n\tdisplay: block;\n\tfont-size: 15px;\n}\n.grid2 {\n\tmargin-top: 6px;\n}\n\n#panel_forums .rowitem {\n\tdisplay: flex;\n}\n\n#panel_users .panel_tag {\n\tfloat: right;\n}\n#panel_users .ban_button {\n\tfont-size: 10px;\n\tfloat: none;\n\tmargin-left: 0px;\n}\n#panel_users .ban_button:before {\n\tcontent: \"|\";\n\tmargin-right: 4px;\n}\n\n#forum_quick_perms .edit_fields {\n\tfloat: right;\n}\n\n.forum_active_name {\n\tcolor: rgb(200,200,200);\n}\n.builtin_forum_divider {\n\tmargin-bottom: 5px;\n}\n\n#panel_settings .panel_compactrow {\n\tpadding-left: 10px;\n}\n#panel_word_filters .itemSeparator:before {\n\tcontent: \" || \";\n\tpadding-left: 2px;\n\tpadding-right: 2px;\n}\n\n#panel_themes .rowitem::after {\n\tcontent: \"\";\n\tdisplay: block;\n\tclear: both;\n}\n\n.panel_submitrow .rowitem {\n\tdisplay: flex;\n}\n.panel_submitrow .rowitem *:first-child {\n\tmargin-left: auto;\n}\n.panel_submitrow .rowitem *:last-child {\n\tmargin-right: auto;\n}\n.panel_submitrow .rowitem button {\n    padding-top: 5px;\n    padding-bottom: 5px;\n}\n\n.colstack_graph_holder {\n\tbackground-color: var(--main-block-color);\n\tpadding: 10px;\n}\n.ct-label {\n\tcolor: var(--input-text-color) !important;\n}\n.ct-chart-line, .ct-grid {\n\tstroke: var(--input-text-color) !important;\n}\n.ct-series-a .ct-bar, .ct-series-a .ct-line, .ct-series-a .ct-point, .ct-series-a .ct-slice-donut {\n    stroke: hsl(359,98%,43%) !important;\n}\n.ct-legend {\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n\n.ct-series-e .ct-bar, .ct-series-e .ct-line, .ct-series-e .ct-point, .ct-series-e .ct-slice-donut {\n    stroke: #c73eaf !important;\n}\n.ct-series-e.ct-point {\n\tstroke: #c73eaf !important;\n}\n.ct-series-e.ct-point:hover {\n\tstroke: #c73eaf !important;\n}\n.ct-legend .ct-series-4:before {\n\tbackground-color: #c73eaf !important;\n\tborder-color: /*#ed4cd0*/#c73eaf !important;\n}\n\n.ct-legend .ct-series-7:before {\n\tbackground-color: #6b0392 !important;\n\tborder-color: #6b0392 !important;\n}\n\nselect + .timeRangeSelector {\n\tmargin-left: 8px;\n}\n\n#widgetTmpl, .widget_disabled {\n\tdisplay: none;\n}\n.bg_red .widget_disabled {\n\tdisplay: inline;\n}\n.wtypes .formrow {\n\tdisplay: none;\n}\n.wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default {\n\tdisplay: block;\n}\n\n.logdetail {\n\tmargin-top: 4px;\n}\n#panel_reglogs .logdetail small, #panel_reglogs .logdetails span {\n\tfont-size: 12px;\n}\n\n.pageset {\n\tmargin-left: 0px;\n\tmargin-bottom: 0px;\n}\n.pageitem {\n\tpadding: 8px;\n}"
  },
  {
    "path": "themes/shadow/public/profile.css",
    "content": ""
  },
  {
    "path": "themes/shadow/theme.json",
    "content": "{\n\t\"Name\": \"shadow\",\n\t\"FriendlyName\": \"Shadow\",\n\t\"Version\": \"0.0.1\",\n\t\"Creator\": \"Azareal\",\n\t\"FullImage\": \"shadow.png\",\n\t\"URL\": \"github.com/Azareal/Gosora\",\n\t\"BgAvatars\":true,\n\t\"Docks\":[\"topMenu\"],\n\t\"Templates\": [\n\t\t{\n\t\t\t\"Name\": \"topic\",\n\t\t\t\"Source\": \"topic\"\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"topic_mini\",\n\t\t\t\"Source\":\"topic_mini\"\n\t\t}\n\t],\n\t\"Resources\": [\n\t\t{\n\t\t\t\"Name\":\"shadow/misc.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Async\":true\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "themes/tempra_simple/DEVELOPERS.md",
    "content": "# Theme Notes\n\n/public/post-avatar-bg.jpg is a solid rgb(255,255,255) white.\n\n"
  },
  {
    "path": "themes/tempra_simple/overrides/login.html",
    "content": "{{template \"header.html\" . }}\n<main id=\"login_page\">\n\t<div class=\"rowblock rowhead\">\n\t\t<div class=\"rowitem\"><h1>{{lang \"login_head\"}}</h1></div>\n\t</div>\n\t<div class=\"rowblock the_form\">\n\t\t<form action=\"/accounts/login/submit/\" method=\"post\">\n\t\t\t<div class=\"formrow login_name_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_name_label\">{{lang \"login_account_name\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"username\"type=\"text\"placeholder=\"{{lang \"login_account_name\"}}\" aria-labelledby=\"login_name_label\" required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_password_row\">\n\t\t\t\t<div class=\"formitem formlabel\"><a id=\"login_password_label\">{{lang \"login_account_password\"}}</a></div>\n\t\t\t\t<div class=\"formitem\"><input name=\"password\"type=\"password\"autocomplete=\"current-password\"placeholder=\"*****\"aria-labelledby=\"login_password_label\" required></div>\n\t\t\t</div>\n\t\t\t<div class=\"formrow login_button_row form_button_row\">\n\t\t\t\t<div class=\"formitem\"><button name=\"login-button\" class=\"formbutton\">{{lang \"login_submit_button\"}}</button></div>\n\t\t\t\t<div class=\"fall_opts\">\n\t\t\t\t\t<div class=\"formitem dont_have_account\">\n\t\t\t\t\t\t<a href=\"/accounts/create/\">{{lang \"login_no_account\"}}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"formitem forgot_password\">\n\t\t\t\t\t\t<a href=\"/accounts/password-reset/\">{{lang \"login_forgot_password\"}}</a>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</form>\n\t</div>\n</main>\n{{template \"footer.html\" . }}"
  },
  {
    "path": "themes/tempra_simple/public/account.css",
    "content": "#back {\n\twidth: 100%;\n}\n.sidebar {\n\tdisplay: none;\n}\n\n#account_dashboard .colstack_right .coldyn_block {\n\tdisplay: flex;\n}\n#dash_left .rowitem {\n\tborder: 1px solid hsl(0,0%,85%);\n}\n#dash_left img {\n\tdisplay: block;\n\theight: 82px;\n\twidth: 82px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tmargin-top: 8px;\n\tmargin-bottom: 8px;\n}\n#dash_username {\n\tdisplay: flex;\n\theight: 26px;\n}\n#dash_username input {\n\tmargin-right: 8px;\n\twidth: 100px;\n\tpadding-left: 8px;\n\tpadding-top: 4px;\n}\n#dash_username .formbutton {\n\tpadding: 5px;\n\tpadding-top: 4px;\n\tfont-size: 14px;\n}\n#dash_avatar_buttons {\n\tdisplay: flex;\n}\n#dash_right {\n\twidth: 100%;\n}\n#dash_right .rowitem {\n\tborder: 1px solid hsl(0,0%,85%);\n\tmargin-left: 8px;\n}\n#dash_right .rowitem:not(:last-child) {\n\tmargin-bottom: 8px;\n}\n.account_soon, .dash_security {\n\tfont-size: 14px;\n\tcolor: maroon;\n}\n\n.validated_email {\n\tcolor: green;\n}\n.invalid_email {\n\tcolor: crimson;\n}"
  },
  {
    "path": "themes/tempra_simple/public/convo.css",
    "content": ".convos_item_user:not(:last-child):after {\n\tcontent: \",\";\n}\n.to_left:after {\n\tclear: both;\n}\n\n/*.parti {\n\tmargin-bottom: 8px;\n}*/\n.parti .rowitem {\n\tdisplay: flex;\n}\n.parti_user:not(:last-child):after {\n\tcontent: \",\";\n}\n\n.convo_row_box .rowitem {\n\tbackground-repeat: no-repeat, repeat-y;\n\tbackground-size: 128px;\n\tpadding-left: 136px;\n}"
  },
  {
    "path": "themes/tempra_simple/public/main.css",
    "content": "* {\n\tbox-sizing: border-box;\n\t-moz-box-sizing: border-box;\n\t-webkit-box-sizing: border-box;\n}\nbody {\n\tfont-family: arial;\n\tpadding-bottom: 8px;\n}\n/* Patch for Edge, until they fix emojis in arial x.x */\n@supports (-ms-ime-align:auto) { .user_content { font-family: Segoe UI Emoji, arial; } }\n\n#main_menu {\n\tpadding-left: 0px;\n\tpadding-right: 0px;\n\theight: 36px;\n\tlist-style-type: none;\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tbackground-color: rgb(252,252,252);\n\tmargin-bottom: 12px;\n}\n.menu_left, .menu_right {\n\theight: 35px;\n\tpadding-left: 10px;\n\tpadding-top: 8px;\n\tpadding-bottom: 8px;\n\tpadding-right: 10px;\n\tbackground: white;\n\tborder-bottom: 1px solid hsl(0, 0%, 80%);\n}\n.menu_left:hover, .menu_right:hover { background: rgb(252,252,252); }\n.menu_left a, .menu_right a {\n\ttext-decoration: none;\n\tcolor: black;\n\tfont-size: 17px;\n}\n.menu_left {\n\tfloat: left;\n\tborder-right: 1px solid hsl(0, 0%, 80%);\n}\n.menu_right {\n\tfloat: right;\n\tborder-left: 1px solid hsl(0, 0%, 80%);\n}\n#menu_overview {\n\tbackground: none;\n\tpadding-right: 13px;\n}\n#menu_overview a {\n\tpadding-left: 3px;\n}\n\n.alert_bell:before {\n\tcontent: '🔔︎';\n}\n.menu_bell {\n\tcursor: default;\n}\n.menu_alerts {\n\t/*padding-left: 7px;*/\n\tfont-size: 20px;\n\tpadding-top: 2px;\n\tcolor: rgb(80,80,80);\n}\n.menu_alerts .alert_counter {\n\tposition: relative;\n\tfont-size: 8px;\n\ttop: -25px;\n\tbackground-color: rgb(190,0,0);\n\tcolor: white;\n\twidth: 14px;\n\tleft: 10px;\n\tline-height: 8px;\n\tpadding-top: 2.5px;\n\theight: 14px;\n\ttext-align: center;\n\tborder: white solid 1px;\n}\n.menu_alerts .alert_counter:empty {\n\tdisplay: none;\n}\n\n.selectedAlert {\n\tbackground: white;\n\tcolor: black;\n}\n.selectedAlert:hover {\n\tbackground: white;\n\tcolor: black;\n}\n.selectedAlert .alert_counter { display: none; }\n.menu_alerts .alertList {\n\tdisplay: none;\n\tz-index: 500;\n}\n\n.selectedAlert .alertList {\n\tposition: absolute;\n\ttop: 51px;\n\tdisplay: block;\n\tbackground: white;\n\tfont-size: 10px;\n\tline-height: 16px;\n\twidth: 300px;\n\tright: calc(5% + 7px);\n\tborder-top: 1px solid hsl(0, 0%, 80%);\n\tborder-left: 1px solid hsl(0, 0%, 80%);\n\tborder-right: 1px solid hsl(0, 0%, 80%);\n\tborder-bottom: 1px solid hsl(0, 0%, 80%);\n\tmargin-bottom: 10px;\n}\n.alertItem {\n\tpadding: 8px;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tpadding-top: 17px;\n\tpadding-bottom: 16px;\n}\n.alertItem.withAvatar {\n\tbackground-size: 60px;\n\tbackground-repeat: no-repeat;\n\tpadding-right: 12px;\n\tpadding-left: 68px;\n\theight: 50px;\n}\n.alertItem.withAvatar:not(:last-child) {\n\tborder-bottom: 1px solid rgb(230,230,230);\n}\n.alertItem.withAvatar .text {\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tfloat: right;\n\theight: 40px;\n\twidth: 100%;\n\twhite-space: nowrap;\n}\n.alertItem .text {\n\tfont-size: 13px;\n\tfont-weight: normal;\n\tmargin-left: 5px;\n}\n\n.container {\n\twidth: 90%;\n\tpadding: 0px;\n\tmargin-left: auto;\n\tmargin-right: auto;\n}\n#back {\n\tdisplay: flex;\n}\n#back, #main {\n\twidth: 100%;\n}\nmain > *:last-child {\n\tmargin-bottom: 12px;\n}\n\n.rowblock {\n\tborder: 1px solid hsl(0, 0%, 80%);\n\twidth: 100%;\n\tpadding: 0px;\n\tpadding-top: 0px;\n}\n.rowblock:empty {\n\tdisplay: none;\n}\n.rowmenu {\n\tborder: 1px solid hsl(0, 0%, 80%);\n}\n.rowmenu > div:not(:last-child) {\n\tborder-bottom: 1px solid hsl(0, 0%, 80%);\n}\n.rowsmall {\n\tfont-size: 12px;\n}\n\n.colstack_left {\n\tfloat: left;\n\twidth: 30%;\n\tmargin-right: 8px;\n}\n.colstack_right {\n\tfloat: left;\n\twidth: 65%;\n\twidth: calc(70% - 15px);\n}\n.colstack_item {\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tpadding: 0px;\n\tpadding-top: 0px;\n\twidth: 100%;\n\tmargin-bottom: 12px;\n\toverflow: hidden;\n\tword-wrap: break-word;\n}\n.colstack_head {\n\tmargin-bottom: 0px;\n}\n.colstack_left:empty, .colstack_right:empty {\n\tdisplay: none;\n}\n\n.colstack_grid {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(3, 1fr);\n\tgrid-gap: 12px;\n\tmargin-left: 5px;\n\tmargin-top: 2px;\n}\n.grid_item {\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tword-wrap: break-word;\n\tbackground-color: white;\n\twidth: 100%;\n\toverflow: hidden;\n}\n.grid_item a {\n\ttext-decoration: none;\n\tcolor: black;\n}\n.grid_stat, .grid_istat {\n\ttext-align: center;\n\tpadding-top: 12px;\n\tpadding-bottom: 12px;\n\tfont-size: 16px;\n}\n/*.grid_istat {\n\tmargin-bottom: 5px;\n}*/\n.stat_green {\n\tbackground-color: lightgreen;\n\tborder-color: lightgreen;\n}\n.stat_orange {\n\tbackground-color: #ffe4b3;\n\tborder-color: #ffe4b3;\n}\n.stat_red {\n\tbackground-color: #ffb2b2;\n\tborder-color: #ffb2b2;\n}\n.stat_disabled {\n\tbackground-color: lightgray;\n\tborder-color: lightgray;\n}\n.grid2 {\n\tmargin-top: 16px;\n}\n\n.rowhead .rowitem, .colstack_head .rowitem {\n\tbackground-color: rgb(252,252,252);\n\tdisplay: flex;\n}\n.rowhead .rowitem select, .colstack_head .rowitem select {\n\tpadding-top: 2px;\n\tpadding-bottom: 2px;\n\tmargin-top: -3px;\n\tmargin-bottom: -2px;\n}\n.rowhead h1, .colstack_head h1,\n.rowhead h2, .colstack_head h2 {\n\tfont-size: 16px;\n\tmargin-left: 4px;\n}\nh1, h2, h3, h4, h5 {\n\t-webkit-margin-before:0;\n\t-webkit-margin-after:0;\n\tmargin-block-start:0;\n\tmargin-block-end:0;\n\tfont-weight: normal;\n}\n\n.rowitem {\n\twidth: 100%;\n\tpadding-left: 10px;\n\tpadding-top: 14px;\n\tpadding-bottom: 12px;\n\tpadding-right: 10px;\n\tbackground-color: white;\n}\n.rowitem:not(:last-child) {\n\tborder-bottom: 1px solid hsl(0,0%,85%);\n}\n.rowitem a {\n\ttext-decoration: none;\n\tcolor: black;\n}\n.rowitem a:hover {\n\tcolor: silver;\n}\n\n.top_post {\n\tmargin-bottom: 12px;\n}\n.opthead {\n\tdisplay: none;\n}\n.topic_list_title_block {\n\tdisplay: flex;\n}\n.has_opt {\n\tborder-bottom: 1px solid hsl(0, 0%, 80%);\n}\n.has_opt .rowitem {\n\tborder-right: 1px solid hsl(0, 0%, 80%);\n\tborder-bottom: none;\n}\n.optbox {\n\tmargin-left: auto;\n}\n.opt {\n\tfont-size: 32px;\n\tbackground-color: white;\n\twidth: 50px;\n\ttext-align: center;\n}\n.create_topic_opt a.create_topic_link:before {\n\tcontent: '🖊︎';\n}\n.create_topic_opt, .create_topic_opt a {\n\tcolor: rgb(120,120,120);\n\ttext-decoration: none;\n}\n.locked_opt {\n\tcolor: rgb(80,80,80);\n}\n.locked_opt:before {\n\tcontent: '🔒︎';\n}\n/*.mod_opt a.moderate_link:before {\n\tcontent: '🔨︎';\n}\n.mod_opt, .mod_opt a {\n\tcolor: rgb(120,120,120);\n\ttext-decoration: none;\n}*/\n.filter_opt {\n\tdisplay: none;\n}\n\n.to_left {\n\tfloat: left;\n}\n.to_right {\n\tmargin-left: auto;\n\tfloat: right;\n}\n\n.rowlist {\n\tfont-size: 15px;\n}\n.datarow, .rowlist .rowitem {\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n}\n.loglist .to_left small {\n\tmargin-left: 2px;\n\tfont-size: 12px;\n}\n.loglist .to_right span {\n\tfont-size: 14px;\n}\n.bgsub {\n\tdisplay: none;\n}\n.bgavatars .rowitem {\n\tbackground-repeat: no-repeat;\n\tbackground-size: 40px;\n\tpadding-left: 46px;\n}\n\n.formrow {\n\twidth: 100%;\n\tbackground-color: white;\n}\n/* Clearfix */\n.formrow:before, .formrow:after {\n\tcontent: \" \";\n\tdisplay: table;\n}\n.formrow:after { clear: both; }\n.formrow:not(:last-child) { border-bottom: 1px dotted hsl(0, 0%, 80%); }\n\n.formitem {\n\tfloat: left;\n\tpadding: 10px;\n\tmin-width: 20%;\n\tfont-weight: normal;\n}\n.formitem:not(:last-child) {\n\tborder-right: 1px dotted hsl(0, 0%, 80%);\n}\n.formitem.invisible_border {\n\tborder: none;\n}\n\ninput, select {\n\tpadding: 3px;\n}\n/* Mostly for textareas */\n.formitem:only-child {\n\twidth: 100%;\n}\n.formitem:only-child select {\n\tpadding: 1px;\n\tmargin-top: -1px;\n\tmargin-bottom: -1px;\n}\n.formitem textarea {\n\twidth: 100%;\n\theight: 100px;\n\toutline-color: #8e8e8e;\n}\n.formitem:has-child() {\n\tmargin: 0 auto;\n\tfloat: none;\n}\n.formitem:not(:only-child).formlabel {\n\tpadding-top: 15px;\n\tpadding-bottom: 12px;\n}\n\n.formbutton, button, input[type=\"submit\"] {\n\tbackground: white;\n\tborder: 1px solid #8e8e8e;\n}\n.formbutton {\n\tpadding: 7px;\n\tdisplay: block;\n\tmargin-left: auto;\n\tmargin-right: auto;\n\tfont-size: 15px;\n}\n.formbutton, ip_search_search {\n\tborder-color: hsl(0, 0%, 80%);\n}\n\n.fall_opts {\n\tfloat: right;\n\tdisplay: flex;\n}\n.dont_have_account, .forgot_password {\n\tcolor: #505050;\n\tfont-size: 14px;\n\tmargin-top: 6px;\n\tborder-right: none !important;\n}\n.dont_have_account:after {\n\tcontent: \"|\";\n\tmargin-left: 5px;\n\tmargin-right: 5px;\n}\n.dont_have_account {\n\tpadding-right: 0px;\n}\n.forgot_password {\n\tpadding-left: 0px;\n}\n\n.ip_search_block {\n\tborder-bottom: none;\n}\n.ip_search_block .rowitem {\n\tdisplay: flex;\n}\n.ip_search_input {\n\twidth: 100%;\n}\n.ip_search_search {\n\tmargin-left: 10px;\n}\n\n/* TODO: Add the avatars to the forum list */\n.forum_list .forum_nodesc {\n\tfont-style: italic;\n}\n.extra_little_row_avatar {\n\tdisplay: none;\n}\n.shift_left {\n\tfloat: left;\n}\n.shift_right {\n\tfloat: right;\n}\n\n/* Topics */\n\n.topic_list {\n\tborder-bottom: none;\n}\n.topic_list .topic_row {\n\tdisplay: grid;\n\tgrid-template-columns: calc(100% - 204px) 204px;\n}\n.topic_list .rowitem {\n\tborder-bottom: 1px solid hsl(0,0%,85%);\n}\n.topic_list .topic_inner_right {\n\tdisplay: none;\n}\n.topic_list .lastReplyAt {\n\twhite-space: nowrap;\n}\n.topic_list .lastReplyAt:before {\n\tcontent: \"{{lang \"topics_last\" . }}: \";\n}\n.topic_list .starter:before {\n\tcontent: \"{{lang \"topics_starter\" . }}: \";\n}\n\n@supports not (display: grid) {\n\t.topic_list .rowitem {\n\t\tfloat: left;\n\t\toverflow: hidden;\n\t}\n\t.topic_list .topic_left {\n\t\twidth: calc(100% - 204px);\n\t}\n\t.topic_list .topic_right {\n\t\twidth: 204px;\n\t}\n}\n\n.topic_left, .topic_right {\n\tdisplay: flex;\n\tpadding: 0px;\n\theight: 58px;\n\toverflow: hidden;\n}\n.topic_right_inside {\n\tdisplay: flex;\n}\n.topic_left img, .topic_right_inside img {\n\twidth: 64px;\n\theight: auto;\n}\n.topic_left .topic_inner_left, .topic_right_inside > span {\n\tmargin-top: 10px;\n\tmargin-left: 8px;\n}\n.topic_right_inside .lastName {\n\tfont-size: 14px;\n}\n.topic_middle {\n\tdisplay: none;\n}\n\n.more_topic_block_initial {\n\tdisplay: none;\n}\n.more_topic_block_active {\n\tdisplay: block;\n}\n\n.postImage {\n\tmax-width: 100%;\n\tmax-height: 200px;\n\tbackground-color: white;\n\tpadding: 10px;\n}\nvideo {\n\twidth: 100%;\n}\n/*blockquote {\n\tbackground-color: #EEEEEE;\n\tpadding: 12px;\n\tmargin: 0px;\n}\n.staff_post blockquote {\n\tbackground-color: rgba(255, 214, 255, 1);\n}*/\n\n.little_row_avatar {\n\tdisplay: none;\n}\n.quick_create_form .quick_button_row .formitem {\n\tdisplay: flex;\n}\n.quick_create_form .formbutton:first-child,\n.quick_create_form .formbutton:not(:first-child) {\n\tmargin-left: 0px;\n\tmargin-right: 5px;\n}\n.quick_create_form .formbutton:last-child {\n\tmargin-left: auto;\n}\n.quick_create_form .upload_file_dock {\n\tdisplay: flex;\n}\n.quick_create_form .uploadItem {\n\tdisplay: inline-block;\n\tmargin-left: 8px;\n\tmargin-right: 8px;\n\tbackground-size: 25px 35px;\n\tbackground-repeat: no-repeat;\n\tpadding-left: 30px;\n}\n\n.username, .panel_tag {\n\ttext-transform: none;\n\tmargin-left: 0px;\n\tpadding-left: 4px;\n\tpadding-right: 4px;\n\tpadding-top: 2px;\n\tpadding-bottom: 2px;\n\tcolor: #505050; /* 80,80,80 */\n\tbackground-color: #FFFFFF;\n\tborder-style: solid;\n\tborder-color: hsl(0, 0%, 80%);\n\tborder-width: 1px;\n\tfont-size: 15px;\n}\n\n.topic_item {\n\tdisplay: flex;\n}\n.topic_status_sticky {\n\tdisplay: none;\n}\n.topic_status_closed {\n\tmargin-left: auto;\n\tmargin-top: -5px;\n\tfont-size: 0.90em;\n\tmargin-bottom: -2px;\n}\n.topic_sticky .topic_left, .topic_sticky .topic_right {\n\tbackground-color: rgb(255,255,234);\n}\n.topic_closed .topic_left, .topic_closed .topic_right {\n\tbackground-color: rgb(248,248,248);\n}\n.topic_sticky_head {\n\tbackground-color: #FFFFEA;\n}\n.topic_closed_head {\n\tbackground-color: #eaeaea;\n}\n\n.topic_status {\n\ttext-transform: none;\n\tmargin-left: 8px;\n\tpadding-left: 2px;\n\tpadding-right: 2px;\n\tpadding-top: 2px;\n\tpadding-bottom: 2px;\n\tbackground-color: #E8E8E8; /* 232,232,232. All three RGB colours being the same seems to create a shade of gray */\n\tcolor: #505050; /* 80,80,80 */\n\tborder-radius: 2px;\n}\n.topic_status:empty {\n\tdisplay: none;\n}\n\nbutton.username {\n\tposition: relative;\n\ttop: -0.25px;\n}\n.username.level {\n\tcolor: #303030;\n}\n.username.real_username {\n\tcolor: #404040;\n\tfont-size: 16px;\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\tpadding-top: 3px;\n\tpadding-bottom: 3px;\n}\n.username.real_username:hover {\n\tcolor: black;\n}\n.post_item > .username {\n\tmargin-top: 20px;\n\tdisplay: inline-block;\n}\n\n.post_item > .mod_button > button {\n\tfont-size: 15px;\n\tcolor: #202020;\n\topacity: 0.7;\n}\n.post_item > .mod_button > button:hover {\n\topacity: 0.9;\n}\n\n.user_content h2 {\n\tfont-size: 19px;\n}\n.user_content h3 {\n\tfont-size: 18px;\n}\n.user_content h4 {\n\tfont-size: 17px;\n}\n.user_content h2, .user_content h3 {\n\tmargin-bottom: 12px;\n}\n.user_content h4 {\n\tmargin-bottom: 8px;\n}\n.user_content strong h2, .user_content strong h3, .user_content strong h4 {\n\tfont-weight: bold;\n}\nred {\n\tcolor: red;\n}\n\n.user_tag {\n\tfloat: right;\n\tcolor: #505050;\n\tfont-size: 16px;\n}\n.post_item {\n\tbackground-size: 128px;\n\tpadding-left: 136px;\n}\n.staff_post {\n\tbackground-color: #ffeaff;\n}\n.update_buttons .add_file_button {\n\tdisplay: none;\n}\n\n.mod_button {\n\tmargin-right: 4px;\n}\n.like_count_label, .like_count {\n\tdisplay: none;\n}\n.has_likes .like_count_label, .has_likes .like_count {\n\tdisplay: block;\n}\n.like_label:before, .like_count_label:before {\n\tcontent: \"😀\";\n}\n.like_count_label {\n\tcolor: #505050;\n\tfloat: right;\n\topacity: 0.85;\n\tmargin-left: 5px;\n}\n.like_count {\n\tfloat: right;\n\tcolor: #505050;\n\tborder-left: none;\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\tfont-size: 17px;\n}\n\n.quote_label:before {\n\tcontent: \"💬\";\n}\n.edit_label:before {\n\tcontent: \"🖊️\";\n}\n.delete_label:before {\n\tcontent: \"🗑️\";\n}\n.pin_label:before, .unpin_label:before {\n\tcontent: \"📌\";\n}\n.remove_like, .unpin_label, .unlock_label {\n\tbackground-color: #D6FFD6;\n}\n.lock_label:before, .unlock_label:before {\n\tcontent: \"🔒\";\n}\n.ip_label:before {\n\tcontent: \"🔍\";\n}\n.flag_label:before {\n\tcontent: \"🚩\";\n}\n.level_label:before {\n\tcontent: \"👑\";\n}\n.level_label {\n\tcolor: #505050;\n\topacity: 0.85;\n\tfloat: right;\n}\n.level_hideable {\n\tdisplay: none;\n}\n\n.controls {\n\tmargin-top: 23px;\n\tdisplay: inline-block;\n\twidth: 100%;\n}\n.action_item {\n\tpadding: 14px;\n\ttext-align: center;\n\tbackground-color: rgb(255,245,245);\n}\n.action_item .action_icon {\n\tfont-size: 18px;\n\tpadding-right: 5px;\n}\n\n.hide_spoil {\n\tbackground-color: rgb(220,220,220);\n\tcolor: rgb(220,220,220) !important;\n}\n.hide_spoil img {\n\tborder: 0;\n\tclip: rect(0 0 0 0);\n\theight: 1px;\n\tmargin: -1px;\n\toverflow: hidden;\n\tpadding: 50px;\n\twhite-space: nowrap;\n\twidth: 1px;\n\tbackground-color: rgb(220,220,220);\n}\n.hide_spoil img {\n\tcontent: \"   \";\n}\n.staff_post .hide_spoil {\n\tbackground-color: rgb(240,180,240); /*rgb(255, 234, 255)*/\n\tcolor: rgb(240,180,240) !important;\n}\n.staff_post .hide_spoil img {\n\tbackground-color: rgb(240,180,240);\n}\n.attach_box {\n\tborder: 1px solid hsl(10, 0%, 80%);\n\tbackground: white;\n\tpadding: 12px;\n\tmargin: 0px;\n\tdisplay: inline-block;\n\twidth: 100%;\n\tmargin-top: 8px;\n\tmargin-bottom: 8px;\n\toverflow-wrap: break-word;\n}\n.attach_box:first-child {\n\tmargin-top: 0px;\n}\n\nblockquote {\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tbackground: white;\n\tpadding: 5px;\n\tmargin: 0px;\n\tdisplay: inline-block;\n\twidth: 100%;\n\tmargin-top: 8px;\n\tmargin-bottom: 8px;\n}\nblockquote:first-child {\n\tmargin-top: 0px;\n}\n.level {\n\tfloat: right;\n\tcolor: #505050;\n\tborder-left: none;\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\tfont-size: 17px;\n}\n.mention {\n\tfont-weight: bold;\n}\n.show_on_edit:not(.edit_opened),\n.hide_on_edit.edit_opened,\n.show_on_block_edit:not(.edit_opened),\n.hide_on_block_edit.edit_opened,\n.auto_hide,\n.hide_on_big,\n.show_on_mobile,\n.link_select:not(.link_opened) {\n\tdisplay: none;\n}\n\ninput[type=checkbox] {\n\tdisplay: none;\n}\ninput[type=checkbox] + label {\n\tdisplay: inline-block;\n\twidth: 12px;\n\theight: 12px;\n\tmargin-bottom: -2px;\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tbackground-color: white;\n}\ninput[type=checkbox]:checked + label .sel {\n\tdisplay: inline-block;\n\twidth: 5px;\n\theight: 5px;\n\tbackground-color: white;\n}\ninput[type=checkbox] + label.poll_option_label {\n\twidth: 18px;\n\theight: 18px;\n\tmargin-right: 2px;\n\tbackground-color: white;\n\tborder: 1px solid hsl(0, 0%, 70%);\n\tcolor: #505050;\n}\ninput[type=checkbox]:checked + label.poll_option_label .sel {\n\tdisplay: inline-block;\n\twidth: 10px;\n\theight: 10px;\n\tmargin-left: 3px;\n\tbackground: hsl(0,0%,70%);\n}\n.poll_option {\n\tmargin-bottom: 1px;\n}\n.poll_item {\n\tdisplay: flex;\n\tpadding-left: 8px;\n\tbackground: none !important;\n}\n.poll_buttons button {\n\tmargin-top: 8px;\n\tpadding: 5px;\n\tpadding-top: 3px;\n\tpadding-bottom: 3px;\n\tborder: 1px solid hsl(0, 0%, 70%);\n}\n.poll_buttons > *:not(:first-child) {\n\tmargin-left: 5px;\n}\n.poll_results {\n\tmargin-left: auto;\n}\n\n.quick_create_form  .pollinputlabel {\n\tdisplay: none;\n}\n\n/* TODO: Can we just set .alert on the alert_success and .alert_error ones? */\n.alert, .alert_success, .alert_error {\n\tdisplay: block;\n\tpadding: 5px;\n\tmargin-bottom: 10px;\n}\n.alert {\n\tborder: 1px solid hsl(0, 0%, 80%);\n}\n.alert_success {\n\tborder: 1px solid #A2FC00;\n\tbackground-color: #DAF7A6;\n}\n.alert_error {\n\tborder: 1px solid #FF004B;\n\tmargin-bottom: 8px;\n\tbackground-color: #FEB7CC;\n}\n.prev_button, .next_button {\n\tposition: fixed;\n\ttop: 50%;\n\tfont-size: 30px;\n\tborder-width: 1px;\n\tbackground-color: #FFFFFF;\n\tborder: 1px solid hsl(0,0%,80%);\n\tpadding: 0px;\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\tz-index: 100;\n}\n\n.prev_button a, .next_button a {\n\tline-height: 28px;\n\tmargin-top: 2px;\n\tmargin-bottom: 0px;\n\tdisplay: block;\n\ttext-decoration: none;\n\tcolor: #505050;\n\tpadding: 2px;\n}\n.prev_button {\n\tleft: 14px;\n}\n.next_button {\n\tright: 14px;\n}\n.head_tag_upshift {\n\tfloat: right;\n\tposition: relative;\n\ttop: -2px;\n}\n\n.elapsed {\n\tdisplay: none;\n}\n#poweredByHolder {\n\tborder: 1px solid hsl(0, 0%, 80%);\n\tmargin-top: 12px;\n\tclear: both;\n\theight: 40px;\n\tpadding: 6px;\n\tpadding-left: 10px;\n\tpadding-right: 10px;\n}\n#poweredByHolder select {\n\tpadding: 2px;\n\tmargin-top: 1px;\n}\n#poweredBy {\n\tfloat: left;\n\tmargin-top: 4px;\n}\n#poweredBy span {\n\tfont-size: 12px;\n}\n#poweredByName {\n\tcolor: black;\n\ttext-decoration: none;\n}\n#themeSelector {\n\tfloat: right;\n}\n\n.sidebar .rowhead:not(:first-child) {\n\tmargin-top: 12px;\n}\n.widget_search {\n\tmargin-bottom: 8px;\n}\n\n#profile_comments .rowitem {\n\tbackground-repeat: no-repeat, repeat-y;\n\tbackground-size: 128px;\n\tpadding-left: 136px;\n}\n\n/* Profiles */\n#profile_left_lane {\n\twidth: 220px;\n}\n#profile_left_pane {\n\tmargin-bottom: 12px;\n}\n#profile_left_lane .avatarRow {\n\toverflow: hidden;\n\tmax-height: 220px;\n\tpadding: 0;\n}\n#profile_left_lane .avatar {\n\twidth: 100%;\n\tmargin: 0;\n\tdisplay: block;\n}\n#profile_left_lane .username {\n\tfont-size: 14px;\n\tdisplay: block;\n\tmargin-top: 3px;\n}\n#profile_left_pane .nameRow .username {\n\tfloat: right;\n\tfont-weight: normal;\n}\n#profile_left_lane .profileName {\n\tfont-size: 18px;\n}\n#profile_left_lane .report_item:after {\n\tcontent: \"{{lang \"topic.report_button_text\" . }}\";\n}\n#profile_right_lane {\n\twidth: calc(100% - 245px);\n}\n#profile_comments {\n\toverflow: hidden;\n\tborder-top: none;\n\tmargin-bottom: 0;\n}\n.simple .user_tag {\n\tfont-size: 14px;\n}\n\n.pageset {\n\tdisplay: flex;\n\t/*margin-bottom: 10px;*/\n\tmargin-top: 8px;\n\tmargin-bottom: 2px;\n}\n.pageitem {\n\tbackground-color: white;\n\tpadding: 5px;\n\tmargin-right: 5px;\n\tpadding-bottom: 4px;\n\tborder: 1px solid hsl(0, 0%, 80%);\n}\n.pageitem a {\n\tcolor: black;\n\ttext-decoration: none;\n}\n.colstack_right .pageset {\n\tmargin-top: -5px;\n}\n\n.level_complete, .level_future, .level_inprogress {\n\tdisplay: flex;\n}\n#profile_left_pane .level_hideable, .levelBit .level_hideable {\n\tdisplay: inline;\n}\n.progressWrap {\n\tmargin-left: auto;\n\twidth: auto !important;\n}\n\n{{template \"media.partial.css\" }}\n"
  },
  {
    "path": "themes/tempra_simple/public/media.partial.css",
    "content": "@media(min-width: 881px) {\n\t.shrink_main {\n\t\tfloat: left;\n\t\t/*width: calc(75% - 12px);*/\n\t}\n\t.sidebar {\n\t\tfloat: left;\n\t\twidth: 25%;\n\t\tmargin-left: 12px;\n\t}\n}\n\n@media (max-width: 880px) {\n\tli {\n\t\theight: 29px;\n\t\tfont-size: 15px;\n\t\tpadding-left: 9px;\n\t\tpadding-top: 6px;\n\t\tpadding-bottom: 6px;\n\t}\n\tul {\n\t\theight: 30px;\n\t\tmargin-top: 14px;\n\t}\n\t.menu_left, .menu_right { padding-right: 9px; }\n\t.menu_alerts {\n\t\tpadding-left: 7px;\n\t\tpadding-right: 7px;\n\t\tfont-size: 18px;\n\t}\n\n\tbody {\n\t\tpadding-left: 12px;\n\t\tpadding-right: 12px;\n\t\tmargin: 0px !important;\n\t\twidth: 100% !important;\n\t\theight: 100% !important;\n\t\toverflow-x: hidden;\n\t}\n\t.container { width: auto; }\n\t.sidebar { display: none; }\n\t.selectedAlert .alertList { top: 37px; right: 4px; }\n}\n\n@media (max-width: 680px) {\n\tli {\n\t\tpadding-left: 5px;\n\t\tpadding-top: 3px;\n\t\tpadding-bottom: 2px;\n\t\theight: 25px;\n\t}\n\tli a { font-size: 14px; }\n\tul { height: 26px; }\n\t.menu_left, .menu_right { padding-right: 7px; }\n\n\t.menu_alerts {\n\t\tpadding-left: 4px;\n\t\tpadding-right: 4px;\n\t\tfont-size: 16px;\n\t\tpadding-top: 1px;\n\t}\n\t.selectedAlert .alertList { top: 33px; }\n\n\t.hide_on_mobile { display: none !important; }\n\t.prev_button, .next_button { top: auto; bottom: 5px; }\n\t.colstack_grid { grid-template-columns: none; grid-gap: 8px; }\n\t.grid_istat { margin-bottom: 0px; }\n}\n\n@media(max-width: 550px) {\n\t#poweredByDash, #poweredByMaker {\n\t\tdisplay: none;\n\t}\n}\n\n@media (max-width: 500px) {\n\t.topic_list .topic_row {\n\t\tgrid-template-columns: calc(100% - 194px) 194px;\n\t}\n}\n\n@media (max-width: 470px) {\n\t.menu_overview, .menu_profile { display: none; }\n\t.selectedAlert .alertList {\n\t\twidth: 135px;\n\t\tmargin-bottom: 5px;\n\t}\n\t.selectedAlert.hasAvatars .alertList { width: calc(100% - 8px); }\n\t.alertItem.withAvatar {\n\t\tbackground-size: 36px;\n\t\ttext-align: right;\n\t\tpadding-left: 10px;\n\t\theight: 46px;\n\t}\n\t.hasAvatars > .alertList > .alertItem.withAvatar {\n\t\tbackground-size: 46px;\n\t\ttext-align: inherit;\n\t\tpadding-left: 56px;\n\t\theight: 42px;\n\t}\n\t.alertItem { padding: 8px; }\n\t.hasAvatars > .alertList > .alertItem { padding-top: 11px; }\n\t.selectedAlert:not(.hasAvatars) > .alertList > .alertItem.withAvatar .text {\n\t\twidth: calc(100% - 20px);\n\t\theight: 30px;\n\t\twhite-space: normal;\n\t}\n\t.selectedAlert:not(.hasAvatars) > .alertList > .alertItem .text {\n\t\tfont-size: 10px;\n\t\tfont-weight: bold;\n\t\tmargin-left: 0px;\n\t}\n\t.alertActive {\n\t\topacity: 0.7;\n\t}\n\n\t.hide_on_micro { display: none !important; }\n\t.post_container { overflow: visible !important; }\n\t.post_item:not(.action_item) {\n\t\tbackground-position: 0px 2px !important;\n\t\tbackground-size: 64px auto !important;\n\t\tpadding-left: 2px !important;\n\t\tmin-height: 96px;\n\t\tposition: relative !important;\n\t}\n\t.post_item > .user_content {\n\t\tmargin-left: 75px !important;\n\t\twidth: 100% !important;\n\t\tmin-height: 45px;\n\t}\n\t.post_item > .controls > .mod_button {\n\t\tfloat: right !important;\n\t\tmargin-left: 2px !important;\n\t\tmargin-right: 3px;\n\t}\n\t.post_item > .controls > .mod_button > button {\n\t\topacity: 1;\n\t\tpadding-left: 3px;\n\t\tpadding-right: 3px;\n\t}\n\t.post_item > .controls > .real_username {\n\t\tmargin-top: 0px;\n\t\tmargin-right: 0px;\n\t\tfont-size: 15px;\n\t\tcolor: black;\n\t\tmax-width: 61px;\n\t\ttext-overflow: ellipsis;\n\t}\n\t.post_item > .controls {\n\t\tmargin-top: 0px;\n\t\tmargin-left: 74px;\n\t\twidth: calc(100% - 74px);\n\t}\n\t.rowtopic {\n\t\tfont-size: 14px;\n\t}\n\t.container { width: 100% !important; }\n}\n\n@media(max-width: 390px) {\n\t.topic_list .topic_row {\n\t\tdisplay: block;\n\t}\n\t.topic_row .topic_right {\n\t\tdisplay: none;\n\t}\n}\n\n@media (max-width: 330px) {\n\tli { padding-left: 6px; }\n\t.menu_left { padding-right: 6px; }\n\t.post_item > .controls > .real_username {\n\t\tdisplay: inline-block;\n\t\toverflow: hidden;\n\t\tmargin-right: -3px;\n\t\ttext-overflow: clip;\n\t\tmax-width: 84px;\n\t}\n\t.post_item > .controls { margin-left: 72px; }\n\t.top_post > .post_item > .controls > .real_username { max-width: 57px; }\n\t.top_post > .post_item { padding-right: 4px; }\n}\n"
  },
  {
    "path": "themes/tempra_simple/public/misc.js",
    "content": "(() => {\naddInitHook(\"end_init\", () => {\n\t// TODO: Run this when the image is loaded rather than when the document is ready?\n\t$(\".topic_list img\").each(function(){\n\t\tlet aspectRatio = this.naturalHeight / this.naturalWidth;\n\t\tlog(\"aspectRatio\",aspectRatio);\n\t\tlog(\"height\",this.naturalHeight);\n\t\tlog(\"width\",this.naturalWidth);\n\n\t\t$(this).css({ height: aspectRatio * this.width });\n\t});\n});\n})()"
  },
  {
    "path": "themes/tempra_simple/public/panel.css",
    "content": "#back {\n\twidth: 100%;\n}\n.sidebar {\n\tdisplay: none;\n}\n.submenu:before {\n\tcontent: \"-\";\n}\n.submenu a {\n\tmargin-left: 8px;\n}\n/*.colstack_right .colstack_head .rowitem {\n\tdisplay: flex;\n}*/\n.colstack_right .colstack_head h1 + h2.hguide {\n\tmargin-left: auto;\n}\n\n.edit_button:before {\n\tcontent: \"{{lang \"panel_edit_button_text\" . }}\";\n}\n.delete_button:after {\n\tcontent: \"{{lang \"panel_delete_button_text\" . }}\";\n}\n\n.tag-mini {\n\ttext-transform: none;\n\tmargin-left: 0px;\n\tpadding-left: 3px;\n\tpadding-right: 3px;\n\tpadding-top: 1.5px;\n\tpadding-bottom: 0px;\n\tcolor: #505050 !important; /* 80,80,80 */\n\tbackground-color: #FFFFFF;\n\tborder-style: solid;\n\tborder-color: #ccc;\n\tborder-width: 1px;\n\tfont-size: 10px;\n}\n\n.panel_compactrow .panel_tag, .panel_compacttext {\n\tfont-size: 14px;\n}\n.panel_compactrow {\n\tpadding-left: 10px;\n\tpadding-top: 10px;\n\tpadding-bottom: 10px;\n\tpadding-right: 10px;\n}\n\n.panel_upshift {\n\tfont-size: 18px;\n\tposition: relative;\n\ttop: -2px;\n}\n.panel_compactrow .panel_upshift {\n\tfont-size: 16px;\n\tposition: static;\n}\n.panel_upshift:visited {\n\tcolor: black;\n}\n\n.panel_floater, .panel_buttons {\n\tmargin-left: auto;\n\tfloat: right;\n}\n#panel_forums .rowitem {\n\tdisplay: flex;\n}\n#panel_forums .panel_floater {\n\tmargin-left: auto;\n\tmargin-top: -2px;\n}\n#panel_forums .panel_buttons {\n\tmargin-left: 3px;\n}\n#panel_users .panel_floater {\n\tmargin-left: 2px;\n\tfloat: none;\n}\n#panel_users .panel_tag {\n\tfloat: right;\n\tmargin-top: -3px;\n}\n\n.panel_rank_tag_admin:before  { content:\"👑\"; }\n.panel_rank_tag_mod:before    { content:\"👮\"; }\n.panel_rank_tag_banned:before { content:\"⛓️\"; }\n.panel_rank_tag_guest:before  { content:\"👽\"; }\n.panel_rank_tag_member:before { content:\"👪\"; }\n\n.forum_preset_announce:before { content:\"📣\"; }\n.forum_preset_members:before  { content:\"👪\"; }\n.forum_preset_staff:before    { content:\"👮\"; }\n.forum_preset_admins:before   { content:\"👑\"; }\n.forum_preset_archive:before  { content:\"☠️\"; }\n.forum_preset_all, .forum_preset_custom, .forum_preset_ {\n\tdisplay: none !important;\n}\n.forum_active_Hide:before {\n\tcontent: \"🕵️\";\n}\n.forum_active_Show {\n\tdisplay: none !important;\n}\n.forum_active_name {\n\tcolor: #707070;\n}\n.builtin_forum_divider {\n\tborder-bottom-style: solid;\n}\n\n.perm_preset_no_access:before {\n\tcontent: \"{{lang \"panel_perms_no_access\" . }}\";\n\tcolor: maroon;\n}\n.perm_preset_read_only:before, .perm_preset_can_post:before {\n\tcolor: green;\n}\n.perm_preset_read_only:before {\n\tcontent: \"{{lang \"panel_perms_read_only\" . }}\";\n}\n.perm_preset_can_post:before {\n\tcontent: \"{{lang \"panel_perms_can_post\" . }}\";\n}\n.perm_preset_can_moderate:before {\n\tcontent: \"{{lang \"panel_perms_can_moderate\" . }}\";\n\tcolor: darkblue;\n}\n.perm_preset_quasi_mod:before {\n\tcontent: \"{{lang \"panel_perms_quasi_mod\" . }}\";\n\tcolor: darkblue;\n}\n.perm_preset_custom:before {\n\tcontent: \"{{lang \"panel_perms_custom\" . }}\";\n\tcolor: black;\n}\n.perm_preset_default:before {\n\tcontent: \"{{lang \"panel_perms_default\" . }}\";\n}\n\n#panel_dashboard_right .colstack_head {\n\tdisplay: none;\n}\n\n.panel_submitrow {\n\tmargin-top: -12px;\n\tborder-top: none;\n}\n.panel_submitrow button {\n\tpadding-top: 3px;\n\tpadding-bottom: 3px;\n}\n\n#panel_settings .panel_compactrow {\n\tpadding-left: 10px;\n}\n#panel_word_filters .itemSeparator:before {\n\tcontent: \" || \";\n\tpadding-left: 2px;\n\tpadding-right: 2px;\n}\n\n.ct_chart {\n\tpadding-left: 10px;\n    padding-top: 14px;\n    padding-bottom: 4px;\n    padding-right: 10px;\n\tmargin-bottom: 12px;\n\n\tbackground-color: white;\n\tborder: 1px solid hsl(0,0%,85%);\n}\n.ct-label {\n\tfill: rgba(0,0,0,.6) !important;\n\tcolor: rgba(0,0,0,.6) !important;\n}\n.ct-grid {\n\tstroke: rgba(0,0,0,.3) !important;\n}\n.ct-legend {\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n.ct-legend .ct-series-7:before {\n\tbackground-color: #6b0392 !important;\n\tborder-color: #6b0392 !important;\n}\nselect + .timeRangeSelector {\n\tmargin-left: 8px;\n}\n\n#widgetTmpl, .widget_disabled {\n\tdisplay: none;\n}\n.bg_red .widget_disabled {\n\tdisplay: inline;\n}\n.wtypes .formrow {\n\tdisplay: none;\n}\n.wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default {\n\tdisplay: block;\n}\n\n#panel_themes .rowitem::after {\n\tcontent: \"\";\n\tdisplay: block;\n\tclear: both;\n}\n\n.logdetail {\n\tmargin-top: 5px;\n}\n#panel_reglogs .logdetail small, #panel_reglogs .logdetails span {\n\tfont-size: 14px;\n}"
  },
  {
    "path": "themes/tempra_simple/public/profile.css",
    "content": "#back {\n\twidth: 100%;\n}\n.sidebar {\n    display: none;\n}\n.colline {\n\tborder-left: 1px solid hsl(0, 0%, 80%);\n\tpadding: 10px;\n\tborder-right: 1px solid hsl(0, 0%, 80%);\n\tbackground-color: white;\n}"
  },
  {
    "path": "themes/tempra_simple/public/sample.css",
    "content": "/* Sample CSS file injected by Tempra Simple. Doesn't do anything. */\n"
  },
  {
    "path": "themes/tempra_simple/theme.json",
    "content": "{\n\t\"Name\": \"tempra_simple\",\n\t\"FriendlyName\": \"Tempra Simple\",\n\t\"Version\": \"0.1.0-dev\",\n\t\"Creator\": \"Azareal\",\n\t\"FullImage\": \"tempra_simple.png\",\n\t\"MobileFriendly\": true,\n\t\"URL\": \"github.com/Azareal/Gosora\",\n\t\"BgAvatars\":true,\n\t\"Docks\":[\"topMenu\",\"rightSidebar\"],\n\t\"Templates\": [\n\t\t{\n\t\t\t\"Name\":\"topic\",\n\t\t\t\"Source\":\"topic\"\n\t\t},\n\t\t{\n\t\t\t\"Name\":\"topic_mini\",\n\t\t\t\"Source\":\"topic_mini\"\n\t\t}\n\t],\n\t\"Resources\": [\n\t\t{\n\t\t\t\"Name\":\"tempra_simple/misc.js\",\n\t\t\t\"Location\":\"global\",\n\t\t\t\"Async\":true\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tickloop.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"log\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"time\"\n\n\tc \"github.com/Azareal/Gosora/common\"\n\t\"github.com/Azareal/Gosora/routes\"\n\t\"github.com/Azareal/Gosora/uutils\"\n\t\"github.com/pkg/errors\"\n)\n\nvar TickLoop *c.TickLoop\n\nfunc runHook(name string) error {\n\tif e := c.RunTaskHook(name); e != nil {\n\t\treturn errors.Wrap(e, \"Failed at task '\"+name+\"'\")\n\t}\n\treturn nil\n}\n\nfunc deferredDailies() error {\n\tlastDailyStr, e := c.Meta.Get(\"lastDaily\")\n\t// TODO: Report this error back correctly...\n\tif e != nil && e != sql.ErrNoRows {\n\t\treturn e\n\t}\n\tlastDaily, _ := strconv.ParseInt(lastDailyStr, 10, 64)\n\tlow := time.Now().Unix() - (60 * 60 * 24)\n\tif lastDaily < low {\n\t\tif e := c.Dailies(); e != nil {\n\t\t\treturn e\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc handleLogLongTick(name string, cn int64, secs int) {\n\tif !c.Dev.LogLongTick {\n\t\treturn\n\t}\n\tdur := time.Duration(uutils.Nanotime() - cn)\n\tif dur.Seconds() > float64(secs) {\n\t\tlog.Print(\"tick \" + name + \" completed in \" + dur.String())\n\t}\n}\n\nfunc tickLoop(thumbChan chan bool) error {\n\ttl := c.NewTickLoop()\n\tTickLoop = tl\n\tif e := deferredDailies(); e != nil {\n\t\treturn e\n\t}\n\tif e := c.StartupTasks(); e != nil {\n\t\treturn e\n\t}\n\n\tstartTick := func(ch chan bool) (ret bool) {\n\t\tif c.Dev.HourDBTimeout {\n\t\t\tgo func() {\n\t\t\t\tdefer c.EatPanics()\n\t\t\t\tch <- c.StartTick()\n\t\t\t}()\n\t\t\treturn <-ch\n\t\t}\n\t\treturn c.StartTick()\n\t}\n\ttick := func(name string, tasks c.TaskSet, secs int) error {\n\t\ttw := c.NewTickWatch()\n\t\ttw.Name = name\n\t\ttw.Set(&tw.Start, uutils.Nanotime())\n\t\ttw.Run()\n\t\tdefer tw.Stop()\n\t\tch := make(chan bool)\n\t\ttw.OutEndChan = ch\n\t\tif startTick(ch) {\n\t\t\treturn nil\n\t\t}\n\t\ttw.Set(&tw.DBCheck, uutils.Nanotime())\n\t\tif e := runHook(\"before_\" + name + \"_tick\"); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tcn := uutils.Nanotime()\n\t\ttw.Set(&tw.StartHook, cn)\n\t\tif e := tasks.Run(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\ttw.Set(&tw.Tasks, uutils.Nanotime())\n\t\thandleLogLongTick(name, cn, secs)\n\t\tif e := runHook(\"after_\" + name + \"_tick\"); e != nil {\n\t\t\treturn e\n\t\t}\n\t\ttw.Set(&tw.EndHook, uutils.Nanotime())\n\t\t//close(tw.OutEndChan)\n\t\treturn nil\n\t}\n\n\ttl.HalfSecf = func() error {\n\t\treturn tick(\"half_second\", c.Tasks.HalfSec, 2)\n\t}\n\t// TODO: Automatically lock topics, if they're really old, and the associated setting is enabled.\n\t// TODO: Publish scheduled posts.\n\ttl.FifteenMinf = func() error {\n\t\treturn tick(\"fifteen_minute\", c.Tasks.FifteenMin, 5)\n\t}\n\t// TODO: Handle the instance going down a lot better\n\t// TODO: Handle the daily clean-up.\n\ttl.Dayf = func() error {\n\t\tif c.StartTick() {\n\t\t\treturn nil\n\t\t}\n\t\tcn := uutils.Nanotime()\n\t\tif e := c.Dailies(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\thandleLogLongTick(\"day\", cn, 5)\n\t\treturn nil\n\t}\n\n\ttl.Secf = func() (e error) {\n\t\tif c.StartTick() {\n\t\t\treturn nil\n\t\t}\n\t\tif e = runHook(\"before_second_tick\"); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tcn := uutils.Nanotime()\n\t\tgo func() {\n\t\t\tdefer c.EatPanics()\n\t\t\tthumbChan <- true\n\t\t}()\n\n\t\tif e = c.Tasks.Sec.Run(); e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\t// TODO: Stop hard-coding this\n\t\tif e = c.HandleExpiredScheduledGroups(); e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\t// TODO: Handle delayed moderation tasks\n\n\t\t// Sync with the database, if there are any changes\n\t\tif e = c.HandleServerSync(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\thandleLogLongTick(\"second\", cn, 3)\n\n\t\t// TODO: Manage the TopicStore, UserStore, and ForumStore\n\t\t// TODO: Alert the admin, if CPU usage, RAM usage, or the number of posts in the past second are too high\n\t\t// TODO: Clean-up alerts with no unread matches which are over two weeks old. Move this to a 24 hour task?\n\t\t// TODO: Rescan the static files for changes\n\t\treturn runHook(\"after_second_tick\")\n\t}\n\n\ttl.Hourf = func() error {\n\t\tif c.StartTick() {\n\t\t\treturn nil\n\t\t}\n\t\tif e := runHook(\"before_hour_tick\"); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tcn := uutils.Nanotime()\n\n\t\tjsToken, e := c.GenerateSafeString(80)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tc.JSTokenBox.Store(jsToken)\n\n\t\tc.OldSessionSigningKeyBox.Store(c.SessionSigningKeyBox.Load().(string)) // TODO: We probably don't need this type conversion\n\t\tsessionSigningKey, e := c.GenerateSafeString(80)\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\t\tc.SessionSigningKeyBox.Store(sessionSigningKey)\n\n\t\tif e = c.Tasks.Hour.Run(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\tif e = PingLastTopicTick(); e != nil {\n\t\t\treturn e\n\t\t}\n\t\thandleLogLongTick(\"hour\", cn, 5)\n\t\treturn runHook(\"after_hour_tick\")\n\t}\n\n\tc.CTickLoop = tl\n\treturn nil\n}\n\nfunc sched() error {\n\tws := errors.WithStack\n\tschedStr, err := c.Meta.Get(\"sched\")\n\t// TODO: Report this error back correctly...\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn ws(err)\n\t}\n\n\tif schedStr == \"recalc\" {\n\t\tlog.Print(\"Cleaning up orphaned data.\")\n\n\t\tcount, err := c.Recalc.Replies()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Printf(\"Deleted %d orphaned replies.\", count)\n\n\t\tcount, err = c.Recalc.Forums()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Printf(\"Recalculated %d forum topic counts.\", count)\n\n\t\tcount, err = c.Recalc.Subscriptions()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Printf(\"Deleted %d orphaned subscriptions.\", count)\n\n\t\tcount, err = c.Recalc.ActivityStream()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Printf(\"Deleted %d orphaned activity stream items.\", count)\n\n\t\terr = c.Recalc.Users()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Print(\"Recalculated user post stats.\")\n\n\t\tcount, err = c.Recalc.Attachments()\n\t\tif err != nil {\n\t\t\treturn ws(err)\n\t\t}\n\t\tlog.Printf(\"Deleted %d orphaned attachments.\", count)\n\t}\n\n\treturn nil\n}\n\nvar pingLastTopicCount = 1\n\n// TODO: Move somewhere else\nfunc PingLastTopicTick() error {\n\tg, e := c.Groups.Get(c.GuestUser.Group)\n\tif e != nil {\n\t\treturn e\n\t}\n\ttList, _, _, e := c.TopicList.GetListByGroup(g, 1, 0, nil)\n\tif e != nil {\n\t\treturn e\n\t}\n\tif len(tList) == 0 {\n\t\treturn nil\n\t}\n\tw := httptest.NewRecorder()\n\tsid := strconv.Itoa(tList[0].ID)\n\treq := httptest.NewRequest(\"get\", \"/topic/\"+sid, bytes.NewReader(nil))\n\tcn := uutils.Nanotime()\n\n\t// Deal with the session stuff, etc.\n\tucpy, ok := c.PreRoute(w, req)\n\tif !ok {\n\t\treturn errors.New(\"preroute failed\")\n\t}\n\thead, rerr := c.UserCheck(w, req, &ucpy)\n\tif rerr != nil {\n\t\treturn errors.New(rerr.Error())\n\t}\n\trerr = routes.ViewTopic(w, req, &ucpy, head, sid)\n\tif rerr != nil {\n\t\treturn errors.New(rerr.Error())\n\t}\n\t/*if w.Code != 200 {\n\t\treturn errors.New(\"topic code not 200\")\n\t}*/\n\n\tdur := time.Duration(uutils.Nanotime() - cn)\n\tif dur.Seconds() > 5 {\n\t\tc.Log(\"topic \" + sid + \" completed in \" + dur.String())\n\t} else if c.Dev.Log4thLongRoute {\n\t\tpingLastTopicCount++\n\t\tif pingLastTopicCount == 4 {\n\t\t\tc.Log(\"topic \" + sid + \" completed in \" + dur.String())\n\t\t}\n\t\tif pingLastTopicCount >= 4 {\n\t\t\tpingLastTopicCount = 1\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "tmp/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "tmpl_client/stub.go",
    "content": "package tmpl\n\nimport (\n\t//\"reflect\"\n\t//\"runtime\"\n\t//\"unsafe\"\n\t\"github.com/Azareal/Gosora/uutils\"\n)\n\nvar GetFrag = func(name string) [][]byte {\n\treturn nil\n}\n\ntype WriteString interface {\n\tWriteString(s string) (n int, err error)\n}\n\nvar StringToBytes = uutils.StringToBytes\n\n/*\nfunc StringToBytes(s string) (bytes []byte) {\n\tstr := (*reflect.StringHeader)(unsafe.Pointer(&s))\n\tslice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))\n\tslice.Data = str.Data\n\tslice.Len = str.Len\n\tslice.Cap = str.Len\n\truntime.KeepAlive(&s)\n\treturn bytes\n}\n*/"
  },
  {
    "path": "tmplstub.go",
    "content": "package main\n\nimport (\n\t//\"reflect\"\n\t//\"runtime\"\n\t//\"unsafe\"\n\t\"github.com/Azareal/Gosora/uutils\"\n)\n\n// TODO: Add a safe build mode for things like Google Appengine\n\nvar GetFrag = func(name string) [][]byte {\n\treturn nil\n}\n\ntype WriteString interface {\n\tWriteString(s string) (n int, err error)\n}\n\nvar StringToBytes = uutils.StringToBytes\nvar BytesToString = uutils.BytesToString\nvar Nanotime = uutils.Nanotime\n\n/*\nfunc StringToBytes(s string) (bytes []byte) {\n\tstr := (*reflect.StringHeader)(unsafe.Pointer(&s))\n\tslice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))\n\tslice.Data = str.Data\n\tslice.Len = str.Len\n\tslice.Cap = str.Len\n\truntime.KeepAlive(&s)\n\treturn bytes\n}\nfunc BytesToString(bytes []byte) (s string) {\n\tslice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))\n\tstr := (*reflect.StringHeader)(unsafe.Pointer(&s))\n\tstr.Data = slice.Data\n\tstr.Len = slice.Len\n\truntime.KeepAlive(&bytes)\n\treturn s\n}\n//go:noescape\n//go:linkname nanotime runtime.nanotime\nfunc nanotime() int64\nfunc Nanotime() int64 {\n\treturn nanotime()\n}*/\n"
  },
  {
    "path": "update-deps-linux",
    "content": "echo \"Updating the dependencies\"\n{\n\tcp ./common/common_easyjson.tgo ./common/common_easyjson.go\n} || {\n\techo \"Failed to copy bundled generated easyjson file\"\n}\n\n{\n\tGO111MODULE=\"off\"\n\tgo get -u github.com/mailru/easyjson/...\n} || {\n\techo \"Defaulting to bundled generated easyjson file\"\n}\nGO111MODULE=\"auto\"\n{\n\teasyjson -pkg common\n} || {\n\techo \"Defaulting to bundled generated easyjson file\"\n}\n\necho \"Building the hook stub generator\"\ngo build -ldflags=\"-s -w\" -o HookStubGen \"./cmd/hook_stub_gen\"\necho \"Running the hook stub generator\"\n./HookStubGen\n\ngo get"
  },
  {
    "path": "update-deps.bat",
    "content": "@echo off\n\necho Updating the dependencies\ngo get -u github.com/mailru/easyjson/...\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\neasyjson -pkg common\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\ngo get\nif %errorlevel% neq 0 (\n\tpause\n\texit /b %errorlevel%\n)\n\necho The dependencies were successfully updated\npause\n"
  },
  {
    "path": "updater/main.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"syscall\"\n\n\t\"gopkg.in/src-d/go-git.v4\"\n)\n\nfunc main() {\n\tscanner := bufio.NewScanner(os.Stdin)\n\n\t// Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tfmt.Println(r)\n\t\t\tdebug.PrintStack()\n\t\t\tpressAnyKey(scanner)\n\t\t\treturn\n\t\t}\n\t}()\n\n\tupdater(scanner)\n}\n\nfunc pressAnyKey(scanner *bufio.Scanner) {\n\tfmt.Println(\"Please press enter to exit...\")\n\tfor scanner.Scan() {\n\t\t_ = scanner.Text()\n\t\treturn\n\t}\n}\n\n// The bool return is a little trick to condense two lines onto one\nfunc logError(err error) bool {\n\tif err == nil {\n\t\treturn true\n\t}\n\tfmt.Println(err)\n\tdebug.PrintStack()\n\treturn false\n}\n\nfunc updater(scanner *bufio.Scanner) bool {\n\tfmt.Println(\"Welcome to Gosora's Upgrader\")\n\tfmt.Println(\"We're going to check for new updates, please wait patiently\")\n\n\trepo, err := git.PlainOpen(\".\")\n\tif err != nil {\n\t\treturn logError(err)\n\t}\n\n\tworkTree, err := repo.Worktree()\n\tif err != nil {\n\t\treturn logError(err)\n\t}\n\n\terr = workTree.Pull(&git.PullOptions{Force: true})\n\tif err == git.NoErrAlreadyUpToDate {\n\t\tfmt.Println(\"You are already up-to-date\")\n\t\treturn true\n\t} else if err != nil && err != git.ErrUnstagedChanges { // fixes a bug in git where it refuses to update the files\n\t\treturn logError(err)\n\t}\n\n\terr = workTree.Reset(&git.ResetOptions{Mode: git.HardReset})\n\tif err != nil {\n\t\treturn logError(err)\n\t}\n\n\tfmt.Println(\"Updated to the latest commit\")\n\theadRef, err := repo.Head()\n\tif err != nil {\n\t\treturn logError(err)\n\t}\n\n\t// Get information about the commit\n\tcommit, err := repo.CommitObject(headRef.Hash())\n\tif err != nil {\n\t\treturn logError(err)\n\t}\n\tfmt.Println(\"Commit details:\", commit)\n\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\terr = syscall.Exec(\"./patcher.bat\", []string{}, os.Environ()) // doesn't work, need something for windows\n\tdefault: //linux, etc.\n\t\terr = syscall.Exec(\"./patcher-linux\", []string{}, os.Environ())\n\t}\n\treturn logError(err)\n}\n"
  },
  {
    "path": "uploads/filler.txt",
    "content": "This file is here so that Git will include this folder in the repository."
  },
  {
    "path": "uutils/utils.go",
    "content": "package uutils\n\nimport (\n\t\"reflect\"\n\t\"runtime\"\n\t\"unsafe\"\n)\n\n// TODO: Add a safe build mode for things like Google Appengine\n\nfunc StringToBytes(s string) (bytes []byte) {\n\tstr := (*reflect.StringHeader)(unsafe.Pointer(&s))\n\tslice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))\n\tslice.Data = str.Data\n\tslice.Len = str.Len\n\tslice.Cap = str.Len\n\truntime.KeepAlive(&s)\n\treturn bytes\n}\n\nfunc BytesToString(bytes []byte) (s string) {\n\tslice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))\n\tstr := (*reflect.StringHeader)(unsafe.Pointer(&s))\n\tstr.Data = slice.Data\n\tstr.Len = slice.Len\n\truntime.KeepAlive(&bytes)\n\treturn s\n}\n\n//go:noescape\n//go:linkname nanotime runtime.nanotime\nfunc nanotime() int64\n\nfunc Nanotime() int64 {\n\treturn nanotime()\n}"
  }
]