Compare commits

...

14 Commits

Author SHA1 Message Date
not manx 2a5ac4b592
Use &aux for (conf)
4 years ago
not manx c4e13c4646
Fix CSS thing
4 years ago
not manx 878250343e
Improve update thread functions
4 years ago
not manx 50718e4ac5
Export *serb*
4 years ago
not manx 0adbfeed83
Uhh
4 years ago
not manx a479b49b3f
Lots of shit
4 years ago
not manx 65d43fd73d
regions was undefined if you had no flags set
4 years ago
not manx 497b87c9c3
Not setting post_nrs correctly
4 years ago
not manx 08254eda06
Make the connection pool work maybe?
4 years ago
not manx a31d24c28d
Moved hunchentoot utils to seperate package. Update readme
4 years ago
not manx 4214c213e3
Add license to top of each file, bump userscript version
4 years ago
not manx 220e660c84
Stop generating access logs
4 years ago
not manx e8198bd1e4
Move back to clsql
4 years ago
not manx a9e861eefd
Remove Ballmer
4 years ago

63
.gitattributes vendored

@ -1,63 +0,0 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

344
.gitignore vendored

@ -1,343 +1,3 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
appsettings.json
src/config.lisp
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
/BantFlags/wwwroot/flags/*.png
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- Backup*.rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
\#*#
backups/

@ -1,25 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29411.108
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BantFlags", "BantFlags\BantFlags.csproj", "{DDDE290B-44A9-485A-B0F4-E7D696EAFF8D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DDDE290B-44A9-485A-B0F4-E7D696EAFF8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DDDE290B-44A9-485A-B0F4-E7D696EAFF8D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDDE290B-44A9-485A-B0F4-E7D696EAFF8D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDDE290B-44A9-485A-B0F4-E7D696EAFF8D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {252385AC-0BBA-43F2-858A-BA3877F33106}
EndGlobalSection
EndGlobal

@ -1,12 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "3.1.0",
"commands": [
"dotnet-ef"
]
}
}
}

@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Copyright>Manx</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="7.14.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Razor" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.1.0" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.0" />
<PackageReference Include="MySql.Data" Version="8.0.18" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Nito.AsyncEx" Version="5.0.0" />
</ItemGroup>
</Project>

@ -1,84 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using BantFlags.Data;
using BantFlags.Data.Database;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace BantFlags.Controllers
{
[ApiController]
[Route("api")]
public class FlagsController : Controller
{
private DatabaseService Database { get; }
public FlagsController(DatabaseService db)
{
Database = db;
}
/// <summary>
/// Retrives flags from the database from the posts sent in post_nrs
/// </summary>
/// <param name="post_nrs">The comma seperated list of post numbers from the thread.</param>
/// <param name="board">Currently should only be /bant/. Not checked here because we don't need to care what they send.</param>
/// <param name="version">The version of the userscript.</param>
[HttpPost]
[Route("get")]
[Consumes("application/x-www-form-urlencoded")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Get([FromForm]string post_nrs, [FromForm]string board, [FromForm]int? version)
{
int ver = version ?? 0;
if (ver > 1)
{
// Improved data structuring, see Docs/get
return Json(await Database.GetPosts_V2(post_nrs, board));
}
return Json(await Database.GetPosts_V1(post_nrs, board));
}
/// <summary>
/// Posts flags in the database.
/// </summary>
/// <param name="post_nr">The post number to associate the flags to.</param>
/// <param name="board">Currently should only be /bant/.</param>
/// <param name="regions">List of flags to associate with the post. Split by "||" in API V1 and "," in V2.</param>
/// <param name="version">The version of the userscript.</param>
[HttpPost]
[Route("post")]
[Consumes("application/x-www-form-urlencoded")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Post([FromForm]string post_nr, [FromForm]string board, [FromForm]string regions, [FromForm]int? version)
{
string splitFlag = (version ?? 0) > 1 ? "," : "||"; // comma for v2+, else || for backwards compatibility.
(PostModel flag, string error) = PostModel.Create(post_nr, board, regions, splitFlag, Database.KnownFlags, Database.Boards);
if (flag is null)
{
return Problem(error, statusCode: StatusCodes.Status400BadRequest);
}
await Database.InsertPost(flag);
return Ok(flag);
}
/// <summary>
/// Gets the list of supported flags.
/// </summary>
[HttpGet]
[Route("flags")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Flags() => Ok(Database.FlagList);
}
}

@ -1,115 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace BantFlags.Data.Database
{
/// <summary>
/// Functions for interacting with the database.
/// </summary>
public partial class DatabaseService
{
private MySqlConnectionPool ConnectionPool { get; }
public string FlagList { get; private set; }
public HashSet<string> KnownFlags { get; private set; }
public HashSet<string> Boards { get; private set; }
public DatabaseService(DatabaseServiceConfig dbConfig)
{
ConnectionPool = new MySqlConnectionPool(dbConfig.ConnectionString, dbConfig.PoolSize);
Boards = dbConfig.Boards;
UpdateKnownFlags().Wait(); // It's okay to deadlock here since it's only initialised at startup.
}
public async Task UpdateKnownFlags()
{
var flags = await GetFlags();
flags.Remove("empty, or there were errors. Re-set your flags."); // So users can't select this.
FlagList = string.Join("\n", flags);
KnownFlags = flags.ToHashSet();
}
public async Task InsertPost(PostModel post)
{
using (var rentedConnection = await ConnectionPool.RentConnectionAsync())
{
await rentedConnection.Object.UseStoredProcedure("insert_post")
.SetParam("@post_nr", post.PostNumber)
.SetParam("@board", post.Board)
.ExecuteNonQueryAsync();
using (var query = rentedConnection.Object.UseStoredProcedure("insert_post_flags"))
{
query.SetParam("@post_nr", post.PostNumber);
post.Flags.ForEach(async f =>
await query.SetParam("@flag", f)
.ExecuteNonQueryAsync(reuse: true));
}
}
}
/// <summary>
/// Returns all of the flags that we support.
/// </summary>
public async Task<List<string>> GetFlags()
{
using var rentedConnected = await ConnectionPool.RentConnectionAsync();
DataTable table = await rentedConnected.Object.CreateQuery("SELECT flags.flag FROM flags")
.ExecuteTableAsync();
return table.AsEnumerable()
.Select(x => x.GetValue<string>("flag"))
.ToList();
}
public async Task InsertFlagAsync(Flag flag)
{
using var rentedConnection = await ConnectionPool.RentConnectionAsync();
await rentedConnection.Object.UseStoredProcedure("insert_flag")
.SetParam("@flag", flag.Name)
.ExecuteNonQueryAsync();
}
public async Task RenameFlagAsync(Flag flag)
{
using var rentedConnection = await ConnectionPool.RentConnectionAsync();
await rentedConnection.Object.UseStoredProcedure("rename_flag")
.SetParam("@old", flag.OldName)
.SetParam("@new", flag.Name)
.ExecuteNonQueryAsync();
}
public async Task DeleteFlagAsync(Flag flag)
{
using var rentedConnection = await ConnectionPool.RentConnectionAsync();
await rentedConnection.Object.UseStoredProcedure("delete_flag")
.SetParam("@flag", flag.Name)
.ExecuteNonQueryAsync();
}
}
/// <summary>
/// Configuration data passed by appsettings.
/// </summary>
public class DatabaseServiceConfig
{
public string ConnectionString { get; set; }
public int PoolSize { get; set; }
public HashSet<string> Boards { get; set; }
}
}

@ -1,58 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace BantFlags.Data.Database
{
public partial class DatabaseService
{
// Maybe this could be better but I don't know SQL lol
private readonly string GetPostsQuery = @"SELECT posts.post_nr, flags.flag FROM flags LEFT JOIN (postflags) ON (postflags.flag = flags.id) LEFT JOIN (posts) ON (postflags.post_nr = posts.id) WHERE FIND_IN_SET(posts.post_nr, (@posts)) AND posts.board = @board";
/// <summary>
/// Returns the post numbers and their flags from the post numbers in the input.
/// </summary>
/// <param name="post_nr">List of post numbers on the page.</param>
public async Task<IEnumerable<IGrouping<int, DataRow>>> GetPosts(string post_nr, string board)
{
using var rentedConnection = await ConnectionPool.RentConnectionAsync();
DataTable table = await rentedConnection.Object.CreateQuery(GetPostsQuery)
.SetParam("@posts", post_nr)
.SetParam("@board", board)
.ExecuteTableAsync();
return table.AsEnumerable()
.GroupBy(x => x.GetValue<int>("post_nr"));
}
public async Task<List<Dictionary<string, string>>> GetPosts_V1(string post_nr, string board)
{
List<Dictionary<string, string>> posts = new List<Dictionary<string, string>>();
var x = await GetPosts(post_nr, board);
x.ForEach(x => posts.Add(new Dictionary<string, string>
{
{"post_nr", x.Key.ToString() },
{"region", string.Join("||", x.AsEnumerable().Select(y => y.GetValue<string>("flag")))}
}));
return posts;
}
public async Task<Dictionary<int, IEnumerable<string>>> GetPosts_V2(string post_nr, string board)
{
var posts = await GetPosts(post_nr, board);
return posts
.ToDictionary(
x => x.Key,
x => x.AsEnumerable().Select(x => x.GetValue<string>("flag"))
);
}
}
}

@ -1,20 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using System;
using System.Collections.Generic;
namespace BantFlags.Data
{
public static class IEnumerableExtensions
{
public static void ForEach<T>(this IEnumerable<T> enumeration, Action<T> action)
{
foreach (T item in enumeration)
{
action(item);
}
}
}
}

@ -1,61 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using MySql.Data.MySqlClient;
using Nito.AsyncEx;
using System;
using System.Data;
using System.Threading.Tasks;
namespace BantFlags.Data
{
public class MySqlConnectionPool : IDisposable
{
public AsyncCollection<MySqlConnection> Connections { get; }
protected string ConnectionString { get; }
protected int PoolSize { get; }
public MySqlConnectionPool(string connectionString, int poolSize)
{
PoolSize = poolSize;
ConnectionString = connectionString;
Connections = new AsyncCollection<MySqlConnection>(poolSize);
for (int i = 0; i < poolSize; i++)
{
var connection = new MySqlConnection(connectionString);
connection.Open();
Connections.Add(connection);
}
}
public async Task<PoolObject<MySqlConnection>> RentConnectionAsync()
{
return new PoolObject<MySqlConnection>(await Connections.TakeAsync(), obj =>
{
if (obj.State != ConnectionState.Open)
{
obj.Open();
}
Connections.Add(obj);
});
}
public void Dispose()
{
for (int i = 0; i < PoolSize; i++)
{
var connection = Connections.Take();
connection.Dispose();
}
}
}
}

@ -1,48 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using MySql.Data.MySqlClient;
using System;
using System.Data;
namespace BantFlags.Data
{
public static class MySqlExtensions
{
public static Query CreateQuery(this MySqlConnection connection, string sql)
{
connection.EnsureConnectionIsOpen();
return new Query(new MySqlCommand(sql, connection));
}
public static Query UseStoredProcedure(this MySqlConnection connection, string sql)
{
connection.EnsureConnectionIsOpen();
return new Query(new MySqlCommand(sql, connection)
{
CommandType = CommandType.StoredProcedure
});
}
public static T GetValue<T>(this DataRow row, string column)
{
object value = row[column];
if (value == null || value == DBNull.Value)
return default;
return (T)value;
}
private static void EnsureConnectionIsOpen(this MySqlConnection connection)
{
if (connection.State != ConnectionState.Open)
{
connection.Open();
}
}
}
}

@ -1,29 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using System;
namespace BantFlags.Data
{
public class PoolObject<T> : IDisposable
{
public T Object { get; }
private Action<T> ReturnAction { get; }
public PoolObject(T o, Action<T> returnAction)
{
Object = o;
ReturnAction = returnAction;
}
public void Dispose()
{
ReturnAction(Object);
}
public static implicit operator T(PoolObject<T> poolObject)
=> poolObject.Object;
}
}

@ -1,52 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using System.Collections.Generic;
using System.Linq;
namespace BantFlags.Data
{
public class PostModel
{
public int PostNumber { get; private set; }
public string Board { get; private set; }
public string[] Flags { get; private set; }
private PostModel(int post_nr, string board, string[] flags)
{
PostNumber = post_nr;
Board = board;
Flags = flags;
}
public static (PostModel, string) Create(string post_nr, string board, string regions, string splitFlag, HashSet<string> knownFlags, HashSet<string> boards)
{
string[] empty = { "empty, or there were errors. Re-set your flags." };
if (!int.TryParse(post_nr, out int postNumber))
return (default, "Invalid post number.");
if (!boards.Contains(board))
return (default, "Invalid board parameter.");
if (regions == null)
return (new PostModel(postNumber, board, empty), default);
var flags = regions.Split(splitFlag);
if (flags.Count() > 30)
return (default, "Too many flags.");
foreach (string flag in flags)
{
if (!knownFlags.Contains(flag)) // Not ideal but it's better than doing it in the controller or passing the database here.
return (new PostModel(postNumber, board, empty), default);
}
return (new PostModel(postNumber, board, flags), default);
}
}
}

@ -1,65 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using MySql.Data.MySqlClient;
using System;
using System.Data;
using System.Threading.Tasks;
namespace BantFlags.Data
{
/// <summary>
/// Succinct methods for creating and executing database queries
/// </summary>
public class Query : IDisposable
{
private MySqlCommand Command { get; }
public Query(MySqlCommand cmd)
{
Command = cmd;
}
public async Task<DataTable> ExecuteTableAsync()
{
using (var reader = await Command.ExecuteReaderAsync())
{
DataTable table = new DataTable();
table.Load(reader);
return table;
}
}
public async Task ExecuteNonQueryAsync(bool reuse = false)
{
await Command.ExecuteNonQueryAsync();
if (!reuse)
{
Dispose();
}
}
public Query SetParam(string parameter, object value)
{
// When we reuse a query, we write over the parameter.
if (Command.Parameters.Contains(parameter))
{
Command.Parameters[parameter].Value = value;
}
else
{
Command.Parameters.AddWithValue(parameter, value);
}
return this;
}
public void Dispose()
{
Command.Dispose();
}
}
}

@ -1,150 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using ImageMagick;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace BantFlags.Data
{
public class Staging
{
public List<Flag> Flags { get; set; }
public string Password { get; }
public HashSet<string> Names { get; set; }
public Staging(string password)
{
Flags = new List<Flag>();
Password = password;
}
public void Clear()
{
Flags = new List<Flag>();
}
}
public enum Method
{
Add = 0,
Delete = 1,
Rename = 2
}
public class Flag
{
public string Name { get; set; }
public string OldName { get; set; }
public bool IsChecked { get; set; }
public Method FlagMethod { get; set; }
// This is bad but we need it so Flags can be generated by the input tag helper
public Flag()
{
}
private Flag(string name, Method method)
{
Name = name;
FlagMethod = method;
}
private Flag(string name, string oldName, Method method)
{
Name = name;
OldName = oldName;
FlagMethod = method;
}
public static Flag CreateFromDelete(string name) => new Flag(name, Method.Delete); // We don't need any validation for deleted flags.
public static (Flag, string) CreateFromRename(string oldName, string newName, HashSet<string> names)
{
(bool valid, string error) = ValidateFileName(newName, names);
if (!valid)
{
return (default, error);
}
return (new Flag(newName, oldName, Method.Rename), default);
}
public static async Task<(Flag, string)> CreateFromFile(IFormFile upload, HashSet<string> names)
{
byte[] PNGHeader = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
if (upload.ContentType.ToLower() != "image/png")
return (default, "Image must be a png.");
if (upload.Length > 15 * 1024)
return (default, "File too big. Max size is 15kb.");
var name = Path.GetFileNameWithoutExtension(upload.FileName);
(bool valid, string error) = ValidateFileName(name, names);
if (!valid)
return (default, error);
using (var memoryStream = new MemoryStream())
{
await upload.CopyToAsync(memoryStream);
memoryStream.Position = 0;
using (var image = new MagickImage(memoryStream))
{
if (image.Width != 16 || image.Height != 11)
return (default, "Invalid image dimensions. Flags should be 16px by 11px.");
}
using (var reader = new BinaryReader(memoryStream))
{
reader.BaseStream.Position = 0;
if (!reader.ReadBytes(PNGHeader.Length).SequenceEqual(PNGHeader))
return (default, "Invalid png header.");
}
}
return (new Flag(name, Method.Add), default);
}
/// <summary>
/// Filters file names created by users.
/// </summary>
/// <param name="name">The file name to validate.</param>
/// <param name="names">The list of current file names.</param>
private static (bool, string) ValidateFileName(string name, HashSet<string> names)
{
if (string.IsNullOrWhiteSpace(name))
return (false, "Flag name can't be empty.");
if (name.Length > 100)
return (false, "Flag name too long.");
if (name == "empty, or there were errors. Re - set your flags.")
return (false, "Invalid flag name.");
if (name.Contains("||") || name.Contains(","))
return (false, "Flag name contains invalid characters. You can't use \"||\" or \",\".");
if (names.Contains(name))
return (false, "A flag with that name already exists.");
return (true, name);
}
}
}

@ -1,21 +0,0 @@
@* (C) Copyright 2019 C-xC-c <boku@plum.moe>
This file is part of BantFlags.
BantFlags is licensed under the GNU AGPL Version 3.0 or later.
see the LICENSE file or <https: //www.gnu.org/licenses/agpl-3.0.en.html /> *@
@page
@model BantFlags.Pages.IndexModel
@{
ViewData["Title"] = "BantFlags";
Layout = "~/Pages/Shared/_Layout.cshtml";
}
<h1>/bant/ Flags</h1>
<p>E<span lang="jp">メール</span>: boku (at) plum (dot) moe</p>
<a href="~/bantflags.user.js">Install Bantflags</a>
<br />
<br />
<a href="https://nineball.party/srsbsn/3521#bottom">Official Thread</a>
<br />
<br />
<a asp-page="Upload">Upload Flags</a>

@ -1,15 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace BantFlags.Pages
{
public class IndexModel : PageModel
{
public void OnGet()
{
}
}
}

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
@RenderSection("Head", required: false)
</head>
<body style="text-align: center;">
@RenderBody()
@RenderSection("Scripts", required: false)
</body>
</html>

@ -1,123 +0,0 @@
@* (C) Copyright 2019 C-xC-c <boku@plum.moe>
This file is part of BantFlags.
BantFlags is licensed under the GNU AGPL Version 3.0 or later.
see the LICENSE file or <https: //www.gnu.org/licenses/agpl-3.0.en.html /> *@
@page
@using BantFlags.Data
@model BantFlags.UploadModel
@{
ViewData["Title"] = "Upload";
Layout = "~/Pages/Shared/_Layout.cshtml";
}
<h1>Upload</h1>
<img src="~/montage.png" />
<h2 id="message">@Model.Message</h2>
<h2>Add a Flag</h2>
<form method="post" asp-page-handler="Add" enctype="multipart/form-data">
<input type="file" name="upload" />
<input type="checkbox" name="gloss" value="true" /><span>Apply Gloss?</span>
<br />
<br />
<input type="submit" value="Upload Flag" />
</form>
<h2>Manage Existing Flags</h2>
<form method="post">
<select name="flag">
@foreach (string s in Model.StagedFlags.Names)
{
<option>@s</option>
}
</select>
<input type="text" name="newName" />
<br />
<br />
<button asp-page-handler="Delete" type="submit">Delete Flag</button>
<button asp-page-handler="Rename" type="submit">Rename Flag</button>
</form>
<h2>Commit Changes</h2>
<form method="post" asp-page-handler="Commit" enctype="multipart/form-data">
<label>Password:</label>
<input type="text" name="password" />
<br />
<br />
<input type="submit" value="Commit Changes" />
</form>
@if (Model.StagedFlags.Flags.Any())
{
<h2>Pending Changes</h2>
<form method="post" asp-page-handler="Unstage">
<input type="text" name="password" />
<br />
<br />
<button type="submit">Remove from staging</button>
<h3>Deleted Flags</h3>
@for (int i = 0; i < Model.StagedFlags.Flags.Count(); i++)
{
if (Model.StagedFlags.Flags[i].FlagMethod == Method.Delete)
{
<div class="flag">
<label>@(Model.StagedFlags.Flags[i].Name)</label>
<img src="~/flags/@(Model.StagedFlags.Flags[i].Name).png" />
<input type="hidden" name="flags[@i].Name" value="@Model.StagedFlags.Flags[i].Name" />
<input type="hidden" name="flags[@i].FlagMethod" value="@Model.StagedFlags.Flags[i].FlagMethod" />
<input type="checkbox" name="flags[@i].IsChecked" value="true" />
</div>
}
}
<h3>Renamed Flags</h3>
@for (int i = 0; i < Model.StagedFlags.Flags.Count(); i++)
{
if (Model.StagedFlags.Flags[i].FlagMethod == Method.Rename)
{
<div class="flag">
<label>@(Model.StagedFlags.Flags[i].Name)</label>
<img src="~/flags/@(Model.StagedFlags.Flags[i].OldName).png" />
<input type="hidden" name="flags[@i].Name" value="@Model.StagedFlags.Flags[i].Name" />
<input type="hidden" name="flags[@i].FlagMethod" value="@Model.StagedFlags.Flags[i].FlagMethod" />
<input type="checkbox" name="flags[@i].IsChecked" value="true" />
</div>
}
}
<h3>Added Flags</h3>
@for (int i = 0; i < Model.StagedFlags.Flags.Count(); i++)
{
if (Model.StagedFlags.Flags[i].FlagMethod == Method.Add)
{
<div class="flag">
<label>@(Model.StagedFlags.Flags[i].Name)</label>
<img src="~/flags/staging/@(Model.StagedFlags.Flags[i].Name).png" />
<input type="hidden" name="flags[@i].Name" value="@Model.StagedFlags.Flags[i].Name" />
<input type="hidden" name="flags[@i].FlagMethod" value="@Model.StagedFlags.Flags[i].FlagMethod" />
<input type="checkbox" name="flags[@i].IsChecked" value="true" />
</div>
}
}
</form>
}
@section Head {
<link rel="stylesheet" href="~/upload.css" />
}
@section Scripts {
@* Place flag image inside the <select> because ASP removes "invalid HTML" *@
<script>
window.addEventListener('load', function (e) {
let x = document.getElementsByTagName('select')[0].children
Array.prototype.slice.call(x).forEach(function (y) {
var name = y.innerHTML;
y.innerHTML = "<img src=\"flags/" + name + ".png\">" + name
});
}, { once: true });
</script>
}

@ -1,191 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using BantFlags.Data;
using BantFlags.Data.Database;
using ImageMagick;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace BantFlags
{
public class UploadModel : PageModel
{
private DatabaseService Database { get; set; }
public Staging StagedFlags { get; set; }
public string Message { get; private set; }
private string WebRoot { get; }
public HashSet<string> AllNames => StagedFlags.Names.Concat(StagedFlags.Flags.Select(x => x.Name)).ToHashSet();
public UploadModel(DatabaseService dbs, Staging ns, IWebHostEnvironment env)
{
Database = dbs;
StagedFlags = ns;
WebRoot = env.WebRootPath;
}
public void OnGet()
{
StagedFlags.Names = StagedFlags.Names ?? Database.KnownFlags;
}
public IActionResult OnPostDelete(string flag)
{
var stagingFlag = Flag.CreateFromDelete(flag);
StagedFlags.Flags.Add(stagingFlag);
StagedFlags.Names.Remove(stagingFlag.Name);
Message = $"{stagingFlag.Name} deleted.";
return Page();
}
public IActionResult OnPostRename(string flag, string newName)
{
(Flag stagingFlag, string error) = Flag.CreateFromRename(flag, newName, AllNames);
if (stagingFlag is null)
{
Message = error;
return Page();
}
StagedFlags.Flags.Add(stagingFlag);
StagedFlags.Names.Add(stagingFlag.Name);
Message = $"{stagingFlag.OldName} renamed to {stagingFlag.Name}.";
return Page();
}
public async Task<IActionResult> OnPostAddAsync(IFormFile upload, bool gloss)
{
(Flag stagingFlag, string error) = await Flag.CreateFromFile(upload, AllNames);
if (stagingFlag is null)
{
Message = error;
return Page();
}
using var memoryStream = new MemoryStream();
await upload.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Magic.NET is a huge dependency to be used like this
// Maybe we should switch to a Process and expect to have
// ImageMagick installed on the target machine.
using var image = new MagickImage(memoryStream);
if (gloss)
{
using var glossImage = new MagickImage(WebRoot + "/gloss.png");
glossImage.Composite(image, new PointD(0, 0), CompositeOperator.Over);
}
image.Write(WebRoot + "/flags/staging/" + upload.FileName);
StagedFlags.Flags.Add(stagingFlag);
Message = $"{stagingFlag.Name} uploaded";
return Page();
}
public IActionResult OnPostUnstage(Flag[] flags, string password)
{
if (password != StagedFlags.Password)
{
Message = "Incorrect Password";
return Page();
}
for (int i = flags.Length - 1; i >= 0; i--)
{
if (flags[i].IsChecked != true)
{
continue;
}
StagedFlags.Flags.RemoveAt(i);
var flag = flags[i];
switch (flag.FlagMethod)
{
case Method.Add:
System.IO.File.Delete(WebRoot + "/flags/staging/" + flag.Name);
StagedFlags.Names.Remove(flag.Name);
break;
case Method.Delete:
StagedFlags.Names.Add(flag.Name);
break;
case Method.Rename:
StagedFlags.Names.Remove(flag.Name);
break;
default:
throw new Exception();
}
}
Message = "Removed flags from staging";
return Page();
}
public async Task<IActionResult> OnPostCommit(string password)
{
if (password != StagedFlags.Password)
{
Message = "Incorrect Password";
return Page();
}
foreach (var flag in StagedFlags.Flags)
{
string flagname = flag.Name + ".png";
switch (flag.FlagMethod)
{
case Method.Add:
await Database.InsertFlagAsync(flag);
Directory.Move(WebRoot + "/flags/staging/" + flagname, WebRoot + "/flags/" + flagname);
break;
case Method.Delete:
await Database.DeleteFlagAsync(flag);
Directory.Move(WebRoot + "/flags/" + flagname, WebRoot + "/flags/dead/" + Guid.NewGuid().ToString()); // Use a GUID so flags with the same name can be deleted.
break;
case Method.Rename:
await Database.RenameFlagAsync(flag);
Directory.Move(WebRoot + "/flags/" + flag.OldName + ".png", WebRoot + "/flags/" + flagname);
break;
default:
throw new Exception();
}
}
await Database.UpdateKnownFlags();
StagedFlags.Names = Database.KnownFlags;
StagedFlags.Clear();
Message = "Changes committed successfully";
return Page();
}
}
}

@ -1,3 +0,0 @@
@using BantFlags
@namespace BantFlags.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@ -1,3 +0,0 @@
@{
Layout = "_Layout";
}

@ -1,31 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.IO;
namespace BantFlags
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.ConfigureAppConfiguration((host, config) =>
{
// Explicitly look for appsettings.json in the program's directory
config.AddJsonFile(Path.Join(System.AppDomain.CurrentDomain.BaseDirectory + "appsettings.json"), optional: false, reloadOnChange: false);
});
}
}

@ -1,30 +0,0 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:17777",
"sslPort": 44366
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"BantFlags": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}

@ -1,83 +0,0 @@
// (C) Copyright 2019 C-xC-c <boku@plum.moe>
// This file is part of BantFlags.
// BantFlags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
using BantFlags.Data;
using BantFlags.Data.Database;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using System.IO;
namespace BantFlags
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers().AddNewtonsoftJson(); // So we can format complex datatypes into JSON
services.AddRazorPages();
if (!Directory.Exists(Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "keys/")))
{
Directory.CreateDirectory(Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "keys/"));
}
services.AddDataProtection()
.SetApplicationName("BantFlags")
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "keys")));
services.AddSingleton(new DatabaseService(Configuration.GetSection("dbconfig").Get<DatabaseServiceConfig>()));
services.AddSingleton(new Staging(Configuration.GetValue<string>("staging-password")));
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
string webroot = Configuration.GetValue<string>("webroot");
if (Directory.Exists(webroot))
{
env.WebRootPath = webroot;
env.WebRootFileProvider = new PhysicalFileProvider(env.WebRootPath);
}
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseStaticFiles(new StaticFileOptions
{
ServeUnknownFileTypes = true,
DefaultContentType = "text/plain"
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
});
}
}
}

@ -1,14 +0,0 @@
{
"AllowedHosts": "*",
"dbconfig": {
"connectionstring": "Server=localhost;Port=3306;User ID=user;Password=password;Database=bantflags",
"poolsize": 2,
"boards": [
"bant",
"nap",
"srsbsn"
]
},
"webroot": "/var/www/html",
"staging-password": "supersecretpassword"
}

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

@ -1,4 +0,0 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

@ -1,28 +0,0 @@
body {
background: #ffe url(fade.png) top center repeat-x;
}
h1, h2, h3 {
color: maroon;
}
h3 {
clear: both;
padding-top: 20px;
}
.flag-container {
width: 60%;
margin: auto;
}
.flag {
margin: auto;
width: 12%;
display: inline-block;
padding-bottom: 10px;
}
#message {
color: #ee0;
}

@ -1,4 +0,0 @@
* /api/flags
returns a plaintext list of all the resolved flags, seperated by
newline characters (\n). This is maintained by the database service
and updated whenever changes are committed to the flag console.

@ -1,70 +0,0 @@
* /api/get
** Recieved Data
All versions of the script send a comma seperated list of post
numbers, =post_nrs= and a board =board=. Modern versions of bantflags
also send a version number, =version=, which decides how the returned
data will be formatted.
Data looks like this:
#+BEGIN_SRC http
POST https://flags.plum.moe/api/get
Content-Type: application/x-www-form-urlencoded
post_nrs=12345,12346,14444&board=bant&version=1
#+END_SRC
** Sent Data
*** GetPosts
Returns an =IEnumerable<IGrouping<int, DataRow>>= of post numbers and
their flags where the post numbers are contained in
=post_nrs=. =board= limits the query to only the board we're currently
on.
*** GetPosts_V1
Minimum script version: 0
Flags are converted from an =IEnumerable<IGrouping<int, DataRow>>= to
a =List<Dictionary<string, string>>= by joining the values in the
=DataRow= by "||", which are then split and converted into an array by
the script.
We're doing a needless conversion at both ends which slows the whole
process down, but it's how extraflags is set up and we need to support
it.
Data looks like this:
#+BEGIN_SRC javascript
[
{
{"post_nr": "123"},
{"regions": "flag1||flag2||flag3"}
},
{
{"post_nr": "456"},
{"regions": "flag4||flag3||flag3"}
}
]
#+END_SRC
*** getPosts_V2
Minimum script version: 2
Flags are converted from an =IEnumerable<IGrouping<int, DataRow>>= to
a =Dictionary<int, IEnumerable<string>>= which can then be parsed by
the script without any conversion. This format is the same as returned
from the database query, sans the extra information returned by a
=DataRow=
Data looks like this:
#+BEGIN_SRC javascript
[
123: [
"flag1",
"flag2",
"flag3"
],
456: [
"flag4",
"flag3",
"flag3"
]
]
#+END_SRC

@ -1,18 +0,0 @@
* /api/post
** Recieved Data
Data is sent from the script to the backend after a post has been
made, containing the post number, =post_nr=, =board= identifier, and
selected flags, =regions=. Modern versions of the script also encode a
=version= which is used when splitting =regions= into individual flags
Data looks like this:
#+BEGIN_SRC http :pretty
POST https://flags/plum.moe/api/post
Content-Type: application/x-www-form-urlencoded
post_nr=12345&board=bant&regions=Patchouli,dount,VIP&version=1
#+END_SRC
** Sent Data
The backend returns either a JSON object of the post, as it would be
returned by =/api/get=, or an error message specific to the issue if
the form data is invalid.

@ -1,15 +0,0 @@
* /Upload
The flag console is the preferred way for adding and modifying flags
in the database. It provides controls for adding, deleting and
renaming flags, all of which are visible before being committed by
someone with with password, which is set using the =staging-password=
section in =appsettings.json=. Changes may be unstaged on an
individual basis, but committed changes must be done all at once.
Flags have several hard-coded requirements for being uploaded using
the console. The image must be 16 by 11 pixels in size, be a PNG file
and have a valid PNG header. Names of files must be under 100
characters in length, and not contain either "," or
"||". Additionally, you cannot name a flag "Empty, or there were
errors. Please re-set your flags." as this is used, well, when someone
hasn't set their flags right.

@ -1,102 +0,0 @@
DROP DATABASE IF EXISTS `bantflags`;
CREATE DATABASE `bantflags`;
USE `bantflags`;
CREATE USER IF NOT EXISTS flags@localhost IDENTIFIED BY 'default';
GRANT ALL PRIVILEGES ON bantflags.* TO flags@localhost;
FLUSH PRIVILEGES;
CREATE TABLE `flags` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`flag` varchar(100) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `flag` (`flag`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
CREATE TABLE `posts` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`post_nr` int(10) NOT NULL DEFAULT '0',
`board` varchar(10) NOT NULL DEFAULT 'bant',
PRIMARY KEY (`id`),
UNIQUE KEY `post_nr_board` (`post_nr`,`board`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
CREATE TABLE `postflags` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`post_nr` int(10) NOT NULL DEFAULT '0',
`flag` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `flag` (`flag`),
KEY `post_nr` (`post_nr`),
CONSTRAINT `flag` FOREIGN KEY (`flag`) REFERENCES `flags` (`id`) ON DELETE CASCADE,
CONSTRAINT `post_nr` FOREIGN KEY (`post_nr`) REFERENCES `posts` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
DELIMITER $$
CREATE DEFINER=`flags`@`localhost` PROCEDURE `insert_post`(
IN `@post_nr` INT,
IN `@board` VARCHAR(5)
)
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
BEGIN
INSERT IGNORE INTO `posts` (`post_nr`, `board`) VALUES (`@post_nr`, `@board`);
END
$$
CREATE DEFINER=`flags`@`localhost` PROCEDURE `insert_post_flags`(
IN `@post_nr` INT,
IN `@flag` VARCHAR(100)
)
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
BEGIN
insert into postflags (post_nr, flag) VALUES (
(select id from posts where post_nr = `@post_nr`),
(select id from flags where flag = `@flag`)
);
END
$$
CREATE DEFINER=`flags`@`localhost` PROCEDURE `rename_flag`(
IN `@old` VARCHAR(100),
IN `@new` VARCHAR(100)
)
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
BEGIN
UPDATE flags SET flags.flag = `@new` WHERE flags.flag = `@old`;
END
$$
CREATE DEFINER=`flags`@`localhost` PROCEDURE `delete_flag`(
IN `@flag` VARCHAR(100)
)
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
BEGIN
DELETE flags.* FROM flags WHERE flags.flag = `@flag`;
END
$$
CREATE DEFINER=`flags`@`localhost` PROCEDURE `insert_flag`(
IN `@flag` VARCHAR(100)
)
LANGUAGE SQL
NOT DETERMINISTIC
CONTAINS SQL
SQL SECURITY DEFINER
BEGIN
INSERT INTO `flags` (`flag`) VALUES (`@flag`);
END
$$
DELIMITER ;

@ -1,117 +1,93 @@
* BantFlags
A user script and backend enabling user created flags on [[https://boards.4chan.org/bant][/bant/]],
originally based on [[https://github.com/flaghunters/Extra-Flags-for-4chan][extraflags]].
A user script and backend enabling user created flags on [[https://boards.4chan.org/bant][/bant/]],
originally based on [[https://github.com/flaghunters/Extra-Flags-for-4chan][extraflags]].
[[https://flags.plum.moe/bantflags.user.js][Install bantflags]]
[[https://flags.plum.moe/bantflags.user.js][Install bantflags]]
** Userscript
The userscript uses of ~GM_xmlhttpRequest~ to get and post flags with
the backend . A user's flags are stored between pages using
~GM_setValue~ and ~GM_getValue~.
The userscript uses of ~GM_xmlhttpRequest~ to get and post flags
with the backend . A user's flags are stored between pages using
~GM_setValue~ and ~GM_getValue~, or their GreaseMonkey4
equivalents.
Old versions of GreaseMonkey will be able to recieve updates to the
script through the ~@updateURL~ and ~@downloadURL~ directives, though
these were depricated sometime in GreaseMonkey 3.x and updates are
only checked from the location the script was downloaded from so be
careful where you upload links.
Old versions of GreaseMonkey will be able to recieve updates to the
script through the ~@updateURL~ and ~@downloadURL~ directives,
though these were depricated sometime in GreaseMonkey 3.x and
updates are only checked from the location the script was
downloaded from so be careful where you upload links.
On self hosting, changing ~back_end~ to your domain /should/ be all
you need to do, but don't take this as fact. I haven't tested the
example nginx config.
The userscript has been designed specifically to target ECMAScript
2015 (ES6), making liberal use of arrow functions, and const/let
declarations. Update your hecking browser.
On self hosting, changing ~back_end~ to your domain /should/ be all
you need to do, but don't take this as fact.
The userscript has been designed specifically to target ECMAScript
2015 (ES6), making liberal use of arrow functions, and const/let
declarations. Update your hecking browser.
** Backend
*** Prerequisites
- .NET Core 3.1
- MariaDB / MySQL
*** .NET dependancies
- Nito.AsyncEX
- Newtonsoft.Json
- MySql.Data
- Microsoft.AspNetCore.Mvc.NewtonsoftJson
- Microsoft.AspNetCore.StaticFiles
- Microsoft.AspNetCore.Razor
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
- Magick.NET-Q8-AnyCPU
- I use SBCL
- Some mysql, I use Mariadb
- Quicklisp
*** Dependancies
- hunchentoot
- [[https://github.com/C-xC-c/hunchenhelpers][hunchenhelpers]], my hunchentoot helper library (yes I'm proud of
the name)
- clsql
- jonathan, the JSON encoder/decoder
- cl-ppcre
*** Setup
1) [[https://dotnet.microsoft.com/download/dotnet-core][Install .NET Core]]
2) Clone and build the BantFlags solution.
3) Create the database using [[https://github.com/C-xC-c/BantFlags/blob/master/Environment/database.sql][database.sql]].
- *Change the password*.
4) configure ~BantFlags/appsettings.example.json~ with your connection
string and webroot (where you'll serve the flags from *without a
trailing slash*) and rename it to ~appsettings.json~
- [[./BantFlags/appsettings.example.json][example appsettings.json]]
- ASP.NET Core applications look for a folder called ~wwwroot~ in
the same directory as the application for static files. However
you can choose to logically seperate these by providing a vaild
directory to ~webroot~.
- That is to say, if the bantflags application is in
~/var/www/bantflags/BantFlags.dll~, the program will look for
the folder ~/var/www/bantflags/wwwroot/~ to host static content,
or whatever directory is provided to ~wwwroot~.
5) If you're hosting on your GNU/Linux distribution of choice, Create a
folder called ~keys~ in the same directory as the bantflags
executable.
- E.G. ~/var/www/bantflags/keys/~
- This is because ASP.NET Core uses some cryptic bullshit anti
forgery token when processing HTML forms, and it's unable to
persistantly store the decryption keys in memory on
GNU/Linux. This directory will store said keys when you or
users upload flags to /upload. The path uses
~AppDomain.CurrentDomain.BaseDirectory~ internally,
I.E. wherever the program is.
6) Add flags to the backend by uploading them to the flag console (/Upload).
- Flags must be 16x11 pixels and under 15kb. Their names must not
exceed 100 characters and cannot contain either "||" or ",".
7) Configure your webserver of choice to forward requests to kestral
- [[https://github.com/C-xC-c/BantFlags/blob/master/Environment/nginx.conf][Example nginx config.]]
8) Run with ~dotnet BantFlags.dll~ or create a service to run it as a
daemon.
- [[https://github.com/C-xC-c/BantFlags/blob/master/Environment/bantflags.service][Example systemd service.]]
9) ???
10) profit.
1. clone the project
2. Symlink src/ to your ~/quicklisp/local-projects
3. Move ~src/config.example.org~ to ~src/config.org~ and change it
to whatever your settings are.
4. Initialise the database by doing something like ~mysql <<
env/database.sql~, This will create all the tables you will
need, plus an entry for the ~`empty flag`~
5. Type the following into your repl:
#+BEGIN_SRC lisp
(ql:quickload :bantflags)
(bantflags:main)
#+END_SRC
6. To use bantflags as a Systemd service, I have included an
example service and an ~init.el~ file for the service to run,
since Systemd will automatically kill it if you just eval
~bantflags:main~.
You will almost certainly have several issues building clsql, the
database connector used. I've [[https://plum.moe/words/bludgeoning-clsql-and-mariadb.html][written a blog post]] on some of the
issues I've encountered personally, but there's no guarantee it'll
work. Piece of shit.
*** Database
Tables look like this:
Tables look like this:
*posts*
| id | post_nr | board |
| 1 | 12345 | bant |
| 2 | 56789 | bant |
*flags*
| id | flag |
| 1 | patchouli |
| 2 | chen |
*postflags*
| id | post_nr | flag |
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 2 | 2 | 2 |
where ~post_nr~ and ~flag~ in *postflags* are the id fields in their
respective tables.
*posts*
| id | post_nr | board |
| 1 | 12345 | bant |
| 2 | 56789 | bant |
*flags*
| id | flag |
| 1 | patchouli |
| 2 | chen |
*postflags*
| id | post_nr | flag |
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 2 | 2 | 2 |
where ~post_nr~ and ~flag~ in *postflags* are the id fields in their
respective tables.
*** API
The backend exposes three endpoints for the userscript to get and post
flags. Flags themselves are hosted from the ~flags/~ directory. This
will be whatever value you gave to ~webroot~ (or
~/path/to/bantflags/wwwroot/~ if no value is provided) + ~flags/~.
| route | purpse |
|------------+--------------------------------------------|
| /api/get | Get flags using post numbers in the thread |
| /api/post | Add flags to the database |
| /api/flags | List the flags we support |
| /flags/* | The flag images |
The backend exposes three endpoints for the userscript to get and
post flags. Flags themselves are hosted from ~flags/~ which is
~www-root/flags/~ from ~config.lisp~ on the filesystem
** Backwards Compatibility
The API is 1:1 compatable with all previous versions of
bantflags. Further improvements are achieved by encoding a ~version~
variable when poking endpoints which allows for breaking changes in
the script and backend while guaranteeing data can be parsed on both
ends. See [[https://github.com/C-xC-c/BantFlags/tree/master/Docs/][Docs/{endpoint}]] for changes and compatibility.
| route | purpse |
|------------+--------------------------------------------|
| /api/get | Get flags using post numbers in the thread |
| /api/post | Add flags to the database |
| /api/flags | List the flags we support |
| /flags/* | The flag images |
** Notes
You will get an error like =Recursive lock attempt #<SB-THREAD:MUTEX
"global-message-log-lock" owner: #<SB-THREAD:THREAD
"hunchentoot-worker-127.0.0.1:54454" RUNNING {1001DED5E3}>>.= if you
try and log to a file that doesn't exist / you don't have permissions
to read/write.

@ -11,7 +11,7 @@
// @exclude http*://archive.nyafuu.org/bant/statistics/
// @exclude http*://archived.moe/bant/statistics/
// @exclude http*://thebarchive.com/bant/statistics/
// @version 1.5.2
// @version 2.1.0
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue

@ -11,7 +11,7 @@
// @exclude http*://archive.nyafuu.org/bant/statistics/
// @exclude http*://archived.moe/bant/statistics/
// @exclude http*://thebarchive.com/bant/statistics/
// @version 1.5.2
// @version 2.1.0
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
@ -29,17 +29,14 @@
// /bant/ Flags is licensed under the GNU AGPL Version 3.0 or later.
// see the LICENSE file or <https://www.gnu.org/licenses/>
// Change this if you want verbose debuging information in the console.
const debugMode = true;
// This will print a load of shit to the console
const debugMode = false;
const isGM4 = typeof GM_setValue === 'undefined';
const setValue = isGM4 ? GM.setValue : GM_setValue;
const getValue = isGM4 ? GM.getValue : GM_getValue;
const xmlHttpRequest = isGM4 ? GM.xmlHttpRequest : GM_xmlhttpRequest;
//
// DO NOT EDIT ANYTHING IN THIS SCRIPT DIRECTLY - YOUR FLAGS SHOULD BE CONFIGURED USING THE FLAG SELECT
//
const version = encodeURIComponent(2); // Breaking changes.
const back_end = 'https://flags.plum.moe/';
const api_flags = back_end + 'api/flags';
@ -55,181 +52,169 @@ let regions = []; // The flags we have selected.
let postNrs = []; // all post numbers in the thread.
let board_id = ""; // The board we get flags for.
let flagsLoaded = false;
//
// DO NOT EDIT ANYTHING IN THIS SCRIPT DIRECTLY - YOUR FLAGS SHOULD BE CONFIGURED USING THE FLAG SELECT
//
const debug = text => {
if (debugMode)
console.log('[BantFlags] ' + text);
if (debugMode)
console.log('[BantFlags] ' + text);
}
// Test unqiue CSS paths to figure out what board software we're using.
const software = {
yotsuba: window.location.host === 'boards.4chan.org',
nodegucaDoushio: document.querySelector('b[id="sync"], span[id="sync"]') !== null,
foolfuuka: document.querySelector('div[id="main"] article header .post_data') !== null
yotsuba: window.location.host === 'boards.4chan.org',
nodegucaDoushio: document.querySelector('b[id="sync"], span[id="sync"]') !== null,
foolfuuka: document.querySelector('div[id="main"] article header .post_data') !== null
};
const createAndAssign = (element, source) => Object.assign(document.createElement(element), source);
const makeElement = (tag, options) => Object.assign(document.createElement(tag), options);
const toggleFlagButton = state => document.getElementById('append_flag_button').disabled = state === 'off' ? true : false;
const flagSource = flag => flag_dir + flag + ".png";
const flagSource = flag => flag_dir + flag + '.png';
/** Add styles to the <head> */
const addGlobalStyle = css => document.head.appendChild(createAndAssign('style', { innerHTML: css }));
const addStyle = css => document.head.appendChild(makeElement('style', { innerHTML: css }));
/** Wrapper around GM_xmlhttpRequest.
* @param {string} method - The HTTP method (GET, POST).
* @param {string} url - The URL of the request.
* @param {string} data - text for the form body.
* @param {Function} func - The function run when we recieve a response. Response data is sent directly to it. */
const makeRequest = ((method, url, data, func) => {
xmlHttpRequest({
method: method,
url: url,
data: data,
headers: { "Content-Type": 'application/x-www-form-urlencoded' },
onload: func
});
xmlHttpRequest({
method: method,
url: url,
data: data,
headers: { "Content-Type": 'application/x-www-form-urlencoded' },
onload: func
});
});
/** Itterate over selected flags are store them across browser sessions.*/
function saveFlags() {
regions = [];
const selectedFlags = document.querySelectorAll("bantflags_flag");
regions = [];
const selectedFlags = document.querySelectorAll(".bantflags_flag");
for (var i = 0; i < selectedFlags.length; i++) {
regions[i] = selectedFlags[i].title;
}
for (let i = 0; i < selectedFlags.length; i++) {
regions[i] = selectedFlags[i].title;
}
setValue(namespace, regions);
setValue(namespace, regions);
}
/** Add a flag to our selection.
* @param {string} flag - The flag to add to our selection. Either passed from saved flags or the current value of flagSelect */
/** Add a flag to our selection. */
function setFlag(flag) {
let UID = Math.random().toString(36).substring(7);
let flagName = flag ? flag : document.querySelector('#flagSelect input').value;
let flagContainer = document.getElementById('bantflags_container');
flagContainer.appendChild(createAndAssign('img', {
title: flagName,
src: flagSource(flagName),
id: UID,
className: 'bantflags_flag'
}));
if (flagContainer.children.length >= max_flags)
toggleFlagButton('off');
document.getElementById(UID).addEventListener("click", e => {
flagContainer.removeChild(e.target);
toggleFlagButton('on');
saveFlags();
});
if (!flag) // We've added a new flag to our selection
saveFlags();
const flagName = flag ? flag : document.querySelector('#flagSelect input').value;
const flagContainer = document.getElementById('bantflags_container');
flagContainer.appendChild(makeElement('img', {
title: flagName,
src: flagSource(flagName),
className: 'bantflags_flag',
onclick: function() {
flagContainer.removeChild(this);
if (flagsLoaded)
toggleFlagButton('on');
saveFlags();
}
}));
if (flagContainer.children.length >= max_flags)
toggleFlagButton('off');
if (!flag) // We've added a new flag to our selection
saveFlags();
}
function init() {
let flagsForm = createAndAssign('div', {
className: 'flagsForm',
innerHTML: '<span id="bantflags_container"></span><button type="button" id="append_flag_button" title="Click to add selected flag to your flags. Click on flags to remove them. Saving happens automatically, you only need to refresh the pages that have an outdated flaglist on the page."><<</button><button id="flagLoad" type="button">Click to load flags.</button><div id="flagSelect" ><ul class="hide"></ul><input type="button" value="(You)" onclick=""></div>'
});
// Where do we append the flagsForm to?
if (software.yotsuba) { document.getElementById('delform').appendChild(flagsForm); }
else if (software.nodegucaDoushio) { document.querySelector('section').append(flagsForm); } // As posts are added the flagForm moves up the page. Could we append this after .section?
for (let i = 0; i < regions.length; i++) {
setFlag(regions[i]);
}
const flagsForm = makeElement('div', {
className: 'flagsForm',
innerHTML: '<span id="bantflags_container"></span><button type="button" id="append_flag_button" title="Click to add selected flag to your flags. Click on flags to remove them." disabled="true"><<</button><button id="flagLoad" type="button">Click to load flags.</button><div id="flagSelect" ><ul class="hide"></ul><input type="button" value="(You)" onclick=""></div>'
});
// Where do we append the flagsForm to?
if (software.yotsuba) { document.getElementById('delform').appendChild(flagsForm); }
else if (software.nodegucaDoushio) { document.querySelector('section').insertAdjacentElement('afterEnd', flagsForm); }
for (let i = 0; i < regions.length; i++) {
setFlag(regions[i]);
}
document.getElementById('append_flag_button').addEventListener('click', () => flagsLoaded ? setFlag() : alert('Load flags before adding them.'));
document.getElementById('flagLoad').addEventListener('click', makeFlagSelect, { once: true });
document.getElementById('flagLoad').addEventListener('click', makeFlagSelect, { once: true });
}
/** Get flag data from server and fill flags form. */
function makeFlagSelect() {
makeRequest(
"GET",
api_flags,
"", // We can't send data, it's a GET request.
function (resp) {
debug('Loading flags.');
makeRequest(
"GET",
api_flags,
"", // We can't send data, it's a GET request.
function (resp) {
debug('Loading flags.');
if (resp.status !== 200) {
if (resp.status !== 200) {
console.log('Couldn\'t get flag list from server')
return;
}
let flagSelect = document.getElementById('flagSelect');
let flagList = flagSelect.querySelector('ul');
let flagInput = flagSelect.querySelector('input');
let flags = resp.responseText.split('\n');
for (var i = 0; i < flags.length; i++) {
let flag = flags[i];
flagList.appendChild(createAndAssign('li',{
let flagSelect = document.getElementById('flagSelect');
let flagInput = flagSelect.querySelector('input');
let flagList = flagSelect.querySelector('ul');
let flags = resp.responseText.split('\n');
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
flagList.appendChild(makeElement('li',{
innerHTML: `<img src="${flagSource(flag)}" title="${flag}"><span>${flag}</span>`
}));
}
}
flagSelect.addEventListener('click', function (e) {
// So it works if we click the flag image
flagSelect.addEventListener('click', e => {
// Maybe we clicked the flag image
const node = e.target.nodeName === 'LI' ? e.target : e.target.parentNode;
if (node.nodeName === 'LI') {
if (node.nodeName === 'LI')
flagInput.value = node.querySelector('span').innerHTML;
}
flagList.classList.toggle('hide');
});
document.getElementById('flagLoad').style.display = 'none';
document.querySelector('.flagsForm').style.marginRight = "200px"; // Element has position: absolute and is ~200px long.
flagSelect.style.display = 'inline-block';
flagsLoaded = true;
});
flagList.classList.toggle('hide');
});
const flagButton = document.getElementById('append_flag_button');
flagButton.addEventListener('click', () => setFlag());
flagButton.disabled = false;
document.getElementById('flagLoad').style.display = 'none';
document.querySelector('.flagsForm').style.marginRight = "200px"; // flagsForm has position: absolute and is ~200px long.
flagSelect.style.display = 'inline-block';
flagsLoaded = true;
});
}
/** add all of the post numbers on the page to postNrs. */
function getPosts(selector) {
const posts = document.querySelectorAll(selector);
const posts = document.querySelectorAll(selector);
for (let i = 0; i < posts.length; i++) {
const postNumber = software.yotsuba
for (let i = 0; i < posts.length; i++) {
const postNumber = software.yotsuba
? posts[i].id.substr(2) // Fuck you 4chan.
: posts[i].id;
postNrs.push(postNumber);
}
debug(postNrs);
debug(postNrs);
}
/** Get flags from the database using values in postNrs and pass the response on to onFlagsLoad */
function resolveFlags() {
makeRequest(
'POST',
api_get,
'post_nrs=' + encodeURIComponent(postNrs) + '&board=' + encodeURIComponent(board_id) + '&version=' + version,
function (resp) {
if (resp.status !== 200) {
console.log('[bantflags] Couldn\'t load flags. Refresh the page.');
return;
}
makeRequest(
'POST',
api_get,
'post_nrs=' + encodeURIComponent(postNrs) + '&board=' + encodeURIComponent(board_id) + '&version=' + version,
function (resp) {
if (resp.status !== 200) {
console.log('[bantflags] Couldn\'t load flags. Refresh the page');
debug(resp.responseText);
return;
}
const jsonData = JSON.parse(resp.responseText);
debug(`JSON: ${resp.responseText}`);
Object.keys(jsonData).forEach(post => {
let flags = jsonData[post];
const flags = jsonData[post];
if (flags.length <= 0)
return;
if (flags.length <= 0) return;
debug(`Resolving flags for >>${post}`);
@ -240,156 +225,145 @@ function resolveFlags() {
for (let i = 0; i < flags.length; i++) {
const flag = flags[i];
const newFlag = createAndAssign('a', {
flagContainer.append(makeElement('a', {
innerHTML: `<img src="${flagSource(flag)}" title="${flag}">`,
className: 'bantFlag',
target: '_blank',
title: flag
});
flagContainer.append(newFlag);
}));
debug(`\t -> ${flag}`);
}
});
postNrs = [];
});
});
}
function main() {
if (!regions) { // Should only be called before you set flags for the first time.
regions = [];
window.confirm('[BantFlags]: No Flags detected.\nIf this is your first time running bantflags, look for the "Click to load flags." button at the bottom right of the thread, then select your flag and press the ">>" button.');
}
// See Docs/styles.css
addGlobalStyle('.bantFlag{padding: 0px 0px 0px 5px; display: inline-block; width: 16px; height: 11px; position: relative;} .bantflags_flag{padding: 1px;} [title^="Romania"]{ position: relative; animation: shakeAnim 0.1s linear infinite;} @keyframes shakeAnim{ 0% {left: 1px;} 25% {top: 2px;} 50% {left: 1px;} 75% {left: 0px;} 100% {left: 2px;}}.flagsForm{float: right; clear: right; margin: 20px 10px;} #flagSelect{display:none;} #flagSelect ul{list-style-type: none;padding: 0;margin-bottom: 0;cursor: pointer;bottom: 100%;height: 200px;overflow: auto;position: absolute;width:200px;background-color:#fff} #flagSelect ul li {display: block;} #flagSelect ul li:hover {background-color: #ddd;}#flagSelect {position: absolute;}#flagSelect input {width: 200px;} #flagSelect .hide {display: none;}#flagSelect img {margin-left: 2px;}')
if (!regions) {
regions = [];
}
// See Docs/styles.css
addStyle('.bantFlag{padding: 0px 0px 0px 5px; display: inline-block; width: 16px; height: 11px; position: relative;} .bantflags_flag{padding: 1px;} [title^="Romania"]{ position: relative; animation: shakeAnim 0.1s linear infinite;} @keyframes shakeAnim{ 0% {left: 1px;} 25% {top: 2px;} 50% {left: 1px;} 75% {left: 0px;} 100% {left: 2px;}}.flagsForm{float: right; clear: right; margin: 0 20px 10px 0;} #flagSelect{display:none;} #flagSelect ul{list-style-type: none;padding: 0;margin-bottom: 0;cursor: pointer;bottom: 100%;height: 200px;overflow: auto;position: absolute;width:200px;background-color:#fff} #flagSelect ul li {display: block;} #flagSelect ul li:hover {background-color: #ddd;}#flagSelect {position: absolute;}#flagSelect input {width: 200px;} #flagSelect .hide {display: none;}#flagSelect img {margin-left: 2px;}')
if (software.yotsuba) {
getPosts('.postContainer');
if (software.yotsuba) {
getPosts('.postContainer');
addGlobalStyle('.flag{top: 0px;left: -1px}');
init();
}
addStyle('.flag{top: 0px;left: -1px}');
init();
}
if (software.nodegucaDoushio) {
getPosts('section[id], article[id]');
else if (software.nodegucaDoushio) {
getPosts('section[id], article[id]');
addGlobalStyle('.bantFlag {cursor: default} .bantFlag img {pointer-events: none;}');
init();
}
addStyle('.bantFlag {cursor: default} .bantFlag img {pointer-events: none;}');
init();
}
if (software.foolfuuka) {
getPosts('article[id]');
else if (software.foolfuuka) {
getPosts('article[id]');
addGlobalStyle('.bantFlag{top: -2px !important;left: -1px !important}');
}
addStyle('.bantFlag{top: -2px !important;left: -1px !important}');
}
board_id = window.location.pathname.split('/')[1];
debug(board_id);
board_id = window.location.pathname.split('/')[1];
debug(`board: ${board_id}`);
resolveFlags();
try {
resolveFlags();
}
catch (fuckywucky) {
console.log(`Wah! Manx fucked something up ;~;\nPoke him somewhere with this:\n${fuckywucky}`)
}
}
if (isGM4) { // Fuck you GM4
(async () => {
regions = await getValue(namespace);
main();
})();
if (isGM4) { // Fuck you GreaseMonkey
(async () => {
regions = await getValue(namespace);
main();
})();
}
else {
regions = getValue(namespace);
main();
regions = getValue(namespace);
main();
}
const postFlags = (post_nr, func = resp => debug(resp.responseText)) => makeRequest(
'POST',
api_post,
'POST',
api_post,
`post_nr=${encodeURIComponent(post_nr)}&board=${encodeURIComponent(board_id)}&regions=${encodeURIComponent(regions)}&version=${version}`,
func);
if (software.yotsuba) {
const GetEvDetail = e => e.detail || e.wrappedJSObject.detail;
// 4chanX and native extension respectively
document.addEventListener('QRPostSuccessful', e => postFlags(e.detail.postID));
document.addEventListener('4chanQRPostSuccess', e => postFlags(GetEvDetail(e).postId));
// I need to look at these.
document.addEventListener('ThreadUpdate', function (e) {
var evDetail = GetEvDetail(e);
var evDetailClone = typeof cloneInto === 'function' ? cloneInto(evDetail, unsafeWindow) : evDetail;
//ignore if 404 event
if (evDetail[404] === true) {
return;
}
evDetailClone.newPosts.forEach(function (post_board_nr) {
var post_nr = post_board_nr.split('.')[1];
postNrs.push(post_nr);
});
resolveFlags();
}, false);
document.addEventListener('4chanThreadUpdated', function (e) {
var evDetail = GetEvDetail(e);
let threadID = window.location.pathname.split('/')[3];
let postsContainer = Array.prototype.slice.call(document.getElementById('t' + threadID).childNodes);
let lastPosts = postsContainer.slice(Math.max(postsContainer.length - evDetail.count, 1)); //get the last n elements (where n is evDetail.count)
lastPosts.forEach(function (post_container) {
var post_nr = post_container.id.replace('pc', '');
postNrs.push(post_nr);
});
resolveFlags();
}, false);
const e_detail = e => e.detail || e.wrappedJSObject.detail// what?
document.addEventListener('QRPostSuccessful', e => postFlags(e_detail(e).postID));
document.addEventListener('4chanQRPostSuccess', e => postFlags(e_detail(e).postId));
document.addEventListener('ThreadUpdate', e => {
const d = e_detail(e)
if (d[404]) return;
d.newPosts.forEach(post => postNrs.push(post.split('.')[1]));
resolveFlags();
});
document.addEventListener('4chanThreadUpdated', e => {
const d = e_detail(e);
if (d.count <= 0) return;
// Get the added posts in reverse order, take post numbers from ID
const posts = document.querySelectorAll('.postContainer');
for (let i = 0; i < d.count; i++) {
postNrs.push(posts[posts.length - 1 - i].id.substr(2));
}
resolveFlags();
});
}
if (software.nodegucaDoushio) {
const postFunc = function() {
const postFunc = () => {
postNrs.push(mutation.target.id);
resolveFlags();
}
const badNodes = ['HR', 'SECTION'];
new MutationObserver(mutations => {
mutations.forEach(mutation => {
new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes.length <= 0)
return; // We only care if something post was added
var firstAddedNode = mutation.addedNodes[0].nodeName;
const firstAddedNode = mutation.addedNodes[0].nodeName;
// Enter a thread / change boards
if (mutation.target.nodeName === 'THREADS') {
if (mutation.target.nodeName === 'THREADS') {
if (badNodes.includes(firstAddedNode))
return; // We are in the index and a post was added, handled properly further down
board_id = window.location.pathname.split('/')[1];
setTimeout(getPosts('section[id], article[id]'), 2000);
resolveFlags();
init();
}
// We post
if (firstAddedNode === 'HEADER') {
board_id = window.location.pathname.split('/')[1];
setTimeout(getPosts('section[id], article[id]'), 2000);
resolveFlags();
init();
}
// We post
if (firstAddedNode === 'HEADER') {
postFlags(mutation.target.id, postFunc)
}
// Someone else posts
if (firstAddedNode === 'ARTICLE') {
if (firstAddedNode === 'ARTICLE') {
if (mutation.target.nodeName === 'BODY' || mutation.target.id === 'hover_overlay')
return; // User is hovering over a post
postNrs.push(mutation.addedNodes[0].id);
setTimeout(resolveFlags, 1500);
}
postNrs.push(mutation.addedNodes[0].id);
setTimeout(resolveFlags, 1500);
}
});
}).observe(document.body, { childList: true, subtree: true });
}

@ -1,16 +1,16 @@
[Unit]
Description=bantflags backend
Description=bantflags serb
[Service]
# Set to the location of the application
WorkingDirectory=/etc/bantflags
ExecStart=/usr/bin/dotnet /etc/bantflags/BantFlags.dll
WorkingDirectory=/var/www/bantflags/src/
ExecStart=/usr/bin/env sbcl --load init.el
Restart=always
# restarts 10 seconds after it goes bang
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=bantflags
User=www-data
User=nginx
[Install]
WantedBy=multi-user.target

36
env/database.sql vendored

@ -0,0 +1,36 @@
DROP DATABASE IF EXISTS `bantflags`;
CREATE DATABASE `bantflags`;
CREATE USER IF NOT EXISTS flags@localhost IDENTIFIED BY 'default';
GRANT ALL PRIVILEGES ON bantflags.* TO flags@localhost;
FLUSH PRIVILEGES;
USE `bantflags`;
CREATE TABLE `flags` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`flag` varchar(100) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `flag` (`flag`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
CREATE TABLE `posts` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`post_nr` int(10) NOT NULL DEFAULT '0',
`board` varchar(10) NOT NULL DEFAULT 'bant',
PRIMARY KEY (`id`),
UNIQUE KEY `post_nr_board` (`post_nr`,`board`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
CREATE TABLE `postflags` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`post_nr` int(10) NOT NULL DEFAULT '0',
`flag` int(10) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `flag` (`flag`),
KEY `post_nr` (`post_nr`),
CONSTRAINT `flag` FOREIGN KEY (`flag`) REFERENCES `flags` (`id`) ON DELETE CASCADE,
CONSTRAINT `post_nr` FOREIGN KEY (`post_nr`) REFERENCES `posts` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
INSERT INTO `flags` (`flag`) VALUES ('empty, or there were errors. Re-set your flags.');

@ -15,7 +15,7 @@ http {
sendfile on;
tcp_nopush on;
charset utf-8;
index index.htm index.html;
index index.html;
server {
root /var/www/flags;

@ -1,3 +1,8 @@
;; (C) Copyright 2020 C-xC-c <boku@plum.moe>
;; This file is part of bantflags.
;; bantflags is licensed under the GNU AGPL Version 3.0 or later.
;; see the LICENSE file or <https://www.gnu.org/licenses/>
(asdf:defsystem #:bantflags
:description "the bantflags server component"
:author "Manx (boku@plum.moe)"
@ -6,11 +11,13 @@
:version "0.0.1"
:serial t
:depends-on (:hunchentoot
:str
:cl-dbi
:hunchenhelpers
:cl-ppcre
:clsql
:jonathan)
:Components
((:file "utils")
((:file "package")
(:file "utils")
(:file "db")
(:file "config")
(:file "main")))

@ -1,6 +1,14 @@
(in-package #:bantflags)
(defvar config
'((boards "bant")
'((boards ("bant" "uhh"))
(staging-password "not implemented")
(db-conn "bantflags" "flags" "default")
(db-conn ("localhost" "bantflags" "flags" "default"))
(poolsize 3)
(www-root #p"/path/to/files/")))
(www-root #p"/path/to/files/")
(port 4242)
;; These can be a file or stream, make them nil to disable logging
;; If the file can't be accessed, throws a weird error. See
;; README.org
(access-log *standard-output*)
(error-log #p"/path/to/error/log/")))

@ -1,59 +1,47 @@
;; (C) Copyright 2020 C-xC-c <boku@plum.moe>
;; This file is part of bantflags.
;; bantflags is licensed under the GNU AGPL Version 3.0 or later.
;; see the LICENSE file or <https://www.gnu.org/licenses/>
(in-package #:bantflags)
;; Databases in common lisp are the fucking worst.
;; Don't even bother.
;; Comparing strings with both
;; We're comparing strings
(defparameter *flags* (make-hash-table :test 'equal))
(defparameter *boards* (make-hash-table :test 'equal))
(defparameter *flags-txt* nil)
(defparameter conn-str nil)
(defvar get-posts-sql "SELECT posts.post_nr, flags.flag from flags left join postflags on (postflags.flag = flags.id) left join posts on (postflags.post_nr = posts.id) where posts.post_nr in (~{'~a'~^,~}) and posts.board = '~a';")
(defparameter conn nil)
(defun fuck-you-fukamachi (conn |;_;|)
"What the fuck is going on with what dbi:fetch returns? why is it a
fucking list with the database columns as a symbols with pipes around
them? How in the dicking shit am I supposed to use :|post_nr| 1234 in
any useful or practical way? Why does this fucking Wumpus of a human
being Fukamachi feel the need to duplicate so much data? Don't get me
wrong, clsql is easily worse to work with, but at least it was fucking
smart enough to make the database fields into (values rows columns)"
(mapcar (lambda (x) (list (nth 1 x) (nth 3 x)))
(dbi:fetch-all (dbi:execute (dbi:prepare conn |;_;|)))))
(defparameter get-posts-sql "SELECT posts.post_nr, flags.flag from flags left join postflags on (postflags.flag = flags.id) left join posts on (postflags.post_nr = posts.id) where posts.post_nr in (~{'~a'~^,~}) and posts.board = '~a';")
(defmacro dbfun (name &rest body)
`(defun ,name ,(car body)
(dbi:with-connection (conn :mysql
:database-name (car conn-str)
:username (nth 1 conn-str)
:password (nth 2 conn-str))
(dbi:do-sql conn "set names 'utf8';") ;; I fucking hate computers
,@(cdr body))))
(clsql:with-database (db conn :database-type :mysql :pool t)
,@(cdr body))))
(defun flag-id (flag)
(gethash flag *flags*))
(dbfun ping ()
(dbi:ping conn))
(dbfun insert-post (post_nr board flags)
(dbi:do-sql conn
(format nil "insert ignore into posts (post_nr, board) values (~a, '~a');" post_nr board))
(let ((post-id (cadr (dbi:fetch (dbi:execute (dbi:prepare conn (format nil "select id from posts where post_nr = ~a and board = '~a';" post_nr board)))))))
(dbi:do-sql conn
(with-output-to-string (s)
(format s "insert into postflags (post_nr, flag) values")
(loop for flag in (butlast flags)
do (format s "(~a,~a)," post-id (flag-id flag)))
(format s "(~a,~a);" post-id (flag-id (car (last flags))))))))
(clsql:execute-command (format nil "insert ignore into posts (post_nr, board) values (~a, '~a');" post_nr board) :database db)
(let ((post-id (caar (clsql:query (format nil "select id from posts where post_nr = ~a and board = '~a';" post_nr board) :database db))))
(clsql:execute-command
(with-output-to-string (s)
(format s "insert into postflags (post_nr, flag) values")
(loop for flag in (butlast flags) ;; The last flag needs a semi-colon instead of a comma
do (format s "(~a,~a)," post-id (flag-id flag)))
(format s "(~a,~a);" post-id (flag-id (car (last flags)))))
:database db)))
(dbfun get-posts (posts board)
(let ((result (fuck-you-fukamachi conn (format nil get-posts-sql posts board)))
(let ((result (clsql:query (format nil get-posts-sql posts board) :database db))
(table (make-hash-table)))
(loop for (post_nr . flag) in result do
(loop for (post_nr . flag) in (reverse result) do
(unless (gethash post_nr table)
(setf (gethash post_nr table) '()))
(push (car flag) (gethash post_nr table)))
(jojo:to-json table)))
(dbfun get-flags ()
(fuck-you-fukamachi conn "select flags.id, flags.flag from flags"))
(clsql:query "select flags.id, flags.flag from flags" :database db))

@ -0,0 +1,9 @@
;; This needs to be run with a lisp started in the same directory as
;; bantflags
(push (truename ".") ql:*local-project-directories*)
(ql:register-local-projects)
(ql:quickload :bantflags)
(bantflags:main)
(hunchentoot:start bantflags:*serb*)
(loop (sleep 43200) (gc :full t))

@ -1,58 +1,53 @@
;; (C) Copyright 2020 C-xC-c <boku@plum.moe>
;; This file is part of bantflags.
;; bantflags is licensed under the GNU AGPL Version 3.0 or later.
;; see the LICENSE file or <https://www.gnu.org/licenses/>
(in-package :bantflags)
(defun init ()
(set-db-conn)
(dotimes (_ (cconf 'poolsize))
(dbi:connect-cached :mysql
:database-name (car conn-str)
:username (nth 1 conn-str)
:password (nth 2 conn-str)))
(ping) ;; test db conn
(assert (not (null config)))
(setf conn (conf 'db-conn))
(loop repeat (conf 'poolsize)
do ;; This doesn't work lole
(clsql:connect conn :database-type :mysql :pool t :if-exists :new))
(set-boards)
(set-flags)
(defvar +serb+ (make-instance 'hunchentoot:easy-acceptor
:port 4242
:document-root (cconf 'www-root)))
(hunchentoot:start +serb+))
(defvar *serb* (make-instance 'hunchentoot:easy-acceptor
:port (conf 'port)
:address "127.0.0.1" ;; localhost
:document-root (conf 'www-root)
:access-log-destination (conf 'access-log)
:message-log-destination (conf 'error-log))))
(defun main ()
(handler-case (init)
(error (c)
(format t "Init fucked up, exiting ~a" c)
(return-from main)))
(loop (sleep 43200) (gc :full t)))
(return-from main)))
(handler-case (hunchentoot:start *serb*)
(error (c)
(format t "couldn't start serb: ~a" c)
(return-from main))))
(defmethod hunchentoot:acceptor-status-message (acceptor (http-status-code (eql 404)) &key)
(format nil ""))
(defmacro handle (method uri params &body body)
`(hunchentoot:define-easy-handler ,uri ,params
(unless (eq ,method (hunchentoot:request-method*))
(setf (hunchentoot:return-code*) hunchentoot:+http-not-found+)
(hunchentoot:abort-request-handler))
,@body))
(format nil "")) ;; Empty 404 page
(handle :post (api-post :uri "/api/post")
(henh:handle :post (api-post :uri "/api/post") @json
(post_nr regions board version)
(setf (hunchentoot:content-type*) "application/json")
(let ((separator (if (< 1 (get-version version)) "," "||")))
(multiple-value-bind (result msg) (post-valid-p post_nr regions board separator)
(cond
(result
(insert-post post_nr board msg)
(format nil "{\"~a\": [~{\"~a\"~^,~}]}~%" post_nr msg))
(t
(format nil "{\"Error\": \"~a\"}~%" msg))))))
(multiple-value-bind (result msg) (insert-post-p post_nr (cl-ppcre:split "," regions) board)
(cond
(result
(insert-post post_nr board msg)
(format nil "{\"~a\": [~{\"~a\"~^,~}]}~%" post_nr msg)) ;; This makes JSON
(t (format nil "{\"Error\": \"~a\"}~%" msg)))))
(handle :post (api-get :uri "/api/get")
(henh:handle :post (api-get :uri "/api/get") @json
(post_nrs board version)
(@json tbnl:*reply*)
(setf post_nrs (str:split "," post_nrs))
(cond
((and (loop for x in post_nrs always (post-number-p x))
(boardp board))
(format nil "~a~%" (get-posts post_nrs board)))
(t (format nil "~a~%" "bad"))))
(setf post_nrs (cl-ppcre:split "," post_nrs))
(if (get-posts-p post_nrs board)
(format nil "~a~%" (get-posts post_nrs board))
(t (format nil "{[\"~a\"]}~%" "bad"))))
(handle :get (api-flags :uri "/api/flags")
(henh:handle :get (api-flags :uri "/api/flags") @plain
()
(@plain tbnl:*reply*)
(format nil "~a~%" *flags-txt*))

@ -0,0 +1,4 @@
(defpackage #:bantflags
(:use #:cl)
(:export :init
:*serb*))

@ -1,13 +1,15 @@
(defvar empty-flag '("empty, or there were errors. Re-set your flags."))
;; (C) Copyright 2020 C-xC-c <boku@plum.moe>
;; This file is part of bantflags.
;; bantflags is licensed under the GNU AGPL Version 3.0 or later.
;; see the LICENSE file or <https://www.gnu.org/licenses/>
(in-package #:bantflags)
(defun conf (thing)
(let ((item (cdr (assoc thing config))))
(if (null item)
(error "no such config item" thing)
item)))
(defvar empty-flag '("empty, or there were errors. Re-set your flags."))
(defun cconf (thing)
(car (conf thing)))
(defun conf (thing &aux (item (cadr (assoc thing config))))
(if (null item)
(error "no such config item" thing)
item))
(defun set-boards ()
(setf *boards* (make-hash-table :test 'equal))
@ -15,21 +17,15 @@
(defun set-flags ()
(setf *flags* (make-hash-table :test 'equal))
(let ((flags (get-flags)))
(loop for (id . flag) in flags
do (setf (gethash (car flag) *flags*) id))
(setf *flags-txt*
(cl-ppcre:regex-replace "empty, or there were errors. Re-set your flags\\.\\n"
(setf *flags-txt* ;; We don't want users to select `empty-flag`
(cl-ppcre:regex-replace (concatenate 'string (car empty-flag) "\\n") ;; newline
(format nil "~{~a~^~%~}" (mapcan (lambda (x) (cdr x)) flags))
""))))
(defun set-db-conn ()
(setq conn-str (conf 'db-conn)))
(defun get-version (thing)
(if (null thing) 0
(or (parse-integer thing :junk-allowed t) 0)))
;; validation
(defun post-number-p (post_nr)
(if (or (null post_nr)
(null (parse-integer post_nr :junk-allowed t)))
@ -39,33 +35,25 @@
(defun boardp (board)
(gethash board *boards*))
(defun post-valid-p (post_nr regions board separator)
(let ((flags (str:split separator regions)))
(cond
((not (post-number-p post_nr))
(values nil "Invalid post number"))
((not (boardp board))
(values nil "Invalid board parameter."))
((null regions)
(values t empty-flag))
((< 30 (length flags))
(values nil "Too many flags."))
((loop for flag in flags
always (gethash flag *flags*))
(values t flags))
(t (values t empty-flag)))))
(defun host-dir (uri path)
(push
(hunchentoot:create-folder-dispatcher-and-handler uri path)
hunchentoot:*dispatch-table*))
;; This is uneccessarily complicated, no I'm not sorry
(defmacro content-type (types)
(cons 'progn
(mapcar (lambda (type) `(defun ,(car type) (reply)
(setf (tbnl:content-type* reply) ,(cadr type))))
types)))
(content-type
((@json "application/json")
(@plain "text/plain")))
(defun insert-post-p (post_nr regions board)
(cond
((not (post-number-p post_nr))
(values nil "Invalid post number."))
((not (boardp board))
(values nil "Invalid board parameter."))
((null regions)
(values t empty-flag))
((< 30 (length regions))
(values nil "Too many flags."))
((every (lambda (flag) (gethash flag *flags*)) regions)
(values t regions))
(t (values t empty-flag))))
(defun get-posts-p (post_nrs board)
(and (not (null post_nrs))
(every #'post-number-p post_nrs)
(boardp board)))
;; Content types
(defparameter @json "application/json")
(defparameter @plain "text/plain")

Loading…
Cancel
Save